KI Implementierung (MVP) auf Übungen #46

Merged
Lars merged 10 commits from develop into main 2026-05-22 10:38:39 +02:00
38 changed files with 3832 additions and 86 deletions

View File

@ -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)

View File

@ -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: TitelVorschlag, 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 (IstMinuten, 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.

View File

@ -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)

View File

@ -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 15, Coaching-Pakete 4a4d, 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 S0S6, 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`**.

View File

@ -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…“.
- **`<datalist>`** mit Titeln der aktuellen Treffer; **`autoComplete="on"`** für Browser-Vorschläge.
@ -76,14 +76,47 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
---
## 6. Frontend Übung bearbeiten (`ExerciseFormPage.jsx`)
## 6. Frontend Übung bearbeiten (`ExerciseFormPageRoot.jsx`)
**Routing:** `/exercises/new`, `/exercises/:id/edit` — keine separaten Varianten-Routen.
### 6.1 Tab-Navigation (Registerkarten)
Horizontale **`PageSectionNav`** über **`ExerciseFormTabBar`** / **`ExerciseFormPanel`** (`ExerciseFormLayout.jsx`); farbige linke Panel-Ränder (CSS `.exercise-form-edit`, `.exercise-form-panel--*`).
| Tab | Inhalt |
|-----|--------|
| **Stammdaten** | Titel, Kurztext, Dauer/Gruppe, Equipment, **Freigabelevel** (`visibility`), Status, Verein |
| **Anleitung** | Ziel, Durchführung, Vorbereitung, Trainerhinweise (Rich-Text inkl. Inline-Medien) |
| **Einordnung** | Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, Altersgruppen, **Fähigkeiten** (kompakte Chip-Editoren) |
| **Kombination** | nur bei `exercise_kind=combination`: Slots, Archetyp, `method_profile` |
| **Varianten** | nur nach erstem Speichern; **nicht** bei Kombinationsübungen |
| **Medien & Mehr** | Medien, Progressionsgraph, KI-Hilfen, Löschen — nach erstem Speichern |
Neue Übungen: Tabs **Varianten** und **Medien & Mehr** deaktiviert bis zur ersten Speicherung.
### 6.2 Freigabelevel (UI-Begriff)
Feld **`exercises.visibility`** heißt in der UI durchgängig **Freigabelevel** (`frontend/src/constants/exerciseGovernanceLabels.js`) — Liste, Filter, Bulk, Picker, Formular. API/DB-Feldname **`visibility`** unverändert.
### 6.3 Fähigkeiten am Übungsobjekt
- Intensität je Fähigkeit: **`niedrig` \| `mittel` \| `hoch`**, Standard **`mittel`** (`exerciseSkillIntensity.js`).
- Kein „Primär“-Schalter mehr in der UI; **`is_primary`** bei `exercise_skills` ist Legacy — Backend speichert immer **`false`**, Scoring ignoriert das Feld.
- Kompakte **Chip-Editoren** für Katalog-Zuordnungen und Fähigkeiten (`ExerciseCatalogAssocEditor`, `ExerciseSkillsEditor`).
### 6.4 Varianten-Editor
- Tab **Varianten**: **eine Variante zur Zeit** (Dropdown oder „Erste Variante anlegen“); Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Löschen pro Variante.
- **Speichern über Aktionsleiste:** `performSaveAttempt` ruft zuerst **`persistPendingVariantChanges()`** auf (geänderte Varianten per PUT, danach optional Entwurf **`createVariantFromDraft()`**).
- Button **„Variante anlegen“** (`type="button"`, kein verschachteltes `<form>`): legt Entwurf sofort per API an; alternativ mitgesichert über **Speichern** in der Aktionsleiste.
- Snapshot **`variantsSavedSnapshotRef`** für Dirty-Erkennung; Hinweis im Panel: Änderungen werden mit Speichern in der Aktionsleiste mitgesichert.
### 6.5 Medien & Progressionsgraph
- **Varianten-Editor**: eingeklappter Bereich (`<details>`), **eine Variante zur Zeit** über Dropdown oder „Neue Variante“; Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Speichern/Löschen pro Variante.
- **Medien:** Upload/Embed, **Archiv verknüpfen** (`from-asset`), Medienliste mit Vorschau, Reaktivierung bei Archiv-Konflikt — Details **§12**.
- Block **Progressionsgraph** (Edit): Kanten mit Bezug zur aktuellen Übung.
Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Bearbeitung erfolgt unter **`/exercises/:id/edit`** (Routing-Doku ggf. anpassen).
---
## 7. Frontend Übung Detail (`ExerciseDetailPage.jsx`)
@ -192,7 +225,21 @@ Norm: **`technical/SKILL_SCORING_SPEC.md`**.
---
## 16. Verweise
## 16. Übungen Governance & Berechtigungen (Ist, Stand 2026-05-20)
**Owner:** `exercises.created_by` (Ersteller). **Varianten** haben kein eigenes `created_by` — Rechte leiten sich von der Eltern-Übung ab.
| Aktion | `private` | `club` | `official` |
|--------|-----------|--------|------------|
| **Lesen** | Ersteller; Plattform-Admin | Aktive Vereinsmitglieder des Objekt-`club_id`; Plattform-Admin ohne Mitgliedschaft (Audit) | Plattform-weit |
| **Bearbeiten** (Übung inkl. Varianten/Medien) | Ersteller; Plattform-Admin | Ersteller; Plattform-Admin; **`can_plan_in_club`** im Objekt-Verein (`trainer`, `content_editor`, `division_lead`, `club_admin`) | Plattform-Admin |
| **Löschen** | Ersteller; Vereins-Admin gemeinsamer Vereine mit Ersteller | Nur **`club_admin`** im Objekt-Verein | Nur Plattform-Admin |
**Code:** `backend/club_tenancy.py` (`exercise_visible_to_profile`, `can_plan_in_club`), `backend/routers/exercises.py` (`_assert_can_edit_exercise`, `_assert_can_delete_exercise`).
---
## 17. Verweise
| Thema | Dokument |
|--------|----------|

View File

@ -119,8 +119,24 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` übergeordnetes Zielbild & Begriffe.
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
- `backend/club_tenancy.py` bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
- `backend/club_tenancy.py` bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, `can_plan_in_club`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
---
**Letzte Aktualisierung:** 2026-05-07
## 8. Anhang Übungen (Ist-Implementierung, Referenz)
**Stand:** 2026-05-20 · **Detail:** `EXERCISES_API_SPEC.md` Permissions, `FEATURES_DELIVERED_2026-Q2.md` §16
| Feld / Konzept | Semantik |
|----------------|----------|
| `created_by` | Owner der Übung; Varianten erben Rechte |
| `visibility` | UI: **Freigabelevel**`private` \| `club` \| `official` |
| Lesen | `exercise_visible_to_profile``official` global; `private` Ersteller + Plattform-Admin; `club` aktive Mitglieder (+ Plattform-Admin Audit) |
| Bearbeiten | Ersteller; Plattform-Admin; bei `club` zusätzlich `can_plan_in_club` (Trainer, Content-Editor, Spartenleitung, Vereins-Admin) |
| Löschen | `official` → Plattform-Admin; `club``club_admin` im Objekt-Verein; `private` → Ersteller oder Vereins-Admin mit gemeinsamem Verein |
**Hinweis:** Dieser Anhang dokumentiert den **produktiven Code-Pfad** in `exercises.py`; die Roadmap in §4 bleibt für die langfristige Vereinheitlichung aller Bibliotheksartefakte maßgeblich.
---
**Letzte Aktualisierung:** 2026-05-20

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
@ -174,10 +176,9 @@ Wähle maximal 5 passende Fähigkeiten. Für jede gib an:
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
- intensity: Trainingsintensität (niedrig|mittel|hoch)
- is_primary: true wenn Hauptfähigkeit
Antworte NUR als JSON-Array:
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch"}]
Wenn keine Fähigkeit passt, antworte mit [].$$,
'exercise', 'json', true, NULL, 2),

View File

@ -1,12 +1,14 @@
# KI-gestützte Trainingsplanung Zentrales Konzept
**Version:** 0.1
**Datum:** 2026-05-16
**Version:** 0.3
**Datum:** 2026-05-22
**Status:** Arbeitsdokument (Verfeinerung durch fachliche Konzept-Agentur vorgesehen)
**Ziel:** Einheitlicher Rahmen für **stufenweise** KI-Unterstützung bei der Planung (Abschnitte, Einheiten, später mehrtägig/Rahmen) ohne vollständigen Katalog im Prompt zu spiegeln.
**Ziel:** Einheitlicher Rahmen für **stufenweise** KI-Unterstützung zuerst **Übungsanlage** (Zusammenfassung, Fähigkeiten, Texte), später **Planung** (Abschnitte, Einheiten, Rahmen) ohne vollständigen Übungskatalog im Prompt.
**Maßgebende Version zum Abgleich:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, relevante Einträge in `MODULE_VERSIONS`).
**Verwandte Dokumente:**
`functional/DOMAIN_MODEL.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (u.a. CURR-003 zu Progressions-/KI-Automatik) · `technical/TRAINING_FRAMEWORK_SPEC.md` · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `docs/FACHLICHE_NUTZERFUNKTIONEN.md` · `docs/HANDOVER.md`
`functional/DOMAIN_MODEL.md` · **`functional/AI_EXERCISE_ASSISTANT_VISION.md`** (Übungs-KI: Zielbild vor Planungs-KI) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (u.a. CURR-003 zu Progressions-/KI-Automatik) · **`working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md`** (mehrstufige Planungs-KI: Daten-„Graph“, Pipeline-Stufen, Code-Schnitte Vorschau gegen späteres Refactoring) · `technical/TRAINING_FRAMEWORK_SPEC.md` · **`technical/SKILL_SCORING_SPEC.md`** (Fähigkeits-Profilierung, Discovery) · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/SKILLS_MATRIX_SPEC.md` · `docs/FACHLICHE_NUTZERFUNKTIONEN.md` · `docs/HANDOVER.md`
---
@ -16,13 +18,30 @@
- **Human-in-the-loop:** KI liefert **Vorschläge** (Liste, Reihenfolge, Begründung); schreibende Übernahme in Pläne nur nach **Trainer-Bestätigung** oder expliziter Aktion (analog „Manual First“ in `KI_FEATURES_SPEC.md`).
- **Governance-first:** Nur Übungen, die die API bereits für den Mandanten/Kontext **sichtbar** freigibt, dürfen in Kandidatenlisten landen **vor** Retrieval und **vor** jedem Prompt.
### 1.1 Abgleich: aktueller Code- und Schema-Stand (Stand Review 2026-05-22)
| Thema | Ist im Repo | Konsequenz für dieses Konzept |
|--------|-------------|-------------------------------|
| **OpenRouter / LLM im Backend** | Produktiver Aufruf für ÜbungsSuggest in `openrouter_chat.py`, `exercise_ai.py`; Endpunkte **`POST …/exercises/ai/suggest`** und **`POST …/{id}/ai/regenerate`**; Migration **067** (`ai_prompts`, `summary_ai_generated`). **`db.py`**-Bootstrap nutzt **`display_name`**. | **Übungs-Assistent (P0)** vorhanden; generalisierter Service + **Planungs-KI** folgen. |
| **Übungs-KI laut Spec** | P0: Kurzfassung + SkillVorschläge (`include_summary` / `include_skills`); **kein** Auto-KI beim Speichern (S5 im Umsetzungsplan). | Feinspez: `summary_ai_generated` bei manueller Kurzfassung zurücksetzen; Rate-Limits; Prompt-Admin-UI. |
| **Fähigkeiten-Stammdaten** | Migration **`065_skills_wiki_karate_relevance`:** `skills.karate_relevance` (Text), `skills.relevance_level` (13, optional); dazu weiterhin `description`, `focus_areas`, Kategorien, `skill_level_definitions` (Level 15 je Skill). | Diese Felder sind **expliziter Prompt-Kontext** für Skill-Vorschläge (Disambiguierung Karate vs. universal) siehe §6. |
| **Skill-Scoring & Discovery (ohne LLM)** | Router `skill_profiles.py` + Modul `skill_scoring.py`: u.a. `GET …/skill-profile` für **Rahmenprogramm**, **Trainingsmodul**, **Progressionsgraph**; `POST /skill-profiles/batch-summaries`; **`GET /api/skill-discovery/suggestions`** (Match Bibliotheksartefakte ⇄ `skill_ids`, mit `library_content_visibility_sql`). | Ergänzt §3 **Stufe 3**: deterministische **Skill-Abdeckung / Artefakt-Discovery** ist **bereits vorhanden** und kann später die **Planungs-KI** speisen (Ziel-Skill-Mengen, Vergleich „Profil des Rahmens“) ersetzt aber **nicht** die TopK-Selektion aus dem **Übungskatalog** für eine konkrete Session. |
| **Profil / Planungs-Präferenzen** | `profiles.training_planning_prefs` (JSONB, vgl. `MODULE_VERSIONS``profiles`), Planungsmodul mit u.a. **Vorlagen inkl. Split-Sessions** (`planning`), `training_units` mit **Publish in Rahmen-Slot-Blueprint**. | Zukünftige KI-Planung kann **Prefs** und **Vorlagen-Struktur** als weiche Constraints einbeziehen; Rahmen↔Einheit-Fluss ist produktiv erweitert für KI nur relevant, sobald Planungs-Endpunkte angebunden werden. |
| **Übungsliste API** | Keyset-Pagination u.a. `cursor_updated_at` + Tie-break `id` (`exercises`-Modul laut `MODULE_VERSIONS`). | Retrieval-Pipelines sollten **cursorbasiert** paginieren, nicht „alle IDs auf einmal“ laden. |
**Nächster produktiver Fokus:** Prompt-/AdminUI zur Pflege von `ai_prompts`, **Rate-Limits**, optional **Auto-KI beim Speichern**; danach Übergang zur **Planungs-KI** laut diesem Dokument.
**Architektur-Vorschau (Planungs-KI):** Damit die **kleinere, starre** Übungs-Pipeline nicht zur stillen Vorlage für Planung wird, sind **eigenes Modul**, **stufenweise Outputs mit Validierung** und ein **kompaktes Kontext-DTO** vorgesehen — siehe **`working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md`**.
---
## 2. Kernproblem: Skalierung des Kontextes
Aus einer **großen Übungssammlung** („>1000 Übungen“) können weder alle **Felder** (Ziele, Ablauf, Skills, Varianten …) noch alle **Zeilen** sinnvoll in einen LLM-Prompt.
**Festlegung:** Der LLM-Prompt erhält immer nur ein **begrenztes Kontext-Paket** mit:
**Abgrenzung Übungsanlage (aktueller Prioritätspfad):** Hier geht der Prompt typischerweise von **einzelnen** Freitexten (`title`, `goal`, `execution`, …) und einem **Skills-Katalog-Auszug** aus nicht vom gesamten Übungsbestand. Trotzdem gilt: Aktive Skills **paginieren** oder **stufig** laden (Subset + zweite Runde nur für Kurzliste), keine vollständigen Romane aus `skill_level_definitions` für hunderte Fähigkeiten auf einmal.
**Festlegung (Planungs-KI):** Der LLM-Prompt erhält immer nur ein **begrenztes Kontext-Paket** mit:
| Paketteil | Zweck |
|-----------|--------|
@ -69,7 +88,8 @@ Mindestens **eine** der folgenden Optionen kombinierbar:
1. **Skill-/Facet-Overlap:** Punktezahl, wenn Übungs-Skills mit Ziel-/Matrix-Schwerpunkten übereinstimmen (bereits Daten in `exercise_skills`).
2. **Diversitäts-/Wiederholungsstrafe:** häufig in letzten Wochen geübte Übungen abwerten.
3. **Textsuche:** PostgreSQL **`tsvector`/Volltext** auf `title`, `summary`, ggf. gekürzte `goal` für Trainer-Stichwort „Koordination Sprung“.
4. **Semantische Suche:** Embeddings + **Ähnlichkeitsuche** auf Kurztexte (siehe §5).
4. **Semantische Suche:** Embeddings + **Ähnlichkeitsuche** auf Kurztexte (siehe §5).
5. **Skill-Discovery über Planungs-Artefakte (bereits implementiert):** `GET /api/skill-discovery/suggestions` matching **Bibliotheksartefakte** (u.a. Rahmenprogramm, Trainingsmodul, Progressionsgraph) gegen gegebene `skill_ids`; `GET …/skill-profile` liefert **gewichtete Fähigkeitsprofile** aus den dort verknüpften Übungen (siehe `SKILL_SCORING_SPEC.md`). Das ist ein **deterministischer** Baustein für „welche Artefakte passen zu diesen Skills?“, **nicht** der Ersatz für **TopK-Übung**-Auswahl in einer konkreten Session dort weiter Stufen 12 + Punkte 14/LLM.
Ergebnis: sortierte Liste, **TopK** für den Prompt.
@ -128,7 +148,8 @@ Sinnvoller zeitlicher Punkt oder technische Auslöser:
RetrievalQualität hängt stärker an **Metadaten** als an der Embedding-Technologie allein:
- verlässliche **Skills** (`exercise_skills`, ggf. KI-geholfen bereits laut Spez beim Übungs-Anlegen);
- verlässliche **Skills** (`exercise_skills`, ggf. KI-geholfen bereits laut Spez beim Übungs-Anlegen); `exercise_skills.ai_suggested` und kanonische Stufen (`required_level` / `target_level` als Slugs) für Nachvollziehbarkeit.
- **`skills`-Stamm:** `description`, **`karate_relevance`**, **`relevance_level` (13)**, **`focus_areas`**, Kategorien/Keywords für **Prompt-Kontext** beim Skill-Mapping bei der Übungsanlage; optional **`skill_level_definitions`** für Stufen 15 **gezielt** in die zweite Prompt-Runde (nur Kurzliste Kandidaten).
- sinnvolle **`summary`**-Felder für Karten/Liste/KI-Pack;
- **Progressionsgraph** dort, wo pädagogische Ketten gefestigt sind;
- konsistente **Fokusbereich/Stil**-Zuordnung.
@ -139,15 +160,18 @@ Das fachliche Konzept sollte entscheiden: **wie viel automatische Pflege vs. Tra
## 7. Produkt-/Release-Stufen (Anknüpfung)
Priorität **jetzt**: **Übungsanlage**, danach **Planung**.
| Stufe | Nutzen | Technik-Schwerpunkt |
|-------|--------|---------------------|
| A | Backend-KI-Service + Prompt-Slugs unter `technical/AI_PROMPT_SYSTEM_SPEC.md` | OpenRouter, Timeouts, 503 ohne Key |
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 13 + Prompt mit TopK |
| **A0** | **Zentraler KI-Service** (ein Modul/Hilfslayer), Prompts aus `ai_prompts` | OpenRouter oder vereinbarter Provider, Timeouts, `503` ohne Key, Parsing/Validation |
| **A1** | **Übungsanlage** (vgl. `KI_FEATURES_SPEC`): `summary`, Skill-Vorschläge inkl. Stufen/Intensität, optional Textglättung | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate`; Prompt-Kontext: Skills mit `description`, `karate_relevance`, `relevance_level`, optional `skill_level_definitions` für Kurzliste; DB: `summary_ai_generated`, `exercise_skills.ai_suggested` |
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 13 + Prompt mit TopK (Übungsliste **keyset-pagination** beachten) |
| C | Reihenfolge / Zeitslots innerhalb bestehender Sektion | Graph + LLM Ranking |
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertere JSON-Ausgabe, strikte Schema-Validation |
| E | Mehreinheiten / RahmenAlignment | Ziele aus Rahmenprogramm, Serie von Slots |
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertes JSON + strikte Schema-Validation gegen bestehende `PUT`-Payloads |
| E | Mehreinheiten / RahmenAlignment | Ziele aus Rahmenprogramm, Serie von Slots; **Skill-Profile** (`…/skill-profile`) als Kontextuelle Verstärker |
Die **SelektionsPipeline §3 bleibt** über alle Stufen konsistent und wird nur parametrierbar erweitert.
Die **SelektionsPipeline §3** bleibt für **Planungs**-KI konsistent und wird parametrierbar erweitert; **§1.1** spiegelt den **aktuellen Implementierungs**-Vorsprung (Skill-Scoring ohne LLM) wider.
---

View File

@ -1,11 +1,12 @@
# Exercises API Specification
**Version:** 1.5
**Datum:** 2026-05-08
**Version:** 1.6
**Datum:** 2026-05-20
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
**Autor:** Claude Code
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
**Änderungen v1.6:** Freigabelevel-UI-Hinweis; `exercise_skills` ohne `is_primary` in Requests (Legacy-Feld wird ignoriert/forciert false); Permissions-Bereich an Ist-Code angeglichen; Intensität kanonisch `niedrig|mittel|hoch`
**Änderungen v1.5:** Medien-/Inline-Workflow aktualisiert (Modal-Picker, Drag&Drop UX im Frontend), Klarstellung zu `context` (legacy/optional), Hinweise zu Platzhaltern in Rich-Text-Feldern.
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
**Änderungen v1.3:** `GET /exercises` erweiterte Query-Parameter (`include_variants`, Multi-Filter, `ai_search`-Platzhalter); Dokumentation angepasst
**Änderungen v1.2:** KI-Assistenz Endpoints, Skill-Level-System (benannte Stufen), intensity als low/medium/high
**Änderungen v1.1:** Exercise Blocks Endpoints, Permissions dokumentiert, age_groups korrigiert
@ -185,11 +186,11 @@ Lightweight-Liste; bei `include_variants=true` zusätzlich z.B.:
"skill_id": 10,
"skill_name": "Distanzgefühl",
"skill_category": "Kumite",
"is_primary": true,
"intensity": "hoch",
"required_level": "grundlagen",
"target_level": "aufbau",
"ai_suggested": false
"ai_suggested": false,
"is_primary": false
}
],
@ -307,7 +308,6 @@ Lightweight-Liste; bei `include_variants=true` zusätzlich z.B.:
"skills": [
{
"skill_id": 10,
"is_primary": true,
"intensity": "hoch",
"required_level": "grundlagen",
"target_level": "aufbau"
@ -578,7 +578,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
"required_level": "grundlagen",
"target_level": "aufbau",
"intensity": "hoch",
"is_primary": true,
"confidence": 0.92
},
{
@ -588,7 +587,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
"required_level": "einsteiger",
"target_level": "grundlagen",
"intensity": "mittel",
"is_primary": false,
"confidence": 0.74
}
]
@ -621,6 +619,38 @@ Trainer muss im Frontend aktiv übernehmen.
## Permissions
**UI-Hinweis:** Das Feld `visibility` heißt in der Oberfläche **Freigabelevel** (`exerciseGovernanceLabels.js`).
### Lesen (`GET /exercises`, `GET /exercises/{id}`)
| `visibility` | Wer darf lesen? |
|--------------|-----------------|
| `official` | Plattform-weit |
| `private` | Ersteller (`created_by`); Plattform-Admin |
| `club` | Aktive Mitglieder des Objekt-`club_id`; Plattform-Admin ohne Mitgliedschaft (Audit-Zugang) |
Implementierung: `library_content_visible_to_profile` / `exercise_visible_to_profile` in `club_tenancy.py`.
### Bearbeiten (`PUT`, Varianten-CRUD, Medien an Übung)
| Bedingung | Wer darf bearbeiten? |
|-----------|----------------------|
| Ersteller | Immer (eigene Übung) |
| Plattform-Admin | Immer |
| `visibility=club` | Zusätzlich **`can_plan_in_club`** im Objekt-Verein: `club_admin`, `trainer`, `content_editor`, `division_lead` |
Implementierung: `_assert_can_edit_exercise` in `exercises.py`. **Varianten** haben kein eigenes Owner-Feld — gleiche Prüfung wie Eltern-Übung.
### Löschen (`DELETE /exercises/{id}`)
| `visibility` | Wer darf löschen? |
|--------------|-------------------|
| `official` | Nur Plattform-Admin |
| `club` | Nur **`club_admin`** im Objekt-Verein |
| `private` | Ersteller; oder Vereins-Admin, der mit dem Ersteller einen gemeinsamen Verein teilt |
Implementierung: `_assert_can_delete_exercise` in `exercises.py`.
### Sichtbarkeits-Workflow
| Von → Nach | Wer darf das? |
@ -638,11 +668,12 @@ Trainer muss im Frontend aktiv übernehmen.
| `club → official` | Club-Admin, Super-Admin |
| `official → club` | Super-Admin |
### Owner-Checks
### Owner-Checks (veraltet — siehe Tabellen oben)
- **Bearbeiten** (PUT): Nur Ersteller oder Club-Admin
- **Löschen** (DELETE): Nur Ersteller oder Super-Admin
- **Lesen** (`private`): Nur Ersteller
Die folgenden Kurzregeln sind durch die Ist-Implementierung ersetzt; nur zur historischen Einordnung:
- ~~Bearbeiten (PUT): Nur Ersteller oder Club-Admin~~ → siehe **Bearbeiten**-Tabelle (`can_plan_in_club`)
- ~~Löschen (DELETE): Nur Ersteller oder Super-Admin~~ → siehe **Löschen**-Tabelle
**403 Fehler-Beispiel:**
```json
@ -904,7 +935,8 @@ Trainer muss im Frontend aktiv übernehmen.
### Exercise Skills
- `required_level`: enum `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
- `target_level`: enum gleiche Werte (optional/nullable)
- `intensity`: enum `niedrig | mittel | hoch` (optional/nullable)
- `intensity`: enum **`niedrig | mittel | hoch`** (optional/nullable; Default beim Speichern **`mittel`**)
- `is_primary`: **Legacy** — Spalte existiert in DB, wird bei POST/PUT **nicht ausgewertet** (immer `false` gespeichert); UI liefert/speichert kein Primär-Flag mehr; Scoring ignoriert das Feld
- `target_level` sollte >= `required_level` sein (Warnung, kein Fehler)
### Exercise Block Item

View File

@ -99,20 +99,21 @@ Exercise Block ──── (N) Block Items ──── (1) Exercise
### 1.3 M:N Beziehungen (Primary/Secondary Pattern)
**Regel:** Alle Katalog-Zuordnungen nutzen M:N mit `is_primary` Flag.
**Regel:** Katalog-Zuordnungen (Fokus, Stil, Zielgruppe, …) nutzen M:N mit optionalem `is_primary`-Flag.
**Betroffene Relationen:**
**Betroffene Relationen (mit `is_primary`):**
- `exercise_focus_areas` (Übung ↔ Fokusbereiche)
- `exercise_styles` (Übung ↔ Trainingsstile)
- `exercise_styles` / `exercise_style_directions` (Übung ↔ Stilrichtungen)
- `exercise_training_types` (Übung ↔ Trainingsstile)
- `exercise_target_groups` (Übung ↔ Zielgruppen)
- `exercise_training_characters` (Übung ↔ Trainingscharaktere)
- `exercise_skills` (Übung ↔ Fähigkeiten)
**Primary/Secondary Semantik:**
**Ausnahme — `exercise_skills`:** Kein Primär-Flag in UI/API mehr; stattdessen **`intensity`** (`niedrig` \| `mittel` \| `hoch`, Default `mittel`). Spalte `is_primary` bleibt Legacy (Backend speichert immer `false`).
**Primary/Secondary Semantik (Katalog-Dimensionen):**
- **Primary:** Hauptzuordnung, entscheidend für Filter/Suche
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension
- **UI:** Primary wird visuell hervorgehoben (z.B. fett, farbig)
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension (wo UI das noch anbietet)
- **UI:** Primary wird visuell hervorgehoben (z. B. fett, farbig) — Fähigkeiten: Intensitäts-Segmente statt Primary
**Legacy-Felder (DEPRECATED):**
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`

View File

@ -1,9 +1,10 @@
# Frontend Routing & Navigation Specification
**Version:** 1.2
**Datum:** 2026-04-30
**Version:** 1.3
**Datum:** 2026-05-20
**Status:** DRAFT - Awaiting Review
**Autor:** Claude Code
**Änderungen v1.3:** Übungsformular Tab-Navigation unter `/exercises/:id/edit` (Stammdaten … Medien & Mehr); Freigabelevel als UI-Begriff
**Änderungen v1.2:** Übersicht **Übungen**: Tabs Liste \| Progressionsgraphen auf `/exercises`; Progressions-Editor ohne neue Routen (Panel + Formularblock unter `/exercises/:id/edit`)
**Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
@ -17,7 +18,7 @@
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
/exercises/new → ExerciseFormPage (Create)
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline + Block Progressionsgraph)
/exercises/{id}/edit → ExerciseFormPage (Edit: Registerkarten + Varianten inline + Progressionsgraph)
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
/exercise-blocks/new → ExerciseBlockFormPage (Create)
@ -35,6 +36,25 @@
- Pagination: `/exercises?limit=50&offset=100`
- Sortierung: `/exercises?sort=created_at&order=desc`
### 1.2 Übungsformular Registerkarten (`/exercises/new`, `/exercises/:id/edit`)
**Implementierung:** `ExerciseFormPageRoot.jsx` + `ExerciseFormLayout.jsx` (`ExerciseFormTabBar`, `ExerciseFormPanel`).
| Tab-ID | Label | Verfügbarkeit |
|--------|-------|---------------|
| `stammdaten` | Stammdaten | immer |
| `anleitung` | Anleitung | immer |
| `einordnung` | Einordnung | immer |
| `kombination` | Kombination | nur `exercise_kind=combination` |
| `varianten` | Varianten | Edit-Modus; nicht bei Kombination; disabled bei Neuanlage |
| `medien` | Medien & Mehr | Edit-Modus; disabled bei Neuanlage |
**UX-Regeln:**
- Nur ein Panel sichtbar (`activeFormTab`); Navigation über `PageSectionNav`.
- **Freigabelevel** (Feld `visibility`) in Stammdaten — Konstante `EXERCISE_VISIBILITY_FIELD_LABEL`.
- Varianten-Änderungen werden mit **Speichern** in der Aktionsleiste persistiert (`persistPendingVariantChanges`); Button „Variante anlegen“ optional sofort.
- Kein URL-Hash pro Tab (Tab-State nur lokal).
---
## 2. Navigation-Patterns
@ -673,7 +693,7 @@ function App() {
---
**Version:** 1.2
**Letzte Änderung:** 2026-04-30
**Version:** 1.3
**Letzte Änderung:** 2026-05-20
**Status:** REVIEWED - Pending Implementation
**Review-Änderungen:** Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)
**Review-Änderungen:** Formular-Registerkarten; Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)

View File

@ -7,11 +7,16 @@
**Änderungen v1.1:** Prompts sind nicht hardcoded sie werden aus der DB geladen (AI_PROMPT_SYSTEM_SPEC.md)
**Verwandte Specs:** AI_PROMPT_SYSTEM_SPEC.md (Prompt-DB + Platzhalter), SKILLS_MATRIX_SPEC.md (Fähigkeitsmatrix)
**Übergeordnete Produkt-Vision** (breiter Scope: Zielausbau, bereichsweise vs. Gesamtüberarbeitung, Varianten, Planungs-/Nachbereitungskontext, Admin-Masse):
`functional/AI_EXERCISE_ASSISTANT_VISION.md`
---
## 1. Übersicht
Zwei KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen:
KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen (Mindestpaket dieser Spec):
**Hinweis:** Die beiden folgenden Zeilen entsprechen **P0** der Phasierung in **`AI_EXERCISE_ASSISTANT_VISION.md`**; spätere Funkteile sind dort beschrieben.
| Funktion | Ziel |
|---------|------|
@ -155,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",
@ -164,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
{
@ -182,7 +216,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
"required_level": "grundlagen",
"target_level": "aufbau",
"intensity": "hoch",
"is_primary": true,
"confidence": 0.92
},
{
@ -192,7 +225,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
"required_level": "einsteiger",
"target_level": "grundlagen",
"intensity": "mittel",
"is_primary": false,
"confidence": 0.74
}
]

View File

@ -13,6 +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; 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) |
@ -33,17 +34,21 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| 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-13 — `GET /api/dashboard/kpis` (Kurzüberblick-Aggregat).
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.
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.

View File

@ -0,0 +1,67 @@
# Umsetzungsplan KI bei Übungen (stufenweise, Driftschutz)
**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)
---
## 1. Drift vermeiden verbindliche Regeln
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. **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.
---
## 2. Stufen (Releases)
| Stufe | Inhalt | Exit-Kriterium |
|-------|--------|------------------|
| **S0** | Dieses Dokument + Verweise konsistent | Review abgehakt |
| **S1** | Migration `ai_prompts` + Defaults `exercise_summary`, `exercise_skill_suggestions`; `exercises.summary_ai_generated` | Migrierte DB, App startet |
| **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:** **S4 + S4b** im Code (`exercise_ai` + Formular übermittelt `focus_areas_context`).
---
## 3. Implementierungs-Checkliste (Technik)
- [ ] `OPENROUTER_API_KEY` / `OPENROUTER_MODEL` in `.env.example` dokumentiert (bereits teils vorhanden prüfen).
- [ ] Fehlerbilder: `400` zu wenig Inhalt, `503` KI nicht konfiguriert, `502` Upstream-Fehler mit kurzer Message.
- [ ] Logging: **keine** vollständigen Prompts mit personenbezogenen Daten in Prod-Logs (optional DEBUG).
- [ ] Optional: Rate-Limit KI-Endpunkte (`slowapi`) nach Bedarf.
- [ ] `MODULE_VERSIONS["exercises"]` / Changelog bei API-Erweiterung setzen.
---
## 4. Changelog dieses Plans
- **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-29:** **S4b:** Migration **068**, `ai_skill_retrieval_profiles`; suggest `focus_areas_context`; Frontend sendet gesetzte Fokusbereiche; Spec `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`.
---
## 5. Umsetzungsstand (Zwischencheckpoint)
**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.
**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,112 @@
# Mehrstufige KI für Trainingsplanung Architektur-Vorschau (Anti-Refactoring)
**Version:** 0.1
**Datum:** 2026-05-22
**Status:** Planungs-/Architektur-Arbeitspapier (keine Implementierungspflicht)
**Ziel:** Für die **spätere** Planungs-KI bereits **Schnittstellen und Schichten** vorzeichnen, damit die **kleinere, starre** Übungs-KI nicht zur impliziten Vorlage für einen viel größeren Kopf wird — **ohne** jetzt eine Mitai-artige Workflow-Engine zu bauen.
**Bezüge:** `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/SKILL_SCORING_SPEC.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-003) · Schwesterprojekt Mitai: `c:/dev/mitai-jinkendo` (Referenz: `prompt_executor`, `placeholder_resolver`, `workflow_*`**nicht** Pflicht-Port).
---
## 1. Zwei getrennte Produktlinien (bewusst entkoppelt)
| Linie | Rolle | Orchestrator |
|--------|--------|----------------|
| **Übungs-KI** | wenige Eingaben → Kurzfassung / Skills; **starrer** Ablauf (12 Calls), kleines Kontextfenster | z.B. `exercise_ai.py` (heute) |
| **Planungs-KI** | Gruppe, Zeit, Ziele, Historie, Katalogausschnitt, Phasen/Streams → **strukturierte Planelemente** | **eigenes** Modul + **mehrstufig** (siehe §3) |
**Regel:** Shared Library nur auf **niedriger Ebene** (`openrouter_chat`-Art: HTTP, Timeouts, Modellname, Fehler-Mapping) und **gemeinsame Prompt-Tabelle** `ai_prompts`. **Keine** Vermischung der Geschäftslogik „Übung erstellen“ mit „Einheit füllen“, um später keine Abhängigkeiten reißen zu müssen.
---
## 2. Konzeptioneller „Planungs-Graph“ (Daten, nicht zwingend Graph-DB)
Für die Planungs-KI ist ein **Graph als Denkmodell** hilfreich — technisch reicht meist **PostgreSQL + bestehende FKs** (+ optional `exercise_progression_graphs`):
**Knoten-Typen (Auszug):** `training_groups`, `training_units`, `training_unit_sections` / Items, `exercises`, `skills`, `training_framework_programs` / Slots / Goals, ggf. Nachbearbeitungs-/Debrief-Metadaten.
**Kanten-Typen (Auszug):**
- **Zeitliche Folge:** Einheiten einer Gruppe nach `planned_date` / Reihenfolge
- **Inhalt:** Section-Item → `exercise_id` (± Variante)
- **Ziele:** Slot-/Framework-Ziele, Kopf-Notizen, Trainer-Zieltexte
- **Progression:** Kanten aus `exercise_progression_graphs` (optional erweitern um „empfohlene Folge im Gruppenkontext“, bleibt Spekulationsfeld)
- **Skills:** bereits über `exercise_skills`; aggregiert über `skill_scoring`-Pfad
**Wichtig:** Für KI **nicht** einen Riesen-Graphen serialisieren, sondern **Projektionen** („letzte *N* Einheiten“, „Nachbarn im Progressionsgraph zu zuletzt verwendeten Übungen“, „Skill-Gap Heuristik“).
---
## 3. Mehrstufiger Prozess (Pflichtidee für Planungs-KI)
Statt einem Prompt „mach den ganzen Plan“ mehrere **Schritte mit kleinen, validierbaren Outputs**:
| Stufe | Beispiel-Aufgabe | Deterministisch möglich? | Typischer LLM-Einsatz |
|-------|-------------------|--------------------------|------------------------|
| **S0** | Governance + Filter + Historie + Slot-Ziele zusammenstellen | Ja (SQL/API) | Nein |
| **S1** | Kandidaten-Übungen auf TopK schrumpfen (Skills, Volltext, Score, Wiederholungsstrafe) | Teilweise | Optional Ranking |
| **S2** | Reihenfolge je Section / Phase unter Constraints (Aufwärmen, Graphen-Nachbarn) | Teilweise | Ja (auf kleiner Liste) |
| **S3** | Zeiten auf Section/Item vorschlagen oder Plausibilisieren | Teilweise | Ja |
| **S4** | Trainer-sprachliche Kurzbegründung / Alternativen | Nein | Ja |
**Zwischen jeder Stufe:** starkes **Schema / Validierung** (z.B. nur erlaubte `exercise_id`s, nur erlaubte Slot-Struktur zu Phasen/Streams). So bleibt das System auch bei Modell-Fehlern stabil.
---
## 4. Schnittstellen-Vorsorge im Code (ohne Big-Bang)
Minimal-Ausbaustufe später, die Refactoring vermeidet:
1. **`PlanningContextPack` (internes DTO)** — reines Python-`dict`/`dataclass` oder Pydantic: aggregierte, **tokenbewusst gekürzte** Ansicht (Gruppe, nächste Einheit-Ziele, Historie-IDs, TopK-Kandidaten, Constraints).
2. **`planning_ai_steps` als rein **funktionale** Pipeline** — jede Stufe `(context) → context` oder `(context) → partial_suggestion`; keine globale „Prompt-String-Bastelei“ überall im Router.
3. **Prompt-Slugs pro Stufe** in `ai_prompts` (analog Übung), z.B. `planning_rank_section_items`, `planning_explain_sequence`, mit **eigenem** Platzhalter-Katalog (nicht `{{skills_catalog}}` aus Übungen recyclen).
4. **Router** `training_planning.py` (oder neuer `planning_ai.py`): nur **dünne** HTTP-Schicht, ruft Orchestrator.
Optional **später**, wenn nötig: zweite Tabelle `ai_prompt_chains` oder externe Workflow-Definition — **erst** wenn 34 feste Stufen nicht mehr reichen. Mitai-Workflow-Engine dann **bewusste** Option, kein Default.
---
## 5. Kontextfenster und „Kaskade“
**Kerngedanke:** Je Stufe nur **neue** Information hinzufügen, die vorherige Stufen **ersetzen** oder **verdichten**, nicht duplizieren.
Beispiel:
- Stufe A (LLM oder Heuristik): „Priorisierte Skill-Ziele für diese Session“ (kurz)
- Stufe B: Top40 Übungen mit **einer** Zeile pro Übung
- Stufe C: Reihenfolge für 8 IDs + 2-Satz-Begründung
So bleibt dieselbe fachliche Tiefe erreichbar ohne Kontext-Explosion.
---
## 6. Schnittstellen zu bereits vorhandenen Bausteinen
- **`skill_profiles` / `skill-discovery`:** liefern **deterministische** Ziel-/Profil-Signale für S0/S1 (`SKILL_SCORING_SPEC.md`).
- **`training_planning_prefs`:** weiche Constraints (Tone, Dauer, Split-Vorlieben).
- **`exercise_progression_graphs`:** lokale Nachbarschaft um „zuletzt verwendet“.
- **Mitai-Referenz:** Platzhalter-Katalog + Preview-API als **Inspiration** für Admin-UX; Workflow-Graph nur wenn Shinkan **wirklich** viele verzweigte Pipelines braucht.
---
## 7. Was wir **nicht** jetzt tun müssen
- Keine zweite Graph-Datenbank nur für KI.
- Keine Workflow-UI-Kopie aus Mitai.
- Keine Vereinheitlichung der Übungs-KI mit Planungs-KI über einen „Mega-Orchestrator“.
---
## 8. Kurz-Checkliste „Refactoring vermeiden“ vor erster Planungs-KI-Zeile Code
- [ ] Eigenes Modulbaum-„Root“ für Planung (nicht `exercise_ai` erweitern).
- [ ] Prompt-Slugs mit **Planungs-**Präfix und **eigenem** Platzhalter-Set dokumentieren.
- [ ] Outputs pro Stufe **JSON-Schema** oder Pydantic validieren.
- [ ] Kandidatenlisten **immer** serverseitig auf erlaubte IDs begrenzen.
---
## 9. Changelog
- **2026-05-22:** Erstfassung als Vorschau-Dokument für mehrstufige Planungs-KI.

View File

@ -0,0 +1,121 @@
# 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 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 + UIRoute dokumentiert (`/admin/ai-skill-retrieval`).
- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration.

View File

@ -35,6 +35,10 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_MODEL=anthropic/claude-sonnet-4
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).
# SHINKAN_AI_DEBUG=1
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@jinkendo.de

View File

@ -180,12 +180,17 @@ def init_db():
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
if cur.fetchone()['count'] == 0:
cur.execute("""
INSERT INTO ai_prompts (slug, name, description, template, active, sort_order)
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, active, sort_order
)
VALUES (
'pipeline',
'Mehrstufige Gesamtanalyse',
'Master-Schalter für die gesamte Pipeline. Deaktiviere diese Analyse, um die Pipeline komplett zu verstecken.',
'Master-Schalter fuer die gesamte Pipeline. Deaktiviere diese Zeile um die Pipeline zu verstecken.',
'PIPELINE_MASTER',
'admin',
'text',
true,
-10
)

813
backend/exercise_ai.py Normal file
View File

@ -0,0 +1,813 @@
"""
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 logging
import math
import os
import re
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple
from fastapi import HTTPException
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
_LOGGER = logging.getLogger("shinkan.exercise_ai")
def _ai_debug_on() -> bool:
return os.getenv("SHINKAN_AI_DEBUG", "").strip().lower() in ("1", "true", "yes", "full")
_CANONICAL_SKILL_LEVELS = frozenset({"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"})
_LEGACY_SKILL_LEVEL_SLUG = {
"einsteiger": "basis",
"experte": "optimierung",
"1": "basis",
"2": "grundlagen",
"3": "aufbau",
"4": "fortgeschritten",
"5": "optimierung",
}
_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
_MAX_SANITIZE_SKILL_INPUT_ROWS = 250
_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:
return None
s = str(value).strip().lower()
if not s:
return None
if s in _CANONICAL_SKILL_LEVELS:
return s
return _LEGACY_SKILL_LEVEL_SLUG.get(s)
def _normalize_exercise_skill_intensity(value) -> str:
if value is None:
return "mittel"
key = str(value).strip().lower()
if key in ("low",):
return "niedrig"
if key in ("medium",):
return "mittel"
if key in ("high",):
return "hoch"
if key in _ALLOWED_SKILL_INTENSITY:
return key
return "mittel"
def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) -> str:
if not html:
return ""
t = _TAG_RE.sub(" ", str(html))
t = re.sub(r"\s+", " ", t).strip()
if len(t) > max_len:
t = t[: max_len - 1].rstrip() + ""
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(
"""
SELECT slug, display_name, template, output_format, active
FROM ai_prompts
WHERE slug = %s
""",
(slug,),
)
row = cur.fetchone()
if not row:
return None
d = dict(row)
if not d.get("active", True):
return None
return d
def _render_template(template: str, ctx: Dict[str, str]) -> str:
out = template or ""
for key, val in ctx.items():
placeholder = "{{" + key + "}}"
out = out.replace(placeholder, val if val is not None else "")
return out
def _first_balanced_json_array(text: str) -> Optional[str]:
"""Findet das erste vollständig geschlossene Top-Level-JSON-Array in beliebigem Fließtext."""
i = text.find("[")
if i < 0:
return None
depth = 0
in_str = False
esc = False
for j in range(i, len(text)):
ch = text[j]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
continue
if ch == "[":
depth += 1
elif ch == "]":
depth -= 1
if depth == 0:
return text[i : j + 1]
return None
def _extract_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()
if s.startswith("["):
end = s.rfind("]")
if end > 0:
s = s[: end + 1]
parsed = json.loads(s)
if isinstance(parsed, list) and len(parsed) > _MAX_SANITIZE_SKILL_INPUT_ROWS:
parsed = parsed[:_MAX_SANITIZE_SKILL_INPUT_ROWS]
return parsed
if s.startswith("{"):
obj = json.loads(s)
if isinstance(obj, dict):
for k in ("skills", "items", "data"):
v = obj.get(k)
if isinstance(v, list):
if len(v) > _MAX_SANITIZE_SKILL_INPUT_ROWS:
return v[:_MAX_SANITIZE_SKILL_INPUT_ROWS]
return v
raise ValueError("JSON-Objekt ohne Skills-Liste")
parsed_end = json.loads(s)
if isinstance(parsed_end, list) and len(parsed_end) > _MAX_SANITIZE_SKILL_INPUT_ROWS:
return parsed_end[:_MAX_SANITIZE_SKILL_INPUT_ROWS]
return parsed_end
def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
if not isinstance(rows, list):
return []
out: List[Dict[str, Any]] = []
cap = rows[:_MAX_SANITIZE_SKILL_INPUT_ROWS]
for raw in cap:
if len(out) >= 5:
break
if not isinstance(raw, dict):
continue
sid = raw.get("skill_id")
try:
skill_id = int(sid)
except (TypeError, ValueError):
continue
cur.execute(
"""
SELECT s.id, s.name, s.category,
sc.name AS subcategory_name
FROM skills s
LEFT JOIN skill_categories sc ON s.category_id = sc.id
WHERE s.id = %s AND (s.status IS NULL OR s.status = 'active')
""",
(skill_id,),
)
sk = cur.fetchone()
if not sk:
continue
req = _normalize_exercise_skill_level(raw.get("required_level")) or "grundlagen"
tgt = _normalize_exercise_skill_level(raw.get("target_level")) or req
if req not in _CANONICAL_SKILL_LEVELS:
req = _LEGACY_SKILL_LEVEL_SLUG.get(str(raw.get("required_level") or "").strip().lower(), "grundlagen")
if req not in _CANONICAL_SKILL_LEVELS:
req = "grundlagen"
if tgt not in _CANONICAL_SKILL_LEVELS:
tgt = _LEGACY_SKILL_LEVEL_SLUG.get(str(raw.get("target_level") or "").strip().lower(), req)
if tgt not in _CANONICAL_SKILL_LEVELS:
tgt = req
inten = _normalize_exercise_skill_intensity(raw.get("intensity"))
is_primary = bool(raw.get("is_primary")) if raw.get("is_primary") is not None else len(out) == 0
cat = (sk.get("category") or "").strip()
sub = (sk.get("subcategory_name") or "").strip()
skill_category = " / ".join(x for x in (cat, sub) if x) or (cat or None)
conf = raw.get("confidence")
try:
conf_f = float(conf) if conf is not None else None
except (TypeError, ValueError):
conf_f = None
item: Dict[str, Any] = {
"skill_id": skill_id,
"skill_name": (sk.get("name") or "").strip() or f"Skill #{skill_id}",
"required_level": req,
"target_level": tgt,
"intensity": inten,
"is_primary": is_primary,
}
if skill_category:
item["skill_category"] = skill_category
if conf_f is not None:
item["confidence"] = conf_f
out.append(item)
return out[:5]
def _require_openrouter() -> Tuple[str, str]:
key, model = normalize_openrouter_env()
if not key:
raise HTTPException(
status_code=503,
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
)
return key, model
def run_exercise_ai_suggestion(
cur,
*,
title: Optional[str],
goal: Optional[str],
execution: Optional[str],
focus_area_hint: Optional[str],
focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
want_summary: bool,
want_skills: bool,
) -> Dict[str, Any]:
key, model = _require_openrouter()
g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution)
if not (g_plain.strip() or e_plain.strip()):
raise HTTPException(
status_code=400,
detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).",
)
t_title = (title or "").strip()
focus = (focus_area_hint or "").strip()
result: Dict[str, Any] = {"model": model}
if _ai_debug_on():
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
_LOGGER.warning(
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s "
"exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
want_summary,
want_skills,
len(t_title),
len(g_plain),
len(e_plain),
len(focus),
fid_list,
)
if want_summary:
prow = _load_prompt_row(cur, "exercise_summary")
if not prow:
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
ctx = {
"exercise_title": t_title or "-",
"exercise_focus_area": focus or "-",
"exercise_goal": g_plain or "-",
"exercise_execution": e_plain or "-",
}
prompt = _render_template(str(prow["template"]), ctx)
if _ai_debug_on():
_LOGGER.warning(
"AI_DEBUG exercise_ai summary prompt_slug=exercise_summary prompt_chars=%s unreplaced_mustache_pairs=%s",
len(prompt),
prompt.count("{{"),
)
try:
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
if _ai_debug_on():
_LOGGER.warning("AI_DEBUG exercise_ai summary response_chars=%s", len(raw or ""))
text = (raw or "").strip()
if not text:
raise HTTPException(
status_code=502,
detail="OpenRouter/KI lieferte eine leere Kurzfassung (kein Modelltext).",
)
if len(text) > _MAX_SUMMARY_CHARS:
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + ""
result["summary"] = {"text": text, "ai_generated": True, "model": model}
if want_skills:
srow = _load_prompt_row(cur, "exercise_skill_suggestions")
if not srow:
raise HTTPException(
status_code=503,
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
)
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 "-",
"exercise_goal": g_plain or "-",
"exercise_execution": e_plain or "-",
"skills_catalog": catalog,
}
prompt = _render_template(str(srow["template"]), ctx)
if _ai_debug_on():
_LOGGER.warning(
"AI_DEBUG exercise_ai skills prompt_slug=exercise_skill_suggestions catalog_chars=%s prompt_chars=%s "
"template_has_skills_placeholder=%s",
len(catalog),
len(prompt),
"{{skills_catalog}}" in str(srow.get("template") or ""),
)
sys_hint = (
"Du antwortest nur mit validem JSON (Array). Keine Kommentare, keine Erklaerungen ausserhalb des JSON."
)
try:
raw = openrouter_chat_completion(
api_key=key,
model=model,
user_content=prompt,
system_content=sys_hint,
temperature=0.15,
)
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
if _ai_debug_on():
_LOGGER.warning("AI_DEBUG exercise_ai skills response_chars=%s", len(raw or ""))
body = (raw or "").strip()
if not body:
raise HTTPException(
status_code=502,
detail="OpenRouter/KI lieferte leeren Inhalt für Skill-JSON.",
)
frag = _first_balanced_json_array(body)
if frag:
body = frag
try:
parsed = _extract_json_array(body)
except (json.JSONDecodeError, ValueError) as e:
if _ai_debug_on():
_LOGGER.warning(
"AI_DEBUG exercise_ai skills JSON parse_failed err=%s head=%s",
e,
(body.replace("\r", "").replace("\n", " ").strip())[:400],
)
raise HTTPException(
status_code=502,
detail="KI lieferte kein verwertbares JSON fuer Skills.",
) from e
skills = _sanitize_skill_entries(cur, parsed)
if _ai_debug_on():
cand_n = len(parsed) if isinstance(parsed, list) else -1
_LOGGER.warning("AI_DEBUG exercise_ai skills parsed_len=%s sanitized_kept=%s", cand_n, len(skills))
result["skills"] = skills
return result
__all__ = [
"build_contextual_skills_catalog_block",
"run_exercise_ai_suggestion",
"strip_html_to_plain",
]

View File

@ -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 <img>/<video>).

View File

@ -0,0 +1,141 @@
-- Migration 067: Konfigurierbare KI-Prompts + Tracking-Feld fuer Uebungs-Zusammenfassung
-- Datum: 2026-05-22
-- Spec: technical/KI_FEATURES_SPEC.md, AI_PROMPT_SYSTEM_SPEC.md
-- ============================================================================
-- AI PROMPTS
-- ============================================================================
CREATE TABLE IF NOT EXISTS ai_prompts (
id SERIAL PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE,
display_name VARCHAR(200) NOT NULL,
description TEXT,
template TEXT NOT NULL,
category VARCHAR(50) DEFAULT 'exercise'
CHECK (category IN ('exercise', 'training', 'matrix', 'import', 'admin')),
output_format VARCHAR(10) DEFAULT 'text'
CHECK (output_format IN ('text', 'json')),
output_schema JSONB,
is_system_default BOOLEAN DEFAULT false,
default_template TEXT,
active BOOLEAN DEFAULT true,
sort_order INT DEFAULT 0,
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ai_prompts_slug ON ai_prompts(slug);
CREATE INDEX IF NOT EXISTS idx_ai_prompts_category ON ai_prompts(category);
CREATE INDEX IF NOT EXISTS idx_ai_prompts_active ON ai_prompts(active, sort_order);
DROP TRIGGER IF EXISTS ai_prompts_update ON ai_prompts;
CREATE TRIGGER ai_prompts_update
BEFORE UPDATE ON ai_prompts
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
-- ============================================================================
-- TRACKING SUMMARY (KI)
-- ============================================================================
ALTER TABLE exercises ADD COLUMN IF NOT EXISTS summary_ai_generated BOOLEAN DEFAULT false;
COMMENT ON COLUMN exercises.summary_ai_generated IS 'TRUE wenn Kurzbeschreibung zuletzt von KI vorgeschlagen und uebernommen (UI setzt bei manueller Aenderung false)';
-- ============================================================================
-- SEED PROMPTS (idempotent)
-- ============================================================================
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, is_system_default, default_template, active, sort_order
)
SELECT
'pipeline',
'Mehrstufige Gesamtanalyse',
'Master-Schalter fuer die Pipeline-Anzeige.',
'PIPELINE_MASTER',
'admin',
'text',
false,
'PIPELINE_MASTER',
true,
-10
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'pipeline');
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, is_system_default, default_template, active, sort_order
)
SELECT
'exercise_summary',
'Uebungs-Zusammenfassung',
'Erzeugt eine kurze Kurzbeschreibung fuer Listen/Galerie.',
$s$Du bist Assistent fuer Kampfsport-Trainer.
Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
Anforderungen:
- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
- Sachlich, auf Deutsch
Uebung: {{exercise_title}}
Fokuskontext: {{exercise_focus_area}}
Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$,
'exercise',
'text',
true,
NULL,
true,
1
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_summary');
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, is_system_default, default_template, active, sort_order
)
SELECT
'exercise_skill_suggestions',
'Faehigkeiten-Empfehlungen',
'Schlaegt passende Skills mit Stufen/Intensitaet vor (JSON-Ausgabe-Prompt).',
$j$Du bist Assistent fuer Kampfsport-Trainer.
Ordne diese Uebung dem globalen Skill-Katalog zu.
Daten zur Uebung:
Titel: {{exercise_title}}
Fokuskontext (optional): {{exercise_focus_area}}
Ziel (gekuerzt_plain): {{exercise_goal}}
Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs keine anderen IDs verwenden):
{{skills_catalog}}
Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
- skill_id: ganze Zahl aus der Liste
- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
- target_level: derselbe Wertvorrat
- intensity: eines von niedrig, mittel, hoch
- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
Beispielformat:
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
Wenn nichts gut passt, antworte mit [].$j$,
'exercise',
'json',
true,
NULL,
true,
2
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_skill_suggestions');

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;

205
backend/openrouter_chat.py Normal file
View File

@ -0,0 +1,205 @@
"""
Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MODEL / OPENROUTER_BASE_URL from env.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict, List, Optional
import httpx
_logger = logging.getLogger("shinkan.openrouter")
_SKIP_ANTHROPIC_BLOCK_TYPES = frozenset(
{
"thinking",
"redacted_thinking",
"reasoning",
"tool_use",
"tool_calls",
}
)
def _shinkan_ai_debug() -> bool:
return os.getenv("SHINKAN_AI_DEBUG", "").strip().lower() in ("1", "true", "yes", "full")
def _coerce_nested_text(val: Any) -> str:
if val is None:
return ""
if isinstance(val, str):
return val.strip()
if isinstance(val, bool) or isinstance(val, (int, float)):
return str(val).strip()
if isinstance(val, list):
return "".join(_coerce_nested_text(x) for x in val).strip()
if isinstance(val, dict):
# OpenRouter/Anthropic: verschachtelte text/content-Hüllen
for key in ("text", "content", "value"):
if key in val:
nested = _coerce_nested_text(val.get(key))
if nested:
return nested
return ""
return str(val).strip()
def _flatten_message_content(content: Any) -> str:
"""
Chat-Completion: `content` als String oder als Liste strukturierter Blöcke
(Anthropic Claude über OpenRouter/Bedrock, teils verschachtelt).
"""
if content is None:
return ""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: List[str] = []
for block in content:
if isinstance(block, str):
bits = _coerce_nested_text(block)
if bits:
parts.append(bits)
elif isinstance(block, dict):
t_raw = block.get("type")
ts = str(t_raw or "").strip().lower()
if ts and (ts in _SKIP_ANTHROPIC_BLOCK_TYPES or ts.endswith("_thinking")):
continue
txt = None
if ts in ("text", "output_text", ""):
txt = block.get("text")
if txt is None:
txt = block.get("content")
else:
# unbekannten Typ weiter versuchen (Provider-Varianten), aber tool-use überspringen
low = ts
if "tool_use" in low or low.startswith("tool_"):
continue
txt = block.get("text") if block.get("text") is not None else block.get("content")
bits = _coerce_nested_text(txt)
if bits:
parts.append(bits)
return "".join(parts).strip()
if isinstance(content, dict):
return _coerce_nested_text(content)
return str(content).strip()
class OpenRouterError(Exception):
"""Upstream or transport failure."""
def openrouter_chat_completion(
*,
api_key: str,
model: str,
user_content: str,
system_content: Optional[str] = None,
timeout_sec: float = 120.0,
temperature: float = 0.25,
site_url: Optional[str] = None,
app_title: Optional[str] = None,
) -> str:
"""
Returns assistant message content (plain string). Caller validates empty responses.
"""
base = (os.getenv("OPENROUTER_BASE_URL") or "").strip().rstrip("/") or "https://openrouter.ai/api/v1"
url = f"{base}/chat/completions"
headers: Dict[str, str] = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
referer = (site_url or os.getenv("APP_URL") or "").strip()
if referer:
headers["HTTP-Referer"] = referer
title = (app_title or os.getenv("OPENROUTER_APP_TITLE") or "Shinkan Jinkendo").strip()
if title:
headers["X-Title"] = title
messages: List[Dict[str, str]] = []
if system_content and str(system_content).strip():
messages.append({"role": "system", "content": str(system_content).strip()})
messages.append({"role": "user", "content": user_content})
payload: Dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
}
try:
with httpx.Client(timeout=timeout_sec) as client:
resp = client.post(url, headers=headers, json=payload)
except httpx.RequestError as e:
raise OpenRouterError(str(e)) from e
if resp.status_code >= 400:
detail = ""
try:
j = resp.json()
detail = (
str(j.get("error", {}).get("message"))
if isinstance(j.get("error"), dict)
else str(j.get("message") or j)
)
except Exception:
detail = (resp.text or "")[:600]
raise OpenRouterError(f"HTTP {resp.status_code}: {detail}".strip())
try:
data = resp.json()
except json.JSONDecodeError as e:
raise OpenRouterError("Ungueltige JSON-Antwort von OpenRouter") from e
choices = data.get("choices") if isinstance(data, dict) else None
if not choices or not isinstance(choices, list):
raise OpenRouterError("OpenRouter: keine choices in Antwort")
msg0 = choices[0] if choices else {}
inner = msg0.get("message") if isinstance(msg0, dict) else None
blobs: List[Any] = []
if isinstance(inner, dict):
if inner.get("content") is not None:
blobs.append(inner.get("content"))
if inner.get("refusal") is not None:
blobs.append(inner.get("refusal"))
elif isinstance(inner, str):
blobs.append(inner)
if isinstance(msg0, dict) and msg0.get("content") is not None and msg0.get("content") not in blobs:
blobs.append(msg0.get("content"))
pieces = [_flatten_message_content(b).strip() for b in blobs if b is not None]
joined = ("\n".join(p for p in pieces if p)).strip()
if _shinkan_ai_debug():
fr = str(msg0.get("finish_reason") or "") if isinstance(msg0, dict) else ""
fu = data.get("usage") if isinstance(data.get("usage"), dict) else {}
pu = str(fu.get("prompt_tokens") or "")
pc = str(fu.get("completion_tokens") or "")
pt = str(fu.get("total_tokens") or "")
raw_cls = type(blobs[0]).__name__ if blobs else "none"
cc = str(len(joined))
_logger.warning(
"[AI_DEBUG/openrouter] model=%s finish_reason=%s usage_prompt=%s usage_completion=%s usage_total=%s "
"raw_content_cls=%s out_chars=%s",
model,
fr,
pu,
pc,
pt,
raw_cls,
cc,
)
return joined
def normalize_openrouter_env() -> tuple[str, str]:
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
return key, model

View File

@ -1,3 +1,4 @@
httpx==0.27.2
fastapi==0.111.0
uvicorn[standard]==0.29.0
anthropic==0.26.0

View File

@ -0,0 +1,370 @@
"""
Superadmin API: Pflege von ai_skill_retrieval_profiles (KI Skill-Katalog / exercise_ai).
Kein Vereinsbezug require_auth + is_superadmin; kein TenantContext.
Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from psycopg2.extras import Json
from auth import require_auth
from club_tenancy import is_superadmin
from db import get_cursor, get_db, r2d
router = APIRouter(tags=["admin_ai_skill_retrieval"])
def _require_superadmin(session: dict) -> dict:
role = (session.get("role") or "").strip().lower()
if not is_superadmin(role):
raise HTTPException(status_code=403, detail="Nur Superadmins")
return session
def _table_ready(cur) -> bool:
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_skill_retrieval_profiles",))
row = cur.fetchone()
if not row:
return False
val = row.get("t") if isinstance(row, dict) else row[0]
return val is not None and str(val).strip() != ""
def _assert_table(cur) -> None:
if not _table_ready(cur):
raise HTTPException(
status_code=503,
detail="Tabelle ai_skill_retrieval_profiles fehlt — Migration 068 ausführen.",
)
def _normalize_config(raw: Any) -> Dict[str, Any]:
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"config: ungültiges JSON — {e}") from e
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="config muss ein JSON-Objekt sein.")
return parsed
raise HTTPException(status_code=400, detail="config muss ein Objekt sein.")
class AiRetrievalProfileCreate(BaseModel):
name: str = Field(..., min_length=2, max_length=200)
description: Optional[str] = Field("", max_length=4000)
active: bool = True
is_default: bool = False
focus_area_id: Optional[int] = Field(None, ge=1)
config: Dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def default_vs_focus(self):
if self.is_default and self.focus_area_id is not None:
raise ValueError("Standardprofil darf keinen Fokusbereich (focus_area_id) haben.")
if not self.is_default and self.focus_area_id is None:
raise ValueError("Profil ohne Standard-Flag benötigt eine focus_area_id.")
return self
class AiRetrievalProfileUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=2, max_length=200)
description: Optional[str] = Field(None, max_length=4000)
active: Optional[bool] = None
is_default: Optional[bool] = None
focus_area_id: Optional[int] = Field(None, ge=1)
config: Optional[Dict[str, Any]] = None
def _row_to_out(row: dict) -> dict:
cfg = row.get("config")
if isinstance(cfg, str):
try:
cfg = json.loads(cfg)
except json.JSONDecodeError:
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
out = {
"id": row["id"],
"focus_area_id": row.get("focus_area_id"),
"focus_area_name": row.get("focus_area_name"),
"is_default": bool(row.get("is_default")),
"name": row.get("name") or "",
"description": row.get("description") or "",
"active": bool(row.get("active", True)),
"config": cfg,
"updated_at": row.get("updated_at"),
}
return out
def _active_focus_conflict(cur, focus_area_id: int, exclude_id: Optional[int] = None) -> bool:
if exclude_id is not None:
cur.execute(
"""
SELECT 1 FROM ai_skill_retrieval_profiles
WHERE active = true AND focus_area_id = %s AND id != %s
LIMIT 1
""",
(focus_area_id, exclude_id),
)
else:
cur.execute(
"""
SELECT 1 FROM ai_skill_retrieval_profiles
WHERE active = true AND focus_area_id = %s
LIMIT 1
""",
(focus_area_id,),
)
return cur.fetchone() is not None
def _focus_area_exists(cur, focus_area_id: int) -> bool:
cur.execute(
"SELECT 1 FROM focus_areas WHERE id = %s AND (status IS NULL OR status = 'active') LIMIT 1",
(focus_area_id,),
)
return cur.fetchone() is not None
@router.get("/api/admin/ai-skill-retrieval-profiles")
def list_ai_skill_retrieval_profiles(session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
ORDER BY p.is_default DESC NULLS LAST, fa.name NULLS LAST, p.name
"""
)
rows = [r2d(r) for r in cur.fetchall()]
return [_row_to_out(r) for r in rows]
@router.get("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
def get_ai_skill_retrieval_profile(profile_id: int, session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
WHERE p.id = %s
""",
(profile_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
return _row_to_out(r2d(row))
@router.post("/api/admin/ai-skill-retrieval-profiles", status_code=201)
def create_ai_skill_retrieval_profile(
body: AiRetrievalProfileCreate,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
cfg = _normalize_config(body.config)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
if body.is_default:
cur.execute(
"UPDATE ai_skill_retrieval_profiles SET is_default = false, updated_at = NOW() WHERE is_default = true"
)
else:
if not _focus_area_exists(cur, int(body.focus_area_id)):
raise HTTPException(status_code=400, detail="Unbekannter oder inaktiver Fokusbereich.")
if body.active and _active_focus_conflict(cur, int(body.focus_area_id)):
raise HTTPException(
status_code=409,
detail="Für diesen Fokusbereich existiert bereits ein aktives Profil.",
)
cur.execute(
"""
INSERT INTO ai_skill_retrieval_profiles
(focus_area_id, is_default, name, description, active, config, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""",
(
None if body.is_default else body.focus_area_id,
body.is_default,
body.name.strip(),
(body.description or "").strip(),
body.active,
Json(cfg),
),
)
new_id_row = cur.fetchone()
new_id = int(new_id_row["id"] if isinstance(new_id_row, dict) else new_id_row[0])
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
WHERE p.id = %s
""",
(new_id,),
)
row = r2d(cur.fetchone())
return _row_to_out(row)
@router.put("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
def update_ai_skill_retrieval_profile(
profile_id: int,
body: AiRetrievalProfileUpdate,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"""
SELECT id, focus_area_id, is_default, name, description, active, config, updated_at
FROM ai_skill_retrieval_profiles WHERE id = %s
""",
(profile_id,),
)
old = cur.fetchone()
if not old:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
old_d = dict(old)
if body.is_default is True and body.focus_area_id is not None:
raise HTTPException(
status_code=400,
detail="Standardprofil und Fokusbereich schließen sich aus.",
)
nm = body.name.strip() if body.name is not None else (old_d["name"] or "")
nm = nm.strip()
desc = (
body.description.strip() if body.description is not None else (old_d.get("description") or "")
).strip()
active = body.active if body.active is not None else bool(old_d.get("active", True))
next_is_default = body.is_default if body.is_default is not None else bool(old_d.get("is_default"))
next_focus_raw = (
body.focus_area_id if body.focus_area_id is not None else old_d.get("focus_area_id")
)
cfg = (
_normalize_config(body.config) if body.config is not None else _normalize_config(old_d.get("config"))
)
if next_is_default:
focus_id_sql: Optional[int] = None
else:
if next_focus_raw is None:
raise HTTPException(status_code=400, detail="Nicht-Standard-Profil benötigt focus_area_id.")
focus_id_sql = int(next_focus_raw)
if not _focus_area_exists(cur, focus_id_sql):
raise HTTPException(status_code=400, detail="Unbekannter oder inaktiver Fokusbereich.")
if active and _active_focus_conflict(cur, focus_id_sql, exclude_id=profile_id):
raise HTTPException(
status_code=409,
detail="Für diesen Fokusbereich existiert bereits ein anderes aktives Profil.",
)
old_default = bool(old_d.get("is_default"))
if old_default and not next_is_default:
raise HTTPException(
status_code=400,
detail="Das Standardprofil kann hier nicht zum Fokusbereichsprofil geändert werden — zuerst ein anderes Profil als Standard aktivieren.",
)
if old_default and active is False:
raise HTTPException(status_code=400, detail="Standardprofil kann nicht deaktiviert werden.")
if next_is_default:
cur.execute(
"""
UPDATE ai_skill_retrieval_profiles
SET is_default = false, updated_at = NOW()
WHERE is_default = true AND id != %s
""",
(profile_id,),
)
cur.execute(
"""
UPDATE ai_skill_retrieval_profiles
SET focus_area_id = %s,
is_default = %s,
name = %s,
description = %s,
active = %s,
config = %s,
updated_at = NOW()
WHERE id = %s
""",
(
focus_id_sql,
next_is_default,
nm,
desc,
active,
Json(cfg),
profile_id,
),
)
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
WHERE p.id = %s
""",
(profile_id,),
)
row = r2d(cur.fetchone())
return _row_to_out(row)
@router.delete("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
def delete_ai_skill_retrieval_profile(profile_id: int, session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"SELECT id, is_default FROM ai_skill_retrieval_profiles WHERE id = %s",
(profile_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
if bool(row.get("is_default") if isinstance(row, dict) else row[1]):
raise HTTPException(status_code=400, detail="Standardprofil kann nicht gelöscht werden.")
cur.execute("DELETE FROM ai_skill_retrieval_profiles WHERE id = %s", (profile_id,))
return {"deleted": True, "id": profile_id}

View File

@ -35,6 +35,8 @@ from tenant_context import TenantContext, get_tenant_context, get_tenant_context
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
from media_legal_hold import assert_not_under_legal_hold
from exercise_ai import run_exercise_ai_suggestion
from exercise_rich_text import (
RICH_HTML_EXERCISE_FIELDS,
assert_no_inline_media_references_on_create,
@ -356,6 +358,53 @@ 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
@model_validator(mode="after")
def check_include_any(self):
if not self.include_summary and not self.include_skills:
raise ValueError("Mindestens include_summary oder include_skills aktivieren.")
return self
class ExerciseAiRegenerateBody(BaseModel):
"""Welche Artefakte neu angefragt werden sollen."""
regenerate: list[str] = Field(default_factory=lambda: ["summary", "skills"])
@model_validator(mode="after")
def normalize_regs(self):
allowed = {"summary", "skills"}
raw = [str(x).strip().lower() for x in (self.regenerate or [])]
out = []
seen = set()
for lx in raw:
if lx in allowed and lx not in seen:
out.append(lx)
seen.add(lx)
if not out:
out = ["summary", "skills"]
self.regenerate = out
return self
class ExerciseVariantCreate(BaseModel):
variant_name: str = Field(..., min_length=3, max_length=200)
description: Optional[str] = None
@ -1244,7 +1293,7 @@ def assign_exercise_relations(
(
exercise_id,
skill["skill_id"],
False,
bool(skill.get("is_primary")),
normalize_exercise_skill_intensity(skill.get("intensity")),
normalize_exercise_skill_level(skill.get("required_level")),
normalize_exercise_skill_level(skill.get("target_level")),
@ -2216,6 +2265,98 @@ 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 []:
if isinstance(row, dict):
nm = (row.get("name") or "").strip()
if nm:
parts.append(nm)
txt = ", ".join(parts).strip()
if len(txt) > 900:
return txt[:899] + ""
return txt
@router.post("/exercises/ai/suggest")
def exercise_ai_suggest_endpoint(
body: ExerciseAiSuggestBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern.
OPENROUTER_API_KEY erforderlich.
"""
_ = 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,
)
return payload
@router.post("/exercises/{exercise_id}/ai/regenerate")
def exercise_ai_regenerate_endpoint(
exercise_id: int,
body: ExerciseAiRegenerateBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
want_summary = "summary" in body.regenerate
want_skills = "skills" in body.regenerate
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
exercise = enrich_exercise_detail(exercise_id, cur)
if not exercise:
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,
title=str(exercise.get("title") or "").strip(),
goal=exercise.get("goal"),
execution=exercise.get("execution"),
focus_area_hint=focus or None,
focus_areas_context=fctx or None,
want_summary=want_summary,
want_skills=want_skills,
)
return payload
@router.get("/exercises/{exercise_id}")
def get_exercise(
exercise_id: int,

View File

@ -23,6 +23,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"admin_users.py",
"platform_media_storage.py",
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
"catalogs.py",
"skills.py",
"maturity_models.py",

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.151"
BUILD_DATE = "2026-05-20"
DB_SCHEMA_VERSION = "20260520066"
APP_VERSION = "0.8.157"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260529068"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -18,11 +18,12 @@ MODULE_VERSIONS = {
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
"groups": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"exercises": "2.30.3", # Frontend KI ohne Modal-Grausperre; Anthropic/OpenRouter verschachtelte Textbloecke; SHINKAN_AI_DEBUG Warn-Logs exercise_ai/OpenRouter
"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 +38,56 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.157",
"date": "2026-05-22",
"changes": [
"Übungsformular-KI: Buttons nur noch bei Busy deaktiviert; Vorschau wird vor neuem Aufruf geschlossen (kein Permanent-Grauschalter durch Modal-Zustand)",
"OpenRouter: verschachtelte Textbloecke/Content-Huellen fuer Anthropic; refusal optional mitgenommen",
"exercise_ai: SHINKAN_AI_DEBUG=1 — detaillierte WARN-Logs (Prompt-Laengen, parse-Fehler, Sanitize-Anzahl)",
],
},
{
"version": "0.8.156",
"date": "2026-05-22",
"changes": [
"OpenRouter Client: Assistant-Content als Liste/Bloecke (Claude über Bedrock/OpenRouter)",
"exercise_ai: leere Modelantwort als 502; Skill-JSON aus Fließtext per balanciertem Array-Zuschnitt robuster parsen",
"Admin Retrieval-Profil: Gewichte/Unterkategorien per Formular ohne JSON-Editor",
],
},
{
"version": "0.8.155",
"date": "2026-05-29",
"changes": [
"exercise_ai: Fix haengender KI-Endpunkt bei sehr langen Skill-Arrays vom Modell (Cap + frueher Abbruch nach 5 gueltigen Zeilen)",
],
},
{
"version": "0.8.154",
"date": "2026-05-29",
"changes": [
"Superadmin-Web: KI Skill-Retrieval-Profile unter /admin/ai-skill-retrieval (Liste, JSON config, CRUD gegen /api/admin/ai-skill-retrieval-profiles*)",
"Backend: Router ai_skill_retrieval_admin registriert; ACCESS_HINTS EXEMPT dokumentiert",
],
},
{
"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",
"changes": [
"KI bei Uebungen: Migration 067 ai_prompts + summary_ai_generated; OpenRouter-Hilfsmodul; POST /api/exercises/ai/suggest und POST /api/exercises/{id}/ai/regenerate",
"Uebungsformular: Buttons KI Kurzfassung / Fähigkeiten; exercise_skills is_primary wird aus Payload gespeichert",
],
},
{
"version": "0.8.151",
"date": "2026-05-20",

View File

@ -29,6 +29,7 @@ services:
DB_PASSWORD: "${DB_PASSWORD:-dev_password}"
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
OPENROUTER_MODEL: ${OPENROUTER_MODEL}
SHINKAN_AI_DEBUG: "${SHINKAN_AI_DEBUG:-}"
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}

View File

@ -28,6 +28,7 @@ services:
DB_PASSWORD: ${DB_PASSWORD}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
OPENROUTER_MODEL: ${OPENROUTER_MODEL}
SHINKAN_AI_DEBUG: "${SHINKAN_AI_DEBUG:-}"
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}

View File

@ -52,9 +52,10 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
### 4.1 Übungen (Kernobjekt)
- **Anlegen, Bearbeiten, Archivieren/Löschen** je nach Rolle und Sichtbarkeit.
- **Mehrdimensionale Einordnung:** Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, **Fähigkeiten mit Stufen**; Suche und Filter über diese Dimensionen.
- **Übungsvarianten:** mehrere Ausprägungen einer Übung (z. B. Aufbau, Schwierigkeit, Material), mit Reihenfolge und optionaler **Voraussetzungsvariante**.
- **Anlegen, Bearbeiten, Archivieren/Löschen** je nach Rolle und Freigabelevel (siehe §4.7).
- **Bearbeitungsformular (Registerkarten):** Stammdaten · Anleitung · Einordnung · (Kombination) · Varianten · Medien & Mehr — reduziert Scroll-Tiefe; farbige Panel-Trenner; Varianten und Medien erst nach erstem Speichern.
- **Mehrdimensionale Einordnung:** Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, **Fähigkeiten mit Stufen und Intensität** (`niedrig`/`mittel`/`hoch`); Suche und Filter über diese Dimensionen.
- **Übungsvarianten:** mehrere Ausprägungen einer Übung (z. B. Aufbau, Schwierigkeit, Material), mit Reihenfolge und optionaler **Voraussetzungsvariante**; Bearbeitung im Tab **Varianten**; Änderungen werden mit **Speichern** in der Aktionsleiste mitgesichert (oder „Variante anlegen“ für sofortiges Anlegen).
- **Progressionsgraph:** gerichtete Beziehungen **von Übung zu Übung** (und Variantenbezug), Pflege in der Übungswelt; unterstützt didaktische „weiter“-Ketten.
- **Medien an der Übung:** Upload, Einbettung, Verknüpfung aus dem **Archiv**; Darstellung in Detail- und Bearbeitungsansicht.
- **Rich-Text-Felder** (Ablauf, Ziele, Hinweise): **Inline-Verweise auf verknüpfte Medien** über eine einheitliche Platzhalter-/Renderlogik (konsistent mit Archiv-Governance).
@ -100,8 +101,12 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
### 4.7 Governance von Übungsinhalten
- **Freigabelevel** (`visibility` in der API): steuert, wer eine Übung **lesen** darf — Werte u. a. **privat**, **Verein** (`club`), **offiziell** (`official`). In der Oberfläche heißt das Feld durchgängig **Freigabelevel** (nicht „Sichtbarkeit“).
- **Status:** Entwurf, in Prüfung, freigegeben, archiviert — konkrete Werte siehe Datenmodell.
- **Owner:** Der anlegende Nutzer (`created_by`). Varianten haben keinen eigenen Owner; Rechte folgen der Eltern-Übung.
- **Bearbeiten** (Stammdaten, Varianten, Medien): Ersteller; Plattform-Admin; bei Vereins-Übungen zusätzlich Planungs-/Inhaltsrollen im Objekt-Verein (Trainer, Content-Editor, Spartenleitung, Vereins-Admin).
- **Löschen:** privat — Ersteller (Vereins-Admin im gemeinsamen Verein mit Ersteller); Verein — nur Vereins-Admin; offiziell — nur Plattform-Admin.
- **Änderungsanfragen** (Content Change Requests) für vorgeschlagene Änderungen an Inhalten Einreichung und Bearbeitung über Posteingang/Admin-Prozesse (Detailtiefe siehe Fachdoku).
- **Sichtbarkeits- und Statusmodelle** für Übungen (Entwurf, veröffentlicht, archiviert konkrete Werte siehe Datenmodell).
### 4.8 Inhaltsmeldungen (P-13, vertrauens- und compliance-orientiert)

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.157`** (KI Übungen: UX-Flow + AI_DEBUG Logs), 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**.
@ -36,7 +36,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
| **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
| **Lieferliste inkl. Medien** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12 |
| **Lieferliste inkl. Medien & Formular-UX** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §6, §16 |
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
---
@ -79,6 +79,24 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Frontend:** KPI-Kacheln + Filter-Modal (UX wie Übungsliste) auf **`/planning/framework-programs`** und **`/planning/training-modules`**; Panels in Editoren; Discovery auf Fähigkeiten-Seite; `SkillTreeMultiSelect` mit Portal-Dropdown in Modals
- **Offen (Backlog):** Corpus-Caching bei großen Bibliotheken; Tests für Typ-Trennung; Filter-Persistenz; Skill-Filter im Dialog „Rahmen übernehmen“; API-Umbenennung `club_*` → Peer-Namen
### 2.7 Übungsformular UX, Freigabelevel & Varianten-Speichern (Stand 2026-05-20)
- **Code:** `frontend/src/components/exercises/ExerciseFormPageRoot.jsx`, `ExerciseFormLayout.jsx`, `ExerciseCatalogAssocEditor.jsx`, `ExerciseSkillsEditor.jsx`, `frontend/src/constants/exerciseGovernanceLabels.js`, `exerciseSkillIntensity.js`
- **Tab-Navigation:** Stammdaten | Anleitung | Einordnung | (Kombination) | Varianten | Medien & Mehr — `PageSectionNav` mit farbigen Panels (`.exercise-form-edit` in `app.css`); Varianten/Medien erst nach erstem Speichern aktiv; Kombi-Übungen ohne Varianten-Tab
- **Freigabelevel:** UI-Label für `exercises.visibility` in Formular, Liste, Filter, Bulk, Picker — API-Feldname unverändert
- **Fähigkeiten:** Intensität `niedrig`/`mittel`/`hoch` (Default `mittel`); kein Primär-Flag in UI; Backend setzt `exercise_skills.is_primary` immer `false`
- **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.157**)
- **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 **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
- **`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`** nutzt gespeicherte `exercise_focus_areas`**Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`)
- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang
- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`**
---
## 3. Trainingsrahmenprogramm & PlanungsBlueprint (kurz)

View File

@ -54,6 +54,7 @@ const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
const LegalPage = lazy(() => import('./pages/LegalPage'))
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
@ -300,6 +301,14 @@ const appRouter = createBrowserRouter([
</PlatformAdminRoute>
),
},
{
path: 'admin/ai-skill-retrieval',
element: (
<PlatformAdminRoute>
<AdminAiSkillRetrievalPage />
</PlatformAdminRoute>
),
},
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
],
},

View File

@ -90,9 +90,11 @@ export function buildExerciseApiPayload(formData, extras = {}) {
age_groups: [],
skills: (formData.skills || []).map((s) => ({
skill_id: s.skill_id,
is_primary: !!s.is_primary,
intensity: normalizeExerciseSkillIntensity(s.intensity),
required_level: s.required_level || null,
target_level: s.target_level || null,
ai_suggested: !!s.ai_suggested,
})),
visibility: visibilityNorm,
status: formData.status || 'draft',
@ -577,17 +579,24 @@ export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
})
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
/** KI (OpenRouter): Nur Vorschlaege; Speichern ueber normales exercise PUT/POST. */
export async function suggestExerciseAi(payload = {}) {
return request('/api/exercises/ai/suggest', {
method: 'POST',
body: JSON.stringify(payload),
body: JSON.stringify({
include_summary: true,
include_skills: true,
...payload,
}),
})
}
export async function regenerateExerciseAi(exerciseId, payload) {
export async function regenerateExerciseAi(exerciseId, payload = {}) {
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
method: 'POST',
body: JSON.stringify(payload),
body: JSON.stringify({
regenerate: ['summary', 'skills'],
...payload,
}),
})
}

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -12,6 +12,7 @@ export default function AdminPageNav() {
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
{ to: '/admin/ai-skill-retrieval', label: 'KI Retrieval', icon: Brain },
]
return (

View File

@ -14,7 +14,8 @@ import {
buildExerciseMediaDragPayload,
} from '../../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../../constants/skillLevels'
import { stripHtmlToText } from '../../utils/htmlUtils'
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
import { useAuth } from '../../context/AuthContext'
@ -43,6 +44,7 @@ import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUn
import {
EXERCISE_SKILL_INTENSITY_DEFAULT,
normalizeExerciseSkillIntensity,
formatExerciseSkillIntensityLabel,
} from '../../constants/exerciseSkillIntensity'
import {
EXERCISE_VISIBILITY_CLUB_FIELD_LABEL,
@ -71,6 +73,102 @@ const comboTinyNumberInputSx = {
textAlign: 'center',
}
function escapeHtmlText(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** Plaintext fuer RichTextEditor: ein bis mehrere Absaetze, ohne bestehendes HTML zu zerstoeren. */
function aiPlainSummaryToMinimalHtml(text) {
const raw = String(text || '').trim()
if (!raw) return ''
const parts = raw.split(/\n+/).map((p) => p.trim()).filter(Boolean)
const paras = parts.length ? parts : [raw]
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
}
function cloneExerciseSkillRows(rows) {
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
}
function buildNormalizedAiSkillRowFromApi(sug) {
const sid = Number(sug.skill_id)
if (!Number.isFinite(sid)) return null
return {
skill_id: sid,
intensity: normalizeExerciseSkillIntensity(sug.intensity),
required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen',
target_level:
normalizeSkillLevelSlug(sug.target_level) ||
normalizeSkillLevelSlug(sug.required_level) ||
'grundlagen',
is_primary: !!sug.is_primary,
ai_suggested: true,
}
}
function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) {
const summaryRequested = mode !== 'skills'
const skillsRequested = mode !== 'summary'
let summaryAfterHtml = null
let summaryAfterPlain = ''
if (summaryRequested && apiRes.summary?.text) {
summaryAfterPlain = String(apiRes.summary.text).trim()
if (summaryAfterPlain) {
summaryAfterHtml = aiPlainSummaryToMinimalHtml(apiRes.summary.text)
}
}
const skillChoices = []
if (skillsRequested && Array.isArray(apiRes.skills)) {
for (const sug of apiRes.skills) {
const after = buildNormalizedAiSkillRowFromApi(sug)
if (!after) continue
const ix = snapshotSkills.findIndex((s) => Number(s.skill_id) === after.skill_id)
const before = ix >= 0 ? { ...snapshotSkills[ix] } : null
skillChoices.push({
key: String(after.skill_id),
skill_id: after.skill_id,
kind: before ? 'update' : 'add',
before,
after,
include: true,
})
}
}
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
const hasSkillChoices = skillChoices.length > 0
return {
mode,
applySummary: hasSummaryProposal,
summaryBeforePlain: stripHtmlToText(snapshotSummaryHtml || '').trim(),
summaryAfterPlain,
summaryAfterHtml,
skillChoices,
hasSummaryProposal,
hasSkillChoices,
summaryRequested,
skillsRequested,
}
}
function describeExerciseSkillRowForPreview(row, skillsCatalog) {
if (!row) return ''
const sk = skillsCatalog.find((x) => Number(x.id) === Number(row.skill_id))
const name = sk?.name || `Fähigkeit #${row.skill_id}`
const int = formatExerciseSkillIntensityLabel(row.intensity)
const from = formatSkillLevelSlug(row.required_level) || '—'
const to = formatSkillLevelSlug(row.target_level) || '—'
const prim = row.is_primary ? ' · Primär' : ''
return `${name}: Intensität ${int}, Niveau ${from}${to}${prim}`
}
function emptyComboSlotRow() {
return {
title: '',
@ -417,6 +515,8 @@ function detailToForm(exercise) {
intensity: normalizeExerciseSkillIntensity(s.intensity),
required_level: normalizeSkillLevelSlug(s.required_level),
target_level: normalizeSkillLevelSlug(s.target_level),
is_primary: !!s.is_primary,
ai_suggested: !!s.ai_suggested,
})) || [],
exercise_kind:
String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
@ -503,6 +603,8 @@ function ExerciseFormPageRoot() {
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
const [variantSavingId, setVariantSavingId] = useState(null)
const [variantBusy, setVariantBusy] = useState(false)
const [aiSuggestBusy, setAiSuggestBusy] = useState(false)
const [aiSuggestionPreview, setAiSuggestionPreview] = useState(null)
const [variantEditSelection, setVariantEditSelection] = useState(null)
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
const variantsSavedSnapshotRef = useRef({})
@ -855,6 +957,122 @@ function ExerciseFormPageRoot() {
)
}
const runExerciseAiSuggestion = async (mode) => {
const gPlain = stripHtmlToText(formData.goal || '').trim()
const ePlain = stripHtmlToText(formData.execution || '').trim()
if (!gPlain && !ePlain) {
toast.error('Ziel oder Durchführung ausfüllen — die KI benötigt Kontext.')
return
}
const summaryOn = mode !== 'skills'
const skillsOn = mode !== 'summary'
const focusHint = (formData.focus_areas_multi || [])
.map((row) => {
const id = row?.focus_area_id
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
return (fa?.name || '').trim()
})
.filter(Boolean)
.join(', ')
const 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
})
/* Vor jedem neuen Aufruf: Vorschau schließen; sonst bleiben die KI-Buttons wegen Modal-Zustand dauerhaft deaktiviert. */
setAiSuggestionPreview(null)
setAiSuggestBusy(true)
try {
const res = await api.suggestExerciseAi({
title: (formData.title || '').trim(),
goal: formData.goal || '',
execution: formData.execution || '',
focus_area_hint: focusHint || undefined,
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
include_summary: summaryOn,
include_skills: skillsOn,
})
const preview = buildExerciseAiSuggestionPreview({
mode,
snapshotSummaryHtml,
snapshotSkills,
apiRes: res,
})
const hasSomething = preview.hasSummaryProposal || preview.hasSkillChoices
if (!hasSomething) {
toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.')
return
}
setAiSuggestionPreview(preview)
} catch (err) {
toast.error(err?.message || String(err))
} finally {
setAiSuggestBusy(false)
}
}
const applyExerciseAiSuggestionPreview = () => {
const p = aiSuggestionPreview
if (!p) return
const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
if (!takeSummary && skillsToMerge.length === 0) {
toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.')
return
}
if (takeSummary) {
updateFormField('summary', p.summaryAfterHtml)
}
if (skillsToMerge.length > 0) {
setFormDirty(true)
setFormData((prev) => {
const next = [...(prev.skills || [])]
for (const row of skillsToMerge) {
const sid = Number(row.skill_id)
const ix = next.findIndex((s) => Number(s.skill_id) === sid)
if (ix >= 0) next[ix] = { ...next[ix], ...row }
else next.push(row)
}
return { ...prev, skills: next }
})
}
toast.success('Ausgewählte KI-Vorschläge übernommen — bitte prüfen und speichern.')
setAiSuggestionPreview(null)
}
const discardExerciseAiSuggestionPreview = () => setAiSuggestionPreview(null)
useEffect(() => {
if (!aiSuggestionPreview) return undefined
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault()
setAiSuggestionPreview(null)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [aiSuggestionPreview])
const refreshVariants = useCallback(async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
@ -1309,7 +1527,28 @@ function ExerciseFormPageRoot() {
</div>
<div className="form-row">
<label className="form-label">Kurzbeschreibung</label>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
flexWrap: 'wrap',
}}
>
<label className="form-label" style={{ marginBottom: 0 }}>
Kurzbeschreibung
</label>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('summary')}
>
KI: Kurzfassung
</button>
</div>
<RichTextEditor
value={formData.summary}
onChange={(html) => updateFormField('summary', html)}
@ -1966,6 +2205,42 @@ function ExerciseFormPageRoot() {
title="Einordnung"
hint="Fokus, Stile, Zielgruppen und Fähigkeiten für Suche, Filter und Skill-Profil."
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginBottom: '12px',
alignItems: 'center',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('skills')}
>
KI: Fähigkeiten
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('both')}
>
KI: Kurzfassung und Fähigkeiten
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Benötigt Ziel oder Durchführung sowie optional{' '}
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('anleitung')}>
Anleitung
</button>
· Es öffnet ein Dialogfeld mit Vorschau; Übernahme wählweise pro Teil. Speichern nur über die Aktionsleiste.
</span>
</div>
<section className="exercise-form-meta-panel" aria-label="Klassifikation">
<div className="exercise-form-meta-panel__grid">
<ExerciseCatalogAssocEditor
@ -2490,6 +2765,259 @@ function ExerciseFormPageRoot() {
</form>
</div>
{aiSuggestionPreview &&
(() => {
const p = aiSuggestionPreview
const summaryBoxSx = {
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '13px',
lineHeight: 1.45,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: '72px',
}
const canApplySomething =
(p.applySummary && p.summaryAfterHtml) || p.skillChoices.some((c) => c.include)
return (
<div
role="dialog"
aria-modal="true"
aria-label="KI-Vorschlag prüfen"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 1001,
overflow: 'auto',
padding: '16px',
}}
onClick={() => discardExerciseAiSuggestionPreview()}
onKeyDown={(e) => e.key === 'Escape' && discardExerciseAiSuggestionPreview()}
>
<div
className="card"
style={{
maxWidth: 760,
margin: '3vh auto',
maxHeight: '92vh',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>KI-Vorschlag übernehmen</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.
</p>
{p.hasSummaryProposal ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
<div
id="ai-preview-summary-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Kurzfassung
</div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
cursor: 'pointer',
fontSize: '14px',
}}
>
<input
type="checkbox"
checked={p.applySummary}
onChange={(e) =>
setAiSuggestionPreview((prev) =>
prev ? { ...prev, applySummary: e.target.checked } : prev,
)
}
/>
Kurzfassung durch Vorschlag ersetzen (bestehende Kurzbeschreibung wird überschrieben)
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
gap: '12px',
}}
>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
Aktuell (ohne Formatierung)
</div>
<div style={summaryBoxSx}>{p.summaryBeforePlain || '(leer)'}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>KI-Vorschlag</div>
<div style={{ ...summaryBoxSx, borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))' }}>
{p.summaryAfterPlain || '(leer)'}
</div>
</div>
</div>
</section>
) : null}
{p.skillsRequested ? (
<section aria-labelledby="ai-preview-skills-heading">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
marginBottom: '10px',
}}
>
<div id="ai-preview-skills-heading" style={{ fontWeight: 600, fontSize: '0.95rem' }}>
Fähigkeiten ({p.skillChoices.length}
{p.skillChoices.length === 1 ? ' Vorschlag' : ' Vorschläge'})
</div>
{p.skillChoices.length > 0 ? (
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: true })),
}
: prev,
)
}
>
Alle auswählen
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: false })),
}
: prev,
)
}
>
Alle abwählen
</button>
</div>
) : null}
</div>
{p.skillChoices.length === 0 ? (
<p style={{ fontSize: '13px', color: 'var(--text3)', margin: 0 }}>
Keine passenden Fähigkeiten der Katalog-Vorschlag war leer oder enthielt nur ungültige IDs.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{p.skillChoices.map((c) => (
<li
key={c.key}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '10px 12px',
marginBottom: '10px',
background: 'var(--surface)',
}}
>
<label
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
cursor: 'pointer',
margin: 0,
}}
>
<input
type="checkbox"
checked={c.include}
onChange={() =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) =>
x.skill_id === c.skill_id ? { ...x, include: !x.include } : x,
),
}
: prev,
)
}
style={{ marginTop: '4px' }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '13px', marginBottom: '6px' }}>
{c.kind === 'add' ? 'Neu hinzufügen' : 'Bestehende Zeile aktualisieren'}
</div>
{c.kind === 'update' && c.before ? (
<div style={{ fontSize: '12px', lineHeight: 1.5 }}>
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Bisher</div>
<div style={{ marginBottom: '8px' }}>
{describeExerciseSkillRowForPreview(c.before, skillsCatalog)}
</div>
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Nach KI-Vorschlag</div>
<div>{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}</div>
</div>
) : (
<div style={{ fontSize: '13px', lineHeight: 1.5 }}>
{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}
</div>
)}
</div>
</label>
</li>
))}
</ul>
)}
</section>
) : null}
<div
style={{
marginTop: '20px',
paddingTop: '14px',
borderTop: '1px solid var(--border)',
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'wrap',
gap: '10px',
}}
>
<button type="button" className="btn btn-secondary" onClick={discardExerciseAiSuggestionPreview}>
Abbrechen
</button>
<button
type="button"
className="btn btn-primary"
disabled={!canApplySomething}
onClick={() => applyExerciseAiSuggestionPreview()}
>
Ausgewähltes übernehmen
</button>
</div>
</div>
</div>
)
})()}
<ExercisePickerModal
open={comboStationPickerIx !== null}
onClose={() => setComboStationPickerIx(null)}
@ -2504,11 +3032,9 @@ function ExerciseFormPageRoot() {
/>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> z.B.{' '}
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
<code>api.suggestExerciseAi</code>).
<strong>KI-Unterstützung:</strong> OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
wie gewohnt.
</p>
<UnsavedChangesPrompt
blocker={blocker}

View File

@ -0,0 +1,704 @@
import { useCallback, useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
function formatDt(iso) {
if (!iso) return ''
try {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
} catch {
return String(iso)
}
}
/** Bestehende Zusatzfelder aus der DB übernehmen (keine eigene Pflegeoberfläche). */
function configExtrasFromRow(cfg) {
const c = cfg && typeof cfg === 'object' ? cfg : {}
return {
keyword_overrides: Array.isArray(c.keyword_overrides) ? c.keyword_overrides : [],
main_min_share: c.main_min_share && typeof c.main_min_share === 'object' ? { ...c.main_min_share } : {},
}
}
function buildRetrievalDraft(cfg, mainList, subList) {
const c = cfg && typeof cfg === 'object' ? cfg : {}
const mainW = c.main_slug_weights || {}
const catW = c.category_slug_weights || {}
const catCap = c.category_max_share || {}
let mains = (mainList || [])
.map((m) => {
const slug = String(m.slug || '').trim()
if (!slug) return null
const w = mainW[slug]
return {
slug,
name: String(m.name || '').trim() || slug,
weight: w != null && w !== '' ? String(w) : '1',
}
})
.filter(Boolean)
if (mains.length === 0) {
mains = ['karate', 'allgemeine'].map((slug) => ({
slug,
name: slug,
weight: mainW[slug] != null && mainW[slug] !== '' ? String(mainW[slug]) : '1',
}))
}
const categories = (subList || [])
.map((sc) => {
const slug = String(sc.slug || '').trim()
if (!slug) return null
const capNum = catCap[slug]
let maxSharePct = ''
if (capNum != null && capNum !== '') {
const v = Number(capNum)
if (Number.isFinite(v) && v > 0 && v <= 1) {
maxSharePct = String(Math.round(v * 1000) / 10)
} else if (Number.isFinite(v) && v > 1 && v <= 100) {
maxSharePct = String(v)
}
}
const w = catW[slug]
return {
slug,
label: [sc.main_category_name, sc.name].filter(Boolean).join(' · ') || sc.name || slug,
weight: w != null && w !== '' ? String(w) : '',
maxSharePct,
}
})
.filter(Boolean)
.sort((a, b) => a.label.localeCompare(b.label, 'de'))
return {
importanceMultiplier: c.importance_multiplier != null ? String(c.importance_multiplier) : '1',
textOverlapBonus: c.text_overlap_bonus != null ? String(c.text_overlap_bonus) : '2',
descMaxLen: c.description_plain_max_len != null ? String(c.description_plain_max_len) : '160',
karateRelMaxLen: c.karate_relevance_max_len != null ? String(c.karate_relevance_max_len) : '72',
mains,
categories,
}
}
function buildConfigPayload(extras, draft) {
const next = {
version: 1,
importance_multiplier: parseFloat(String(draft.importanceMultiplier).replace(',', '.')) || 1,
text_overlap_bonus: parseFloat(String(draft.textOverlapBonus).replace(',', '.')) || 2,
description_plain_max_len: Math.max(
40,
Math.min(400, parseInt(String(draft.descMaxLen), 10) || 160),
),
karate_relevance_max_len: Math.max(
0,
Math.min(280, parseInt(String(draft.karateRelMaxLen), 10) || 0),
),
main_slug_weights: {},
category_slug_weights: {},
category_max_share: {},
keyword_overrides: Array.isArray(extras.keyword_overrides) ? extras.keyword_overrides : [],
main_min_share:
extras.main_min_share && typeof extras.main_min_share === 'object' ? { ...extras.main_min_share } : {},
}
for (const m of draft.mains || []) {
if (!m.slug) continue
const w = parseFloat(String(m.weight).replace(',', '.'))
if (Number.isFinite(w) && w > 0) next.main_slug_weights[m.slug] = w
}
for (const slug of ['karate', 'allgemeine']) {
if (next.main_slug_weights[slug] == null) next.main_slug_weights[slug] = 1
}
for (const row of draft.categories || []) {
if (!row.slug) continue
const w = parseFloat(String(row.weight).replace(',', '.'))
if (Number.isFinite(w) && w > 0 && w !== 1) next.category_slug_weights[row.slug] = w
const capRaw = String(row.maxSharePct || '').trim().replace(',', '.')
if (capRaw) {
const v = parseFloat(capRaw)
if (Number.isFinite(v) && v > 0) {
const frac = v > 1 ? v / 100 : v
if (frac > 0 && frac <= 1) next.category_max_share[row.slug] = frac
}
}
}
return next
}
/**
* Pflege von ai_skill_retrieval_profiles Gewichte pro Haupt- und Unterkategorie ohne JSON-Bearbeitung.
* Nur Superadmin (PlatformAdminRoute).
*/
export default function AdminAiSkillRetrievalPage() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [profiles, setProfiles] = useState([])
const [focusAreas, setFocusAreas] = useState([])
const [skillMainCatalog, setSkillMainCatalog] = useState([])
const [skillSubCatalog, setSkillSubCatalog] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editor, setEditor] = useState(null)
const loadAll = useCallback(async () => {
const [p, faRaw, mc, sc] = await Promise.all([
api.listAiSkillRetrievalProfiles(),
api.listFocusAreas(),
api.listSkillMainCategories(),
api.listSkillCategories({ status: 'active' }),
])
setProfiles(Array.isArray(p) ? p : [])
const fa = Array.isArray(faRaw) ? faRaw : []
setFocusAreas(fa.filter((a) => !a.status || String(a.status).toLowerCase() === 'active'))
setSkillMainCatalog(Array.isArray(mc) ? mc : [])
setSkillSubCatalog(Array.isArray(sc) ? sc : [])
}, [])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
setLoading(true)
setError('')
try {
await loadAll()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, loadAll])
const openNew = () => {
setEditor({
mode: 'new',
isDefault: false,
focusAreaId: '',
name: '',
description: '',
active: true,
configExtras: { keyword_overrides: [], main_min_share: {} },
retrieval: buildRetrievalDraft({}, skillMainCatalog, skillSubCatalog),
})
}
const openEdit = (row) => {
const cfg = row.config && typeof row.config === 'object' ? row.config : {}
setEditor({
mode: 'edit',
id: row.id,
originalIsDefault: !!row.is_default,
isDefault: !!row.is_default,
promoteToDefault: false,
focusAreaId: row.focus_area_id != null ? String(row.focus_area_id) : '',
name: row.name || '',
description: row.description || '',
active: !!row.active,
configExtras: configExtrasFromRow(cfg),
retrieval: buildRetrievalDraft(cfg, skillMainCatalog, skillSubCatalog),
})
}
const closeEditor = () => setEditor(null)
const save = async () => {
if (!editor) return
const cfg = buildConfigPayload(editor.configExtras, editor.retrieval)
const name = editor.name.trim()
if (name.length < 2) {
alert('Name mindestens 2 Zeichen.')
return
}
try {
if (editor.mode === 'new') {
const body = {
name,
description: (editor.description || '').trim(),
active: editor.active,
is_default: editor.isDefault,
config: cfg,
}
if (!editor.isDefault) {
const fid = parseInt(editor.focusAreaId, 10)
if (!fid) {
alert('Fokusbereich wählen (oder „Standardprofil“ aktivieren).')
return
}
body.focus_area_id = fid
}
await api.createAiSkillRetrievalProfile(body)
} else {
const body = {
name,
description: (editor.description || '').trim(),
active: editor.active,
config: cfg,
}
if (editor.originalIsDefault) {
await api.updateAiSkillRetrievalProfile(editor.id, body)
} else {
if (editor.promoteToDefault) {
body.is_default = true
} else {
const fid = parseInt(editor.focusAreaId, 10)
if (!fid) {
alert('Fokusbereich wählen oder „Als Standardprofil setzen“ aktivieren.')
return
}
body.focus_area_id = fid
}
await api.updateAiSkillRetrievalProfile(editor.id, body)
}
}
closeEditor()
await loadAll()
} catch (e) {
alert(e.message || String(e))
}
}
const remove = async (row) => {
if (row.is_default) return
if (!confirm(`Profil „${row.name}“ wirklich löschen?`)) return
try {
await api.deleteAiSkillRetrievalProfile(row.id)
await loadAll()
} catch (e) {
alert(e.message || String(e))
}
}
if (!isSuperadmin) return <Navigate to="/" replace />
const retrieval = editor?.retrieval
return (
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>KI Skill-Retrieval-Profile</h1>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1rem' }}>
Gewichtungen für den Skill-Katalog bei der <strong>Übungs-KI</strong> (OpenRouter).{' '}
<strong>Unterkategorien</strong> (z.B. Kondition, Selbstverteidigung): Multiplikatoren optional;{' '}
<strong>max. Anteil</strong> begrenzt den Listenanteil dieser Kategorie (1100&nbsp;%). Hauptgruppen
(Karate / Allgemein) separat einstellbar siehe auch{' '}
<span style={{ fontSize: '0.88em' }}>.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md</span>.
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn btn-primary" onClick={openNew}>
Neues Profil
</button>
<button type="button" className="btn btn-secondary" onClick={() => loadAll().catch((e) => setError(e.message))}>
Aktualisieren
</button>
</div>
{error ? (
<div className="card" style={{ padding: '12px 16px', marginBottom: '1rem', borderColor: 'var(--danger)' }}>
<strong style={{ color: 'var(--danger)' }}>Fehler</strong>
<div style={{ marginTop: '0.35rem', whiteSpace: 'pre-wrap' }}>{error}</div>
</div>
) : null}
{loading ? (
<div className="spinner" style={{ margin: '2rem auto' }} />
) : (
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.92rem' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '10px 12px' }}>Name</th>
<th style={{ padding: '10px 12px' }}>Typ</th>
<th style={{ padding: '10px 12px' }}>Fokusbereich</th>
<th style={{ padding: '10px 12px' }}>Aktiv</th>
<th style={{ padding: '10px 12px' }}>Geändert</th>
<th style={{ padding: '10px 12px', width: '1%' }} />
</tr>
</thead>
<tbody>
{profiles.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '10px 12px', fontWeight: 600 }}>{row.name}</td>
<td style={{ padding: '10px 12px' }}>{row.is_default ? 'Standard' : 'Fokus'}</td>
<td style={{ padding: '10px 12px', color: 'var(--text2)' }}>
{row.is_default ? '—' : row.focus_area_name || `(ID ${row.focus_area_id})`}
</td>
<td style={{ padding: '10px 12px' }}>{row.active ? 'ja' : 'nein'}</td>
<td style={{ padding: '10px 12px', color: 'var(--text3)' }}>{formatDt(row.updated_at)}</td>
<td style={{ padding: '10px 12px', whiteSpace: 'nowrap' }}>
<button type="button" className="btn btn-secondary" style={{ marginRight: 6 }} onClick={() => openEdit(row)}>
Bearbeiten
</button>
{!row.is_default ? (
<button type="button" className="btn btn-secondary" onClick={() => remove(row)}>
Löschen
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
{profiles.length === 0 ? (
<div style={{ padding: '1.25rem', color: 'var(--text3)' }}>Keine Einträge (oder Tabelle/Migration 068 fehlt).</div>
) : null}
</div>
)}
{editor && retrieval && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
}}
>
<div
className="card"
style={{
maxWidth: '920px',
width: '100%',
maxHeight: '92vh',
overflow: 'auto',
padding: '1.25rem 1.5rem',
}}
>
<h2 style={{ marginTop: 0 }}>{editor.mode === 'new' ? 'Neues Profil' : 'Profil bearbeiten'}</h2>
<div className="form-row">
<label className="form-label" htmlFor="arp-name">
Name
</label>
<input
id="arp-name"
className="form-input"
value={editor.name}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, name: e.target.value } : ed))}
/>
</div>
<div className="form-row">
<label className="form-label" htmlFor="arp-desc">
Beschreibung
</label>
<textarea
id="arp-desc"
className="form-input"
rows={3}
value={editor.description}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, description: e.target.value } : ed))}
/>
</div>
{editor.mode === 'new' ? (
<div className="form-row">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editor.isDefault}
onChange={(e) =>
setEditor((ed) =>
ed ? { ...ed, isDefault: e.target.checked, focusAreaId: e.target.checked ? '' : ed.focusAreaId } : ed
)
}
/>
Standardprofil (ohne Fokusbereich)
</label>
</div>
) : null}
{editor.mode === 'edit' && editor.originalIsDefault ? (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
Standardprofil: Fokusbereich ist immer leer; Deaktivieren ist nicht erlaubt.
</p>
) : null}
{editor.mode === 'edit' && !editor.originalIsDefault ? (
<div className="form-row">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!editor.promoteToDefault}
onChange={(e) =>
setEditor((ed) =>
ed ? { ...ed, promoteToDefault: e.target.checked, focusAreaId: e.target.checked ? '' : ed.focusAreaId } : ed
)
}
/>
Als Standardprofil setzen (andere Standard-Markierung wird entfernt)
</label>
</div>
) : null}
{!editor.isDefault && editor.mode === 'new' ? (
<div className="form-row">
<label className="form-label" htmlFor="arp-focus">
Fokusbereich
</label>
<select
id="arp-focus"
className="form-input"
value={editor.focusAreaId}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, focusAreaId: e.target.value } : ed))}
>
<option value=""> wählen </option>
{focusAreas.map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name || `Fokus ${fa.id}`}
</option>
))}
</select>
</div>
) : null}
{editor.mode === 'edit' && !editor.originalIsDefault && !editor.promoteToDefault ? (
<div className="form-row">
<label className="form-label" htmlFor="arp-focus-e">
Fokusbereich
</label>
<select
id="arp-focus-e"
className="form-input"
value={editor.focusAreaId}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, focusAreaId: e.target.value } : ed))}
>
<option value=""> wählen </option>
{focusAreas.map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name || `Fokus ${fa.id}`}
</option>
))}
</select>
</div>
) : null}
<div className="form-row">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editor.active}
disabled={editor.mode === 'edit' && editor.originalIsDefault}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, active: e.target.checked } : ed))}
/>
Aktiv
</label>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1.25rem 0' }} />
<h3 style={{ margin: '0 0 0.75rem', fontSize: '1rem' }}>Globale Ranking-Parameter</h3>
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label className="form-label" htmlFor="arp-imp">
importance_multiplier
</label>
<input
id="arp-imp"
className="form-input"
inputMode="decimal"
value={retrieval.importanceMultiplier}
onChange={(e) =>
setEditor((ed) =>
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, importanceMultiplier: e.target.value } } : ed
)
}
/>
</div>
<div>
<label className="form-label" htmlFor="arp-tob">
text_overlap_bonus
</label>
<input
id="arp-tob"
className="form-input"
inputMode="decimal"
value={retrieval.textOverlapBonus}
onChange={(e) =>
setEditor((ed) =>
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, textOverlapBonus: e.target.value } } : ed
)
}
/>
</div>
</div>
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label className="form-label" htmlFor="arp-dmax">
Beschreibung max. Zeichen (je Zeile im Katalog)
</label>
<input
id="arp-dmax"
className="form-input"
inputMode="numeric"
value={retrieval.descMaxLen}
onChange={(e) =>
setEditor((ed) =>
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, descMaxLen: e.target.value } } : ed
)
}
/>
</div>
<div>
<label className="form-label" htmlFor="arp-krm">
Karate-Relevanz max. Zeichen (0 = weglassen)
</label>
<input
id="arp-krm"
className="form-input"
inputMode="numeric"
value={retrieval.karateRelMaxLen}
onChange={(e) =>
setEditor((ed) =>
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, karateRelMaxLen: e.target.value } } : ed
)
}
/>
</div>
</div>
<h3 style={{ margin: '1.25rem 0 0.5rem', fontSize: '1rem' }}>Hauptkategorien (Multiplikatoren)</h3>
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 10px' }}>Bezeichnung</th>
<th style={{ padding: '8px 10px', width: '30%' }}>Multiplikator</th>
</tr>
</thead>
<tbody>
{retrieval.mains.map((m) => (
<tr key={m.slug} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px' }}>
<div style={{ fontWeight: 600 }}>{m.name}</div>
<div style={{ color: 'var(--text3)', fontSize: '0.8rem' }}>{m.slug}</div>
</td>
<td style={{ padding: '8px 10px' }}>
<input
className="form-input"
inputMode="decimal"
value={m.weight}
aria-label={`Gewicht ${m.slug}`}
onChange={(e) =>
setEditor((ed) => {
if (!ed?.retrieval) return ed
const nm = ed.retrieval.mains.map((row) =>
row.slug === m.slug ? { ...row, weight: e.target.value } : row
)
return { ...ed, retrieval: { ...ed.retrieval, mains: nm } }
})
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 style={{ margin: '1.25rem 0 0.5rem', fontSize: '1rem' }}>
Unterkategorien (optional: Gewicht · max. Listenanteil)
</h3>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Gewicht leer oder 1 = neutral. Max. Anteil in Prozent der Katalogzeilen für diese Unterkategorie
(z.B. 25 für 25&nbsp;%).
</p>
<div style={{ maxHeight: '280px', overflow: 'auto', border: '1px solid var(--border)', borderRadius: '8px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
<thead style={{ position: 'sticky', top: 0, background: 'var(--surface2)', zIndex: 1 }}>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 10px' }}>Unterkategorie</th>
<th style={{ padding: '8px 10px', width: '110px' }}>Gewicht</th>
<th style={{ padding: '8px 10px', width: '120px' }}>Max. %</th>
</tr>
</thead>
<tbody>
{retrieval.categories.map((c) => (
<tr key={c.slug} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '6px 10px', verticalAlign: 'middle' }}>
<span title={c.slug}>{c.label}</span>
</td>
<td style={{ padding: '6px 8px', verticalAlign: 'middle' }}>
<input
className="form-input"
placeholder="1"
inputMode="decimal"
value={c.weight}
aria-label={`Gewicht ${c.slug}`}
onChange={(e) =>
setEditor((ed) => {
if (!ed?.retrieval) return ed
const nc = ed.retrieval.categories.map((row) =>
row.slug === c.slug ? { ...row, weight: e.target.value } : row
)
return { ...ed, retrieval: { ...ed.retrieval, categories: nc } }
})
}
/>
</td>
<td style={{ padding: '6px 8px', verticalAlign: 'middle' }}>
<input
className="form-input"
placeholder="—"
inputMode="decimal"
value={c.maxSharePct}
aria-label={`Max. Anteil ${c.slug}`}
onChange={(e) =>
setEditor((ed) => {
if (!ed?.retrieval) return ed
const nc = ed.retrieval.categories.map((row) =>
row.slug === c.slug ? { ...row, maxSharePct: e.target.value } : row
)
return { ...ed, retrieval: { ...ed.retrieval, categories: nc } }
})
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p style={{ margin: '1rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Hinweis: <strong>Keyword-Overrides</strong> und etwaige <strong>main_min_share</strong>-Felder aus der Datenbank werden
beim Speichern mitgeschrieben ({editor.configExtras?.keyword_overrides?.length || 0} Overrides). Es gibt dafür
keine separate Maske.
</p>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary" style={{ flex: '1 1 120px' }} onClick={save}>
Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={closeEditor}>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -361,6 +361,33 @@ export async function getAdminHierarchy() {
return request('/api/admin/hierarchy')
}
// Superadmin: KI Skill-Retrieval-Profile (Migration 068, exercise_ai)
export async function listAiSkillRetrievalProfiles() {
return request('/api/admin/ai-skill-retrieval-profiles')
}
export async function getAiSkillRetrievalProfile(profileId) {
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`)
}
export async function createAiSkillRetrievalProfile(data) {
return request('/api/admin/ai-skill-retrieval-profiles', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateAiSkillRetrievalProfile(profileId, data) {
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteAiSkillRetrievalProfile(profileId) {
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, { method: 'DELETE' })
}
// ============================================================================
// Reifegradmodelle / Fähigkeitsmatrix
// ============================================================================
@ -764,6 +791,11 @@ export const api = {
updateFocusArea,
deleteFocusArea,
getAdminHierarchy,
listAiSkillRetrievalProfiles,
getAiSkillRetrievalProfile,
createAiSkillRetrievalProfile,
updateAiSkillRetrievalProfile,
deleteAiSkillRetrievalProfile,
listStyleDirections,
listTrainingStyles,
createStyleDirection,