Enhance Exercise Management and AI Integration
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Has been cancelled
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Has been cancelled
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr. - Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components. - Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes. - Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management. - Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
This commit is contained in:
parent
7245bbb1da
commit
e4451e1362
|
|
@ -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] **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] **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] 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] Exercise Blocks (Bausteine)
|
||||||
- [x] Saved Searches (wo implementiert)
|
- [x] Saved Searches (wo implementiert)
|
||||||
|
|
||||||
|
|
|
||||||
100
.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md
Normal file
100
.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md
Normal 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: Titel‑Vorschlag, Ziel-/Durchführungstext, Sicherheit/Organisation, ggf. Trainerhinweise – **immer als Vorschlagspaket**, nicht als Speicher ohne Bestätigung.
|
||||||
|
|
||||||
|
**Abgrenzung:** Kombinationsübungen / komplexe Methodenprofile können **phasenweise** später einbezogen werden (Verweis Fachspez Trainingsmodule).
|
||||||
|
|
||||||
|
### 2.2 Anleitung (Durchführung / „Ausführung“) maximal hilfreich
|
||||||
|
|
||||||
|
**Ziel:** Die **Ausführungs-/Anleitungsbereiche** sollen sich **didaktisch klar**, **teilbar** und **wieder verwendbar** lesen – ohne den Trainer zu entmindigen.
|
||||||
|
|
||||||
|
**KI-Aufgabe:** Überarbeitungsvorschlag für Struktur (nummerierte Schritte, Zeiten pro Block, häufige Fehler, Progressionshinweise innerhalb der Übung wo sinnvoll). **Selektiver** Aufruf: nur diese Felder oder nur ein markierter Abschnitt (wenn UX Textauswahl unterstützt).
|
||||||
|
|
||||||
|
### 2.3 Kurzbeschreibung (`summary`)
|
||||||
|
|
||||||
|
**KI-Aufgabe:** Aus den **relevanten Übungstexten** eine **Liste-/Karte-taugliche** Kurzfassung generieren — wie in **`KI_FEATURES_SPEC.md`** beschrieben, mit **Ablehnen / Bearbeiten / Übernehmen**.
|
||||||
|
|
||||||
|
### 2.4 Einordnung – primär **Fähigkeiten**
|
||||||
|
|
||||||
|
**KI-Aufgabe:** automatische Erkennung und **Zuordnung** zum **globale Skills-Katalog** inklusive:
|
||||||
|
|
||||||
|
- **Intensität** (`exercise_skills`)
|
||||||
|
- **Skill-Level**: `required_level` / `target_level` nach **kanonischen Slugs** (Backend-konform)
|
||||||
|
- **`is_primary`** / Priorisierung wo fachlich sinnvoll
|
||||||
|
|
||||||
|
**Prompt-Kontext für Qualität:** Stammfelder wie `skills.description`, **`karate_relevance`**, **`relevance_level`**, **`focus_areas`**, optional **`skill_level_definitions`** nur für eine **kurze Kandidatenliste** (zweite Runde möglich) – keine vollständigen Romane für den gesamten Katalog auf einmal.
|
||||||
|
|
||||||
|
### 2.5 Varianten (optional, später prioritär erwägenswert)
|
||||||
|
|
||||||
|
**Vision:** Aus Ziel-/Durchführungstext **mehrere sinnvolle Ausprägungen** als **Übungsvarianten** vorschlagen oder einzelne erzeugen (**progression**, **Schwierigkeit**, andere Paararbeit, Gerätevariation) mit **übernehmbarem** Datenmodell gleich dem bestehenden `exercise_variants`.
|
||||||
|
|
||||||
|
**Randbedingungen:** Validierung gegen Übungstyp (Kombinationsübungen ohne Varianten laut Produktstand), keine Halluzination fremder IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Kontextbezug später: Nachbearbeitung aus der Trainingsplanung
|
||||||
|
|
||||||
|
**Vision:** Hinweise aus der **Nachbearbeitung** einer Trainingseinheit (Ist‑Minuten, Trainer-Notizen, Abweichungen „was lief nicht?“ – je nach Datenmodell) fließen **optional** als Kontext in eine **erneute KI-Überarbeitung der betroffenen Übung** ein („Übung aus den Erfahrungen der Gruppe verbessern“).
|
||||||
|
|
||||||
|
**Konsequenz technisch später:** Zugriffsrechte, Mandant, keine unzulässige Verknüpfung personenbezogener Sportlerdaten; Aggregation auf **Einheit-/Gruppe** und **bereits dokumentierte Trainer-Insights**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Admin: Massenverarbeitung und Analyse
|
||||||
|
|
||||||
|
**Vision für Plattform-/Vereins-Admins:**
|
||||||
|
|
||||||
|
| Thema | Richtungsziel |
|
||||||
|
|-------|----------------|
|
||||||
|
| **Massenverarbeitung** | Batch: z. B. Zusammenfassungen nachziehen, fehlende Skills vorschlagen, einheitlicher Stil bei importiertem Bestand — immer mit **Review-Queue**, nicht ohne menschliche Freigabe skalierungskritisch. |
|
||||||
|
| **Analyse / Qualität** | Werkzeugkasten oder Berichte: **welche Übungen** sollten überarbeitet werden? z. B. leere/kurze `summary`, fehlende `goal`/`execution`, **fehlende oder widersprüchliche Skill-Zuordnung**, Import-Herkunft ohne Plausibilität, Kombi-Slots unvollständig, sehr alte Imports. |
|
||||||
|
| **Lückenkarten** | Z. B. Abgleich gegen **Skill-Discovery**/Profil-Analysen („keine Übung deckt Fähigkeit X ab“ auf gewähltem Korpus); Verbindung zu **`skill-discovery`** entscheidend später im Detail (kein automatischer Rewrite ohne Policy). |
|
||||||
|
|
||||||
|
**Governance:** Sichtbarkeit (`official`, Verein), Rechte (**Superadmin** vs. Vereinsinhalt), Audit der KI-Anwendung bei Massenjobs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phasierung (überarbeitungsfähig)
|
||||||
|
|
||||||
|
| Phase | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| **P0** | KI-Service + Prompts aus DB + **Suggestion-only** UX; Kern: **Summary** + **Skills** (wie Spec-Minimum), **ein Feld / Komplettpaket mit Diff** nach UX. |
|
||||||
|
| **P1** | **Anleitung überarbeiten** + **„von Idee zur Übung“** (Zielausbau) mit Rahmenparameter-Form |
|
||||||
|
| **P2** | **Variantenvorschläge** mit strenger Validation |
|
||||||
|
| **P3** | **Planungs-/Nachbereitungskontext** |
|
||||||
|
| **P4** | **Admin** Massen-/Analyse (Queue + Reports + Governance) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Offene Produkt-/Fachfragen
|
||||||
|
|
||||||
|
- Minimaler **Parameterbau** beim Zielausbau (Pflicht vs. optional).
|
||||||
|
- Umgang mit **Medien**/Inline-Verweisen beim KI-Text – nichts zerstören, Platzhalter erhalten (siehe Medien-Spec §11).
|
||||||
|
- **Kombinationsübungen:** welche Teilaspekte dürfen KI anfassen?
|
||||||
|
- Limits: **Tokens**, **Rate-Limits**, Kostenüberwachung pro Verein/global.
|
||||||
|
|
@ -57,7 +57,7 @@ Haupt-Kategorie (KARATE / ALLGEMEINE)
|
||||||
- Selbstverteidigung ✓
|
- Selbstverteidigung ✓
|
||||||
- Gewaltschutz ✓
|
- 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)
|
### 3. Hierarchischer Kontext (§8.1)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§ 10.4)**, kanonische Archetyp-IDs **§ 10.2.1**, **Anhang A** Implementierungsabgleich |
|
||||||
| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand |
|
| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand |
|
||||||
| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
|
| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
|
||||||
|
| [**KI-Unterstützung Übungen (Vision)**](./AI_EXERCISE_ASSISTANT_VISION.md) | Zielbild Zielausbau, Vorschlags-UX (teilweise/komplett), Skills/Varianten, später Planungskontext, Admin-Masse/Qualität |
|
||||||
|
| [**KI Übungen – Umsetzungsplan**](../working/AI_EXERCISE_IMPLEMENTATION_PLAN.md) | Stufen S0–S6, Driftschutz-Regeln, Checkliste gegen Specs |
|
||||||
|
|
||||||
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
|
**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`**.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
||||||
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
||||||
|
|
||||||
- Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht.
|
- 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.
|
- **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…“.
|
- **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.
|
- **`<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**.
|
- **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.
|
- 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`)
|
## 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 |
|
| Thema | Dokument |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
|
|
|
||||||
|
|
@ -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/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.
|
- `.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
|
||||||
|
|
|
||||||
|
|
@ -174,10 +174,9 @@ Wähle maximal 5 passende Fähigkeiten. Für jede gib an:
|
||||||
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
|
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
|
||||||
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
|
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
|
||||||
- intensity: Trainingsintensität (niedrig|mittel|hoch)
|
- intensity: Trainingsintensität (niedrig|mittel|hoch)
|
||||||
- is_primary: true wenn Hauptfähigkeit
|
|
||||||
|
|
||||||
Antworte NUR als JSON-Array:
|
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 [].$$,
|
Wenn keine Fähigkeit passt, antworte mit [].$$,
|
||||||
'exercise', 'json', true, NULL, 2),
|
'exercise', 'json', true, NULL, 2),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
# KI-gestützte Trainingsplanung – Zentrales Konzept
|
# KI-gestützte Trainingsplanung – Zentrales Konzept
|
||||||
|
|
||||||
**Version:** 0.1
|
**Version:** 0.2
|
||||||
**Datum:** 2026-05-16
|
**Datum:** 2026-05-22
|
||||||
**Status:** Arbeitsdokument (Verfeinerung durch fachliche Konzept-Agentur vorgesehen)
|
**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:**
|
**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) · `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,28 @@
|
||||||
- **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`).
|
- **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.
|
- **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 Übungs‑Suggest 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 + Skill‑Vorschlä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` (1–3, optional); dazu weiterhin `description`, `focus_areas`, Kategorien, `skill_level_definitions` (Level 1–5 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 Top‑K-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-/Admin‑UI zur Pflege von `ai_prompts`, **Rate-Limits**, optional **Auto-KI beim Speichern**; danach Übergang zur **Planungs-KI** laut diesem Dokument.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Kernproblem: Skalierung des Kontextes
|
## 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.
|
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 |
|
| Paketteil | Zweck |
|
||||||
|-----------|--------|
|
|-----------|--------|
|
||||||
|
|
@ -69,7 +86,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`).
|
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.
|
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“.
|
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 **Top‑K-Übung**-Auswahl in einer konkreten Session – dort weiter Stufen 1–2 + Punkte 1–4/LLM.
|
||||||
|
|
||||||
Ergebnis: sortierte Liste, **Top‑K** für den Prompt.
|
Ergebnis: sortierte Liste, **Top‑K** für den Prompt.
|
||||||
|
|
||||||
|
|
@ -128,7 +146,8 @@ Sinnvoller zeitlicher Punkt oder technische Auslöser:
|
||||||
|
|
||||||
Retrieval‑Qualität hängt stärker an **Metadaten** als an der Embedding-Technologie allein:
|
Retrieval‑Qualitä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` (1–3)**, **`focus_areas`**, Kategorien/Keywords für **Prompt-Kontext** beim Skill-Mapping bei der Übungsanlage; optional **`skill_level_definitions`** für Stufen 1–5 **gezielt** in die zweite Prompt-Runde (nur Kurzliste Kandidaten).
|
||||||
- sinnvolle **`summary`**-Felder für Karten/Liste/KI-Pack;
|
- sinnvolle **`summary`**-Felder für Karten/Liste/KI-Pack;
|
||||||
- **Progressionsgraph** dort, wo pädagogische Ketten gefestigt sind;
|
- **Progressionsgraph** dort, wo pädagogische Ketten gefestigt sind;
|
||||||
- konsistente **Fokusbereich/Stil**-Zuordnung.
|
- konsistente **Fokusbereich/Stil**-Zuordnung.
|
||||||
|
|
@ -139,15 +158,18 @@ Das fachliche Konzept sollte entscheiden: **wie viel automatische Pflege vs. Tra
|
||||||
|
|
||||||
## 7. Produkt-/Release-Stufen (Anknüpfung)
|
## 7. Produkt-/Release-Stufen (Anknüpfung)
|
||||||
|
|
||||||
|
Priorität **jetzt**: **Übungsanlage**, danach **Planung**.
|
||||||
|
|
||||||
| Stufe | Nutzen | Technik-Schwerpunkt |
|
| Stufe | Nutzen | Technik-Schwerpunkt |
|
||||||
|-------|--------|---------------------|
|
|-------|--------|---------------------|
|
||||||
| A | Backend-KI-Service + Prompt-Slugs unter `technical/AI_PROMPT_SYSTEM_SPEC.md` | OpenRouter, Timeouts, 503 ohne Key |
|
| **A0** | **Zentraler KI-Service** (ein Modul/Hilfslayer), Prompts aus `ai_prompts` | OpenRouter oder vereinbarter Provider, Timeouts, `503` ohne Key, Parsing/Validation |
|
||||||
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 1–3 + Prompt mit Top‑K |
|
| **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 1–3 + Prompt mit Top‑K (Übungsliste **keyset-pagination** beachten) |
|
||||||
| C | Reihenfolge / Zeitslots innerhalb bestehender Sektion | Graph + LLM Ranking |
|
| C | Reihenfolge / Zeitslots innerhalb bestehender Sektion | Graph + LLM Ranking |
|
||||||
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertere JSON-Ausgabe, strikte Schema-Validation |
|
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertes JSON + strikte Schema-Validation gegen bestehende `PUT`-Payloads |
|
||||||
| E | Mehreinheiten / Rahmen‑Alignment | Ziele aus Rahmenprogramm, Serie von Slots |
|
| E | Mehreinheiten / Rahmen‑Alignment | Ziele aus Rahmenprogramm, Serie von Slots; **Skill-Profile** (`…/skill-profile`) als Kontextuelle Verstärker |
|
||||||
|
|
||||||
Die **Selektions‑Pipeline §3 bleibt** über alle Stufen konsistent und wird nur parametrierbar erweitert.
|
Die **Selektions‑Pipeline §3** bleibt für **Planungs**-KI konsistent und wird parametrierbar erweitert; **§1.1** spiegelt den **aktuellen Implementierungs**-Vorsprung (Skill-Scoring ohne LLM) wider.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
# Exercises API Specification
|
# Exercises API Specification
|
||||||
|
|
||||||
**Version:** 1.5
|
**Version:** 1.6
|
||||||
**Datum:** 2026-05-08
|
**Datum:** 2026-05-20
|
||||||
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
|
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
|
||||||
**Autor:** Claude 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.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.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.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
|
**Ä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_id": 10,
|
||||||
"skill_name": "Distanzgefühl",
|
"skill_name": "Distanzgefühl",
|
||||||
"skill_category": "Kumite",
|
"skill_category": "Kumite",
|
||||||
"is_primary": true,
|
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau",
|
"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": [
|
"skills": [
|
||||||
{
|
{
|
||||||
"skill_id": 10,
|
"skill_id": 10,
|
||||||
"is_primary": true,
|
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau"
|
"target_level": "aufbau"
|
||||||
|
|
@ -578,7 +578,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau",
|
"target_level": "aufbau",
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"is_primary": true,
|
|
||||||
"confidence": 0.92
|
"confidence": 0.92
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -588,7 +587,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "einsteiger",
|
"required_level": "einsteiger",
|
||||||
"target_level": "grundlagen",
|
"target_level": "grundlagen",
|
||||||
"intensity": "mittel",
|
"intensity": "mittel",
|
||||||
"is_primary": false,
|
|
||||||
"confidence": 0.74
|
"confidence": 0.74
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -621,6 +619,38 @@ Trainer muss im Frontend aktiv übernehmen.
|
||||||
|
|
||||||
## Permissions
|
## 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
|
### Sichtbarkeits-Workflow
|
||||||
|
|
||||||
| Von → Nach | Wer darf das? |
|
| Von → Nach | Wer darf das? |
|
||||||
|
|
@ -638,11 +668,12 @@ Trainer muss im Frontend aktiv übernehmen.
|
||||||
| `club → official` | Club-Admin, Super-Admin |
|
| `club → official` | Club-Admin, Super-Admin |
|
||||||
| `official → club` | Super-Admin |
|
| `official → club` | Super-Admin |
|
||||||
|
|
||||||
### Owner-Checks
|
### Owner-Checks (veraltet — siehe Tabellen oben)
|
||||||
|
|
||||||
- **Bearbeiten** (PUT): Nur Ersteller oder Club-Admin
|
Die folgenden Kurzregeln sind durch die Ist-Implementierung ersetzt; nur zur historischen Einordnung:
|
||||||
- **Löschen** (DELETE): Nur Ersteller oder Super-Admin
|
|
||||||
- **Lesen** (`private`): Nur Ersteller
|
- ~~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:**
|
**403 Fehler-Beispiel:**
|
||||||
```json
|
```json
|
||||||
|
|
@ -904,7 +935,8 @@ Trainer muss im Frontend aktiv übernehmen.
|
||||||
### Exercise Skills
|
### Exercise Skills
|
||||||
- `required_level`: enum – `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
|
- `required_level`: enum – `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
|
||||||
- `target_level`: enum – gleiche Werte (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)
|
- `target_level` sollte >= `required_level` sein (Warnung, kein Fehler)
|
||||||
|
|
||||||
### Exercise Block Item
|
### Exercise Block Item
|
||||||
|
|
|
||||||
|
|
@ -99,20 +99,21 @@ Exercise Block ──── (N) Block Items ──── (1) Exercise
|
||||||
|
|
||||||
### 1.3 M:N Beziehungen (Primary/Secondary Pattern)
|
### 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_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_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
|
- **Primary:** Hauptzuordnung, entscheidend für Filter/Suche
|
||||||
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
|
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
|
||||||
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension
|
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension (wo UI das noch anbietet)
|
||||||
- **UI:** Primary wird visuell hervorgehoben (z.B. fett, farbig)
|
- **UI:** Primary wird visuell hervorgehoben (z. B. fett, farbig) — Fähigkeiten: Intensitäts-Segmente statt Primary
|
||||||
|
|
||||||
**Legacy-Felder (DEPRECATED):**
|
**Legacy-Felder (DEPRECATED):**
|
||||||
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
|
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
# Frontend Routing & Navigation Specification
|
# Frontend Routing & Navigation Specification
|
||||||
|
|
||||||
**Version:** 1.2
|
**Version:** 1.3
|
||||||
**Datum:** 2026-04-30
|
**Datum:** 2026-05-20
|
||||||
**Status:** DRAFT - Awaiting Review
|
**Status:** DRAFT - Awaiting Review
|
||||||
**Autor:** Claude Code
|
**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.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)
|
**Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@
|
||||||
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
||||||
/exercises/new → ExerciseFormPage (Create)
|
/exercises/new → ExerciseFormPage (Create)
|
||||||
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
|
/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 → ExerciseBlocksListPage (Meine Blocks)
|
||||||
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
||||||
|
|
@ -35,6 +36,25 @@
|
||||||
- Pagination: `/exercises?limit=50&offset=100`
|
- Pagination: `/exercises?limit=50&offset=100`
|
||||||
- Sortierung: `/exercises?sort=created_at&order=desc`
|
- 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
|
## 2. Navigation-Patterns
|
||||||
|
|
@ -673,7 +693,7 @@ function App() {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version:** 1.2
|
**Version:** 1.3
|
||||||
**Letzte Änderung:** 2026-04-30
|
**Letzte Änderung:** 2026-05-20
|
||||||
**Status:** REVIEWED - Pending Implementation
|
**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)
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,16 @@
|
||||||
**Änderungen v1.1:** Prompts sind nicht hardcoded – sie werden aus der DB geladen (AI_PROMPT_SYSTEM_SPEC.md)
|
**Ä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)
|
**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
|
## 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 |
|
| Funktion | Ziel |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
|
|
@ -182,7 +187,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau",
|
"target_level": "aufbau",
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"is_primary": true,
|
|
||||||
"confidence": 0.92
|
"confidence": 0.92
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -192,7 +196,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "einsteiger",
|
"required_level": "einsteiger",
|
||||||
"target_level": "grundlagen",
|
"target_level": "grundlagen",
|
||||||
"intensity": "mittel",
|
"intensity": "mittel",
|
||||||
"is_primary": false,
|
|
||||||
"confidence": 0.74
|
"confidence": 0.74
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
| exercises | `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 | `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 | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
|
||||||
|
| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; Sendung an OpenRouter |
|
||||||
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
|
| 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 |
|
| 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) |
|
| 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) |
|
||||||
|
|
@ -38,12 +39,14 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
|
|
||||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
**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-22 — `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` (Übungs-KI, kein Persist durch Endpunkt).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Changelog (Fortführung)
|
### Changelog (Fortführung)
|
||||||
|
|
||||||
|
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||||
|
|
||||||
- **2026-05-13:** Dashboard-KPI-Endpunkt 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:** 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`.
|
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||||
|
|
|
||||||
58
.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
Normal file
58
.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Umsetzungsplan – KI bei Übungen (stufenweise, Driftschutz)
|
||||||
|
|
||||||
|
**Version:** 0.1
|
||||||
|
**Datum:** 2026-05-22
|
||||||
|
**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen.
|
||||||
|
4. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec).
|
||||||
|
5. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`.
|
||||||
|
6. **Schema:** Neue DB-Objekte nur nummerierte Migration `backend/migrations/067_*.sql` (oder folgend); `DB_SCHEMA_VERSION` in `backend/version.py` anheben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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, Teilübernahme Summary + Skills | Manuelle UX-Prüfung |
|
||||||
|
| **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config |
|
||||||
|
| **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics |
|
||||||
|
|
||||||
|
**Aktueller Implementierungsstand nach Merge:** S0–S4 anstreben; S5/S6 nicht Teil dieses Laufs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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; S1–S4 als erster Umsetzungspfad.
|
||||||
|
- **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
**Bewusst noch nicht:** automatische KI beim Speichern (**S5**), Setzen von `summary_ai_generated` bei manuellen UI-Änderungen, Prompt-Admin-UI, Rate-Limits.
|
||||||
|
|
||||||
|
|
@ -180,12 +180,17 @@ def init_db():
|
||||||
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
|
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
|
||||||
if cur.fetchone()['count'] == 0:
|
if cur.fetchone()['count'] == 0:
|
||||||
cur.execute("""
|
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 (
|
VALUES (
|
||||||
'pipeline',
|
'pipeline',
|
||||||
'Mehrstufige Gesamtanalyse',
|
'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',
|
'PIPELINE_MASTER',
|
||||||
|
'admin',
|
||||||
|
'text',
|
||||||
true,
|
true,
|
||||||
-10
|
-10
|
||||||
)
|
)
|
||||||
|
|
|
||||||
320
backend/exercise_ai.py
Normal file
320
backend/exercise_ai.py
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
"""
|
||||||
|
KI-Vorschlaege fuer Uebungsformular: Laedt Prompts aus ai_prompts, ruft OpenRouter auf.
|
||||||
|
Keine persistente Aenderung an exercises — nur Response-DTO fuer das Frontend.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
||||||
|
|
||||||
|
_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"})
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE)
|
||||||
|
|
||||||
|
_MAX_PLAIN_FIELD = 28_000
|
||||||
|
_MAX_SKILLS_CATALOG_LINES = 240
|
||||||
|
_MAX_SUMMARY_CHARS = 220
|
||||||
|
|
||||||
|
|
||||||
|
def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) -> str:
|
||||||
|
if not html:
|
||||||
|
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 _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 _build_skills_catalog_block(cur) -> str:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.id, s.name, s.category, s.description, s.karate_relevance, s.relevance_level,
|
||||||
|
sc.name AS subcategory_name
|
||||||
|
FROM skills s
|
||||||
|
LEFT JOIN skill_categories sc ON s.category_id = sc.id
|
||||||
|
WHERE (s.status IS NULL OR s.status = 'active')
|
||||||
|
ORDER BY s.importance DESC NULLS LAST, s.name
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(_MAX_SKILLS_CATALOG_LINES,),
|
||||||
|
)
|
||||||
|
lines: List[str] = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
rid = int(r["id"])
|
||||||
|
nm = (r.get("name") or "").strip() or f"Skill #{rid}"
|
||||||
|
cat = (r.get("category") or "").strip()
|
||||||
|
sub = (r.get("subcategory_name") or "").strip()
|
||||||
|
dsc = strip_html_to_plain(r.get("description"), max_len=320)
|
||||||
|
kr = strip_html_to_plain(r.get("karate_relevance"), max_len=200)
|
||||||
|
rel = r.get("relevance_level")
|
||||||
|
rel_s = ""
|
||||||
|
if rel is not None:
|
||||||
|
rel_s = str(rel)
|
||||||
|
|
||||||
|
cats = " / ".join(x for x in (cat, sub) if x)
|
||||||
|
|
||||||
|
blob = (
|
||||||
|
f"- id={rid} | name={nm} | kategorie={cats or '-'}"
|
||||||
|
f" | beschreibung={dsc or '-'} | karate_relevanz={kr or '-'}"
|
||||||
|
f" | relevanz_stufe={rel_s or '-'}"
|
||||||
|
)
|
||||||
|
lines.append(blob)
|
||||||
|
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_array(text: str) -> Any:
|
||||||
|
s = text.strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
|
if s.endswith("```"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
# array whole string
|
||||||
|
if s.startswith("["):
|
||||||
|
end = s.rfind("]")
|
||||||
|
if end > 0:
|
||||||
|
s = s[: end + 1]
|
||||||
|
return json.loads(s)
|
||||||
|
# object wrapping array
|
||||||
|
if s.startswith("{"):
|
||||||
|
obj = json.loads(s)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k in ("skills", "items", "data"):
|
||||||
|
v = obj.get(k)
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
raise ValueError("JSON-Objekt ohne Skills-Liste")
|
||||||
|
return json.loads(s)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for raw in rows:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# max 5
|
||||||
|
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],
|
||||||
|
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 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)
|
||||||
|
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
|
||||||
|
text = (raw or "").strip()
|
||||||
|
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_skills_catalog_block(cur)
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
parsed = _extract_json_array(raw)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="KI lieferte kein verwertbares JSON fuer Skills.",
|
||||||
|
) from e
|
||||||
|
skills = _sanitize_skill_entries(cur, parsed)
|
||||||
|
result["skills"] = skills
|
||||||
|
|
||||||
|
return result
|
||||||
141
backend/migrations/067_ai_prompts_exercise_assistant.sql
Normal file
141
backend/migrations/067_ai_prompts_exercise_assistant.sql
Normal 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');
|
||||||
100
backend/openrouter_chat.py
Normal file
100
backend/openrouter_chat.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""
|
||||||
|
Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MODEL / OPENROUTER_BASE_URL from env.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
content = ""
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
content = str(inner.get("content") or "")
|
||||||
|
elif isinstance(inner, str):
|
||||||
|
content = inner
|
||||||
|
elif isinstance(msg0.get("content"), str):
|
||||||
|
content = msg0.get("content") or ""
|
||||||
|
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
httpx==0.27.2
|
||||||
fastapi==0.111.0
|
fastapi==0.111.0
|
||||||
uvicorn[standard]==0.29.0
|
uvicorn[standard]==0.29.0
|
||||||
anthropic==0.26.0
|
anthropic==0.26.0
|
||||||
|
|
|
||||||
|
|
@ -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_storage import get_effective_media_root, library_storage_key, path_under_media_root
|
||||||
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
|
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
|
||||||
from media_legal_hold import assert_not_under_legal_hold
|
from media_legal_hold import assert_not_under_legal_hold
|
||||||
|
from exercise_ai import run_exercise_ai_suggestion
|
||||||
|
|
||||||
from exercise_rich_text import (
|
from exercise_rich_text import (
|
||||||
RICH_HTML_EXERCISE_FIELDS,
|
RICH_HTML_EXERCISE_FIELDS,
|
||||||
assert_no_inline_media_references_on_create,
|
assert_no_inline_media_references_on_create,
|
||||||
|
|
@ -356,6 +358,42 @@ class ExerciseMediaFromAsset(BaseModel):
|
||||||
media_type: Optional[str] = None
|
media_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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):
|
class ExerciseVariantCreate(BaseModel):
|
||||||
variant_name: str = Field(..., min_length=3, max_length=200)
|
variant_name: str = Field(..., min_length=3, max_length=200)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
@ -1244,7 +1282,7 @@ def assign_exercise_relations(
|
||||||
(
|
(
|
||||||
exercise_id,
|
exercise_id,
|
||||||
skill["skill_id"],
|
skill["skill_id"],
|
||||||
False,
|
bool(skill.get("is_primary")),
|
||||||
normalize_exercise_skill_intensity(skill.get("intensity")),
|
normalize_exercise_skill_intensity(skill.get("intensity")),
|
||||||
normalize_exercise_skill_level(skill.get("required_level")),
|
normalize_exercise_skill_level(skill.get("required_level")),
|
||||||
normalize_exercise_skill_level(skill.get("target_level")),
|
normalize_exercise_skill_level(skill.get("target_level")),
|
||||||
|
|
@ -2216,6 +2254,75 @@ def list_exercises_like_get(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
want_summary=want_summary,
|
||||||
|
want_skills=want_skills,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/exercises/{exercise_id}")
|
@router.get("/exercises/{exercise_id}")
|
||||||
def get_exercise(
|
def get_exercise(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.151"
|
APP_VERSION = "0.8.152"
|
||||||
BUILD_DATE = "2026-05-20"
|
BUILD_DATE = "2026-05-22"
|
||||||
DB_SCHEMA_VERSION = "20260520066"
|
DB_SCHEMA_VERSION = "20260522067"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
|
|
@ -22,7 +22,7 @@ MODULE_VERSIONS = {
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
"exercises": "2.29.0", # POST exercises/ai/suggest + …/ai/regenerate (OpenRouter); exercise_ai; is_primary fuer exercise_skills
|
||||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -37,6 +37,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.8.151",
|
||||||
"date": "2026-05-20",
|
"date": "2026-05-20",
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,10 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
|
||||||
|
|
||||||
### 4.1 Übungen (Kernobjekt)
|
### 4.1 Übungen (Kernobjekt)
|
||||||
|
|
||||||
- **Anlegen, Bearbeiten, Archivieren/Löschen** je nach Rolle und Sichtbarkeit.
|
- **Anlegen, Bearbeiten, Archivieren/Löschen** je nach Rolle und Freigabelevel (siehe §4.7).
|
||||||
- **Mehrdimensionale Einordnung:** Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, **Fähigkeiten mit Stufen**; Suche und Filter über diese Dimensionen.
|
- **Bearbeitungsformular (Registerkarten):** Stammdaten · Anleitung · Einordnung · (Kombination) · Varianten · Medien & Mehr — reduziert Scroll-Tiefe; farbige Panel-Trenner; Varianten und Medien erst nach erstem Speichern.
|
||||||
- **Übungsvarianten:** mehrere Ausprägungen einer Übung (z. B. Aufbau, Schwierigkeit, Material), mit Reihenfolge und optionaler **Voraussetzungsvariante**.
|
- **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.
|
- **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.
|
- **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).
|
- **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
|
### 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).
|
- **Ä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)
|
### 4.8 Inhaltsmeldungen (P-13, vertrauens- und compliance-orientiert)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
|
||||||
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
|
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
|
||||||
| **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.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`** |
|
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -79,6 +79,15 @@ 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
|
- **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
|
- **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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz)
|
## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz)
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,11 @@ export function buildExerciseApiPayload(formData, extras = {}) {
|
||||||
age_groups: [],
|
age_groups: [],
|
||||||
skills: (formData.skills || []).map((s) => ({
|
skills: (formData.skills || []).map((s) => ({
|
||||||
skill_id: s.skill_id,
|
skill_id: s.skill_id,
|
||||||
|
is_primary: !!s.is_primary,
|
||||||
intensity: normalizeExerciseSkillIntensity(s.intensity),
|
intensity: normalizeExerciseSkillIntensity(s.intensity),
|
||||||
required_level: s.required_level || null,
|
required_level: s.required_level || null,
|
||||||
target_level: s.target_level || null,
|
target_level: s.target_level || null,
|
||||||
|
ai_suggested: !!s.ai_suggested,
|
||||||
})),
|
})),
|
||||||
visibility: visibilityNorm,
|
visibility: visibilityNorm,
|
||||||
status: formData.status || 'draft',
|
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. */
|
/** KI (OpenRouter): Nur Vorschlaege; Speichern ueber normales exercise PUT/POST. */
|
||||||
export async function suggestExerciseAi(payload) {
|
export async function suggestExerciseAi(payload = {}) {
|
||||||
return request('/api/exercises/ai/suggest', {
|
return request('/api/exercises/ai/suggest', {
|
||||||
method: 'POST',
|
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`, {
|
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({
|
||||||
|
regenerate: ['summary', 'skills'],
|
||||||
|
...payload,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '../../utils/exerciseInlineMediaRefs'
|
} from '../../utils/exerciseInlineMediaRefs'
|
||||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||||
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||||
|
import { stripHtmlToText } from '../../utils/htmlUtils'
|
||||||
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
||||||
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
|
@ -71,6 +72,23 @@ const comboTinyNumberInputSx = {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtmlText(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 emptyComboSlotRow() {
|
function emptyComboSlotRow() {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
|
|
@ -417,6 +435,8 @@ function detailToForm(exercise) {
|
||||||
intensity: normalizeExerciseSkillIntensity(s.intensity),
|
intensity: normalizeExerciseSkillIntensity(s.intensity),
|
||||||
required_level: normalizeSkillLevelSlug(s.required_level),
|
required_level: normalizeSkillLevelSlug(s.required_level),
|
||||||
target_level: normalizeSkillLevelSlug(s.target_level),
|
target_level: normalizeSkillLevelSlug(s.target_level),
|
||||||
|
is_primary: !!s.is_primary,
|
||||||
|
ai_suggested: !!s.ai_suggested,
|
||||||
})) || [],
|
})) || [],
|
||||||
exercise_kind:
|
exercise_kind:
|
||||||
String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
|
String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
|
||||||
|
|
@ -503,6 +523,7 @@ function ExerciseFormPageRoot() {
|
||||||
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
|
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
|
||||||
const [variantSavingId, setVariantSavingId] = useState(null)
|
const [variantSavingId, setVariantSavingId] = useState(null)
|
||||||
const [variantBusy, setVariantBusy] = useState(false)
|
const [variantBusy, setVariantBusy] = useState(false)
|
||||||
|
const [aiSuggestBusy, setAiSuggestBusy] = useState(false)
|
||||||
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
||||||
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
|
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
|
||||||
const variantsSavedSnapshotRef = useRef({})
|
const variantsSavedSnapshotRef = useRef({})
|
||||||
|
|
@ -855,6 +876,83 @@ 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(', ')
|
||||||
|
|
||||||
|
setAiSuggestBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await api.suggestExerciseAi({
|
||||||
|
title: (formData.title || '').trim(),
|
||||||
|
goal: formData.goal || '',
|
||||||
|
execution: formData.execution || '',
|
||||||
|
focus_area_hint: focusHint || undefined,
|
||||||
|
include_summary: summaryOn,
|
||||||
|
include_skills: skillsOn,
|
||||||
|
})
|
||||||
|
|
||||||
|
let applied = false
|
||||||
|
|
||||||
|
if (summaryOn && res.summary?.text) {
|
||||||
|
updateFormField('summary', aiPlainSummaryToMinimalHtml(res.summary.text))
|
||||||
|
applied = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillsOn && Array.isArray(res.skills) && res.skills.length) {
|
||||||
|
setFormDirty(true)
|
||||||
|
setFormData((prev) => {
|
||||||
|
const next = [...(prev.skills || [])]
|
||||||
|
for (const sug of res.skills) {
|
||||||
|
const sid = Number(sug.skill_id)
|
||||||
|
if (!Number.isFinite(sid)) continue
|
||||||
|
const row = {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
applied = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!applied) {
|
||||||
|
toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.')
|
||||||
|
} else {
|
||||||
|
toast.success('KI-Vorschlag ins Formular übernommen — bitte prüfen und speichern.')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err?.message || String(err))
|
||||||
|
} finally {
|
||||||
|
setAiSuggestBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const refreshVariants = useCallback(async () => {
|
const refreshVariants = useCallback(async () => {
|
||||||
if (!exerciseId) return
|
if (!exerciseId) return
|
||||||
const ex = await api.getExercise(exerciseId)
|
const ex = await api.getExercise(exerciseId)
|
||||||
|
|
@ -1309,7 +1407,28 @@ function ExerciseFormPageRoot() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<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
|
<RichTextEditor
|
||||||
value={formData.summary}
|
value={formData.summary}
|
||||||
onChange={(html) => updateFormField('summary', html)}
|
onChange={(html) => updateFormField('summary', html)}
|
||||||
|
|
@ -1966,6 +2085,42 @@ function ExerciseFormPageRoot() {
|
||||||
title="Einordnung"
|
title="Einordnung"
|
||||||
hint="Fokus, Stile, Zielgruppen und Fähigkeiten für Suche, Filter und Skill-Profil."
|
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>
|
||||||
|
· Vorschläge werden ins Formular übernommen und nicht automatisch gespeichert.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="exercise-form-meta-panel" aria-label="Klassifikation">
|
<section className="exercise-form-meta-panel" aria-label="Klassifikation">
|
||||||
<div className="exercise-form-meta-panel__grid">
|
<div className="exercise-form-meta-panel__grid">
|
||||||
<ExerciseCatalogAssocEditor
|
<ExerciseCatalogAssocEditor
|
||||||
|
|
@ -2504,11 +2659,9 @@ function ExerciseFormPageRoot() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
||||||
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
|
<strong>KI-Unterstützung:</strong> OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
|
||||||
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}
|
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme nur im Formular; Speichern
|
||||||
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> — z. B.{' '}
|
wie gewohnt.
|
||||||
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
|
|
||||||
<code>api.suggestExerciseAi</code>).
|
|
||||||
</p>
|
</p>
|
||||||
<UnsavedChangesPrompt
|
<UnsavedChangesPrompt
|
||||||
blocker={blocker}
|
blocker={blocker}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user