KI Implementierung (MVP) auf Übungen #46
|
|
@ -83,7 +83,9 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
- [x] **Varianten** (CRUD, Reorder, Voraussetzung) + Anzeige im Detail
|
||||
- [x] **Progressionsgraph zwischen Übungen** (Bibliotheks-Container, Kanten, Sequenz-Bulk, Varianten-Knoten — Zwischenstand, siehe TRAINING_FRAMEWORK_SPEC §4)
|
||||
- [x] Medien (Upload/Embed, rollenabhängige Größenlimits)
|
||||
- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen)
|
||||
- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen; **Freigabelevel** als UI-Begriff für `visibility`)
|
||||
- [x] **Übungsformular:** Registerkarten (Stammdaten … Medien & Mehr), kompakte Chip-Editoren, Varianten-Speichern über Aktionsleiste
|
||||
- [x] **Fähigkeiten-Intensität** ohne Primär-Flag (`niedrig`/`mittel`/`hoch`; Backend `is_primary` immer false)
|
||||
- [x] Exercise Blocks (Bausteine)
|
||||
- [x] Saved Searches (wo implementiert)
|
||||
|
||||
|
|
|
|||
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 ✓
|
||||
- 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ Ausführliche fachliche Inhalte:
|
|||
| [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§ 10.4)**, kanonische Archetyp-IDs **§ 10.2.1**, **Anhang A** Implementierungsabgleich |
|
||||
| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand |
|
||||
| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
|
||||
| [**KI-Unterstützung Übungen (Vision)**](./AI_EXERCISE_ASSISTANT_VISION.md) | Zielbild Zielausbau, Vorschlags-UX (teilweise/komplett), Skills/Varianten, später Planungskontext, Admin-Masse/Qualität |
|
||||
| [**KI Übungen – Umsetzungsplan**](../working/AI_EXERCISE_IMPLEMENTATION_PLAN.md) | Stufen S0–S6, Driftschutz-Regeln, Checkliste gegen Specs |
|
||||
|
||||
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
|||
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
||||
|
||||
- Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht.
|
||||
- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, Sichtbarkeit, Status).
|
||||
- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, **Freigabelevel**, Status).
|
||||
- **Filter-Chips** unter der Suchleiste; Klick entfernt einen Filter; Badge am Filter-Button = Anzahl Chips.
|
||||
- **Kein Vollbild-Spinner** bei jeder Suche: nur noch **`listFetching`** — Suchfelder bleiben im DOM (**Fokus/Cursor** bleiben erhalten); Liste zeigt optional „Aktualisiere Treffer…“.
|
||||
- **`<datalist>`** mit Titeln der aktuellen Treffer; **`autoComplete="on"`** für Browser-Vorschläge.
|
||||
|
|
@ -76,14 +76,47 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
|||
|
||||
---
|
||||
|
||||
## 6. Frontend – Übung bearbeiten (`ExerciseFormPage.jsx`)
|
||||
## 6. Frontend – Übung bearbeiten (`ExerciseFormPageRoot.jsx`)
|
||||
|
||||
**Routing:** `/exercises/new`, `/exercises/:id/edit` — keine separaten Varianten-Routen.
|
||||
|
||||
### 6.1 Tab-Navigation (Registerkarten)
|
||||
|
||||
Horizontale **`PageSectionNav`** über **`ExerciseFormTabBar`** / **`ExerciseFormPanel`** (`ExerciseFormLayout.jsx`); farbige linke Panel-Ränder (CSS `.exercise-form-edit`, `.exercise-form-panel--*`).
|
||||
|
||||
| Tab | Inhalt |
|
||||
|-----|--------|
|
||||
| **Stammdaten** | Titel, Kurztext, Dauer/Gruppe, Equipment, **Freigabelevel** (`visibility`), Status, Verein |
|
||||
| **Anleitung** | Ziel, Durchführung, Vorbereitung, Trainerhinweise (Rich-Text inkl. Inline-Medien) |
|
||||
| **Einordnung** | Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, Altersgruppen, **Fähigkeiten** (kompakte Chip-Editoren) |
|
||||
| **Kombination** | nur bei `exercise_kind=combination`: Slots, Archetyp, `method_profile` |
|
||||
| **Varianten** | nur nach erstem Speichern; **nicht** bei Kombinationsübungen |
|
||||
| **Medien & Mehr** | Medien, Progressionsgraph, KI-Hilfen, Löschen — nach erstem Speichern |
|
||||
|
||||
Neue Übungen: Tabs **Varianten** und **Medien & Mehr** deaktiviert bis zur ersten Speicherung.
|
||||
|
||||
### 6.2 Freigabelevel (UI-Begriff)
|
||||
|
||||
Feld **`exercises.visibility`** heißt in der UI durchgängig **Freigabelevel** (`frontend/src/constants/exerciseGovernanceLabels.js`) — Liste, Filter, Bulk, Picker, Formular. API/DB-Feldname **`visibility`** unverändert.
|
||||
|
||||
### 6.3 Fähigkeiten am Übungsobjekt
|
||||
|
||||
- Intensität je Fähigkeit: **`niedrig` \| `mittel` \| `hoch`**, Standard **`mittel`** (`exerciseSkillIntensity.js`).
|
||||
- Kein „Primär“-Schalter mehr in der UI; **`is_primary`** bei `exercise_skills` ist Legacy — Backend speichert immer **`false`**, Scoring ignoriert das Feld.
|
||||
- Kompakte **Chip-Editoren** für Katalog-Zuordnungen und Fähigkeiten (`ExerciseCatalogAssocEditor`, `ExerciseSkillsEditor`).
|
||||
|
||||
### 6.4 Varianten-Editor
|
||||
|
||||
- Tab **Varianten**: **eine Variante zur Zeit** (Dropdown oder „Erste Variante anlegen“); Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Löschen pro Variante.
|
||||
- **Speichern über Aktionsleiste:** `performSaveAttempt` ruft zuerst **`persistPendingVariantChanges()`** auf (geänderte Varianten per PUT, danach optional Entwurf **`createVariantFromDraft()`**).
|
||||
- Button **„Variante anlegen“** (`type="button"`, kein verschachteltes `<form>`): legt Entwurf sofort per API an; alternativ mitgesichert über **Speichern** in der Aktionsleiste.
|
||||
- Snapshot **`variantsSavedSnapshotRef`** für Dirty-Erkennung; Hinweis im Panel: Änderungen werden mit Speichern in der Aktionsleiste mitgesichert.
|
||||
|
||||
### 6.5 Medien & Progressionsgraph
|
||||
|
||||
- **Varianten-Editor**: eingeklappter Bereich (`<details>`), **eine Variante zur Zeit** über Dropdown oder „Neue Variante“; Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Speichern/Löschen pro Variante.
|
||||
- **Medien:** Upload/Embed, **Archiv verknüpfen** (`from-asset`), Medienliste mit Vorschau, Reaktivierung bei Archiv-Konflikt — Details **§12**.
|
||||
- Block **Progressionsgraph** (Edit): Kanten mit Bezug zur aktuellen Übung.
|
||||
|
||||
Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Bearbeitung erfolgt unter **`/exercises/:id/edit`** (Routing-Doku ggf. anpassen).
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend – Übung Detail (`ExerciseDetailPage.jsx`)
|
||||
|
|
@ -192,7 +225,21 @@ Norm: **`technical/SKILL_SCORING_SPEC.md`**.
|
|||
|
||||
---
|
||||
|
||||
## 16. Verweise
|
||||
## 16. Übungen – Governance & Berechtigungen (Ist, Stand 2026-05-20)
|
||||
|
||||
**Owner:** `exercises.created_by` (Ersteller). **Varianten** haben kein eigenes `created_by` — Rechte leiten sich von der Eltern-Übung ab.
|
||||
|
||||
| Aktion | `private` | `club` | `official` |
|
||||
|--------|-----------|--------|------------|
|
||||
| **Lesen** | Ersteller; Plattform-Admin | Aktive Vereinsmitglieder des Objekt-`club_id`; Plattform-Admin ohne Mitgliedschaft (Audit) | Plattform-weit |
|
||||
| **Bearbeiten** (Übung inkl. Varianten/Medien) | Ersteller; Plattform-Admin | Ersteller; Plattform-Admin; **`can_plan_in_club`** im Objekt-Verein (`trainer`, `content_editor`, `division_lead`, `club_admin`) | Plattform-Admin |
|
||||
| **Löschen** | Ersteller; Vereins-Admin gemeinsamer Vereine mit Ersteller | Nur **`club_admin`** im Objekt-Verein | Nur Plattform-Admin |
|
||||
|
||||
**Code:** `backend/club_tenancy.py` (`exercise_visible_to_profile`, `can_plan_in_club`), `backend/routers/exercises.py` (`_assert_can_edit_exercise`, `_assert_can_delete_exercise`).
|
||||
|
||||
---
|
||||
|
||||
## 17. Verweise
|
||||
|
||||
| Thema | Dokument |
|
||||
|--------|----------|
|
||||
|
|
|
|||
|
|
@ -119,8 +119,24 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
|||
|
||||
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe.
|
||||
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
|
||||
- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
|
||||
- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, `can_plan_in_club`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-05-07
|
||||
## 8. Anhang – Übungen (Ist-Implementierung, Referenz)
|
||||
|
||||
**Stand:** 2026-05-20 · **Detail:** `EXERCISES_API_SPEC.md` Permissions, `FEATURES_DELIVERED_2026-Q2.md` §16
|
||||
|
||||
| Feld / Konzept | Semantik |
|
||||
|----------------|----------|
|
||||
| `created_by` | Owner der Übung; Varianten erben Rechte |
|
||||
| `visibility` | UI: **Freigabelevel** — `private` \| `club` \| `official` |
|
||||
| Lesen | `exercise_visible_to_profile` — `official` global; `private` Ersteller + Plattform-Admin; `club` aktive Mitglieder (+ Plattform-Admin Audit) |
|
||||
| Bearbeiten | Ersteller; Plattform-Admin; bei `club` zusätzlich `can_plan_in_club` (Trainer, Content-Editor, Spartenleitung, Vereins-Admin) |
|
||||
| Löschen | `official` → Plattform-Admin; `club` → `club_admin` im Objekt-Verein; `private` → Ersteller oder Vereins-Admin mit gemeinsamem Verein |
|
||||
|
||||
**Hinweis:** Dieser Anhang dokumentiert den **produktiven Code-Pfad** in `exercises.py`; die Roadmap in §4 bleibt für die langfristige Vereinheitlichung aller Bibliotheksartefakte maßgeblich.
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-05-20
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
**Autor:** Claude Code
|
||||
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
||||
|
||||
**Verwandt (Skill-Katalog in Übungs-KI):** `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md` — Tabelle **`ai_skill_retrieval_profiles`** (`config`-JSON ergänzt inhaltliche Prompt-/Katalog-Steuerung neben Platzhaltern).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konzept
|
||||
|
|
@ -174,10 +176,9 @@ Wähle maximal 5 passende Fähigkeiten. Für jede gib an:
|
|||
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
|
||||
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
|
||||
- intensity: Trainingsintensität (niedrig|mittel|hoch)
|
||||
- is_primary: true wenn Hauptfähigkeit
|
||||
|
||||
Antworte NUR als JSON-Array:
|
||||
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
||||
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch"}]
|
||||
|
||||
Wenn keine Fähigkeit passt, antworte mit [].$$,
|
||||
'exercise', 'json', true, NULL, 2),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
# KI-gestützte Trainingsplanung – Zentrales Konzept
|
||||
|
||||
**Version:** 0.1
|
||||
**Datum:** 2026-05-16
|
||||
**Version:** 0.3
|
||||
**Datum:** 2026-05-22
|
||||
**Status:** Arbeitsdokument (Verfeinerung durch fachliche Konzept-Agentur vorgesehen)
|
||||
**Ziel:** Einheitlicher Rahmen für **stufenweise** KI-Unterstützung bei der Planung (Abschnitte, Einheiten, später mehrtägig/Rahmen) – ohne vollständigen Katalog im Prompt zu spiegeln.
|
||||
**Ziel:** Einheitlicher Rahmen für **stufenweise** KI-Unterstützung – zuerst **Übungsanlage** (Zusammenfassung, Fähigkeiten, Texte), später **Planung** (Abschnitte, Einheiten, Rahmen) – ohne vollständigen Übungskatalog im Prompt.
|
||||
|
||||
**Maßgebende Version zum Abgleich:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, relevante Einträge in `MODULE_VERSIONS`).
|
||||
|
||||
**Verwandte Dokumente:**
|
||||
`functional/DOMAIN_MODEL.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (u. a. CURR-003 zu Progressions-/KI-Automatik) · `technical/TRAINING_FRAMEWORK_SPEC.md` · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `docs/FACHLICHE_NUTZERFUNKTIONEN.md` · `docs/HANDOVER.md`
|
||||
`functional/DOMAIN_MODEL.md` · **`functional/AI_EXERCISE_ASSISTANT_VISION.md`** (Übungs-KI: Zielbild vor Planungs-KI) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (u. a. CURR-003 zu Progressions-/KI-Automatik) · **`working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md`** (mehrstufige Planungs-KI: Daten-„Graph“, Pipeline-Stufen, Code-Schnitte – Vorschau gegen späteres Refactoring) · `technical/TRAINING_FRAMEWORK_SPEC.md` · **`technical/SKILL_SCORING_SPEC.md`** (Fähigkeits-Profilierung, Discovery) · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/SKILLS_MATRIX_SPEC.md` · `docs/FACHLICHE_NUTZERFUNKTIONEN.md` · `docs/HANDOVER.md`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -16,13 +18,30 @@
|
|||
- **Human-in-the-loop:** KI liefert **Vorschläge** (Liste, Reihenfolge, Begründung); schreibende Übernahme in Pläne nur nach **Trainer-Bestätigung** oder expliziter Aktion (analog „Manual First“ in `KI_FEATURES_SPEC.md`).
|
||||
- **Governance-first:** Nur Übungen, die die API bereits für den Mandanten/Kontext **sichtbar** freigibt, dürfen in Kandidatenlisten landen – **vor** Retrieval und **vor** jedem Prompt.
|
||||
|
||||
### 1.1 Abgleich: aktueller Code- und Schema-Stand (Stand Review 2026-05-22)
|
||||
|
||||
| Thema | Ist im Repo | Konsequenz für dieses Konzept |
|
||||
|--------|-------------|-------------------------------|
|
||||
| **OpenRouter / LLM im Backend** | Produktiver Aufruf für Ü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.
|
||||
|
||||
**Architektur-Vorschau (Planungs-KI):** Damit die **kleinere, starre** Übungs-Pipeline nicht zur stillen Vorlage für Planung wird, sind **eigenes Modul**, **stufenweise Outputs mit Validierung** und ein **kompaktes Kontext-DTO** vorgesehen — siehe **`working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md`**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kernproblem: Skalierung des Kontextes
|
||||
|
||||
Aus einer **großen Übungssammlung** („>1000 Übungen“) können weder alle **Felder** (Ziele, Ablauf, Skills, Varianten …) noch alle **Zeilen** sinnvoll in einen LLM-Prompt.
|
||||
|
||||
**Festlegung:** Der LLM-Prompt erhält immer nur ein **begrenztes Kontext-Paket** mit:
|
||||
**Abgrenzung Übungsanlage (aktueller Prioritätspfad):** Hier geht der Prompt typischerweise von **einzelnen** Freitexten (`title`, `goal`, `execution`, …) und einem **Skills-Katalog-Auszug** aus – nicht vom gesamten Übungsbestand. Trotzdem gilt: Aktive Skills **paginieren** oder **stufig** laden (Subset + zweite Runde nur für Kurzliste), keine vollständigen Romane aus `skill_level_definitions` für hunderte Fähigkeiten auf einmal.
|
||||
|
||||
**Festlegung (Planungs-KI):** Der LLM-Prompt erhält immer nur ein **begrenztes Kontext-Paket** mit:
|
||||
|
||||
| Paketteil | Zweck |
|
||||
|-----------|--------|
|
||||
|
|
@ -69,7 +88,8 @@ Mindestens **eine** der folgenden Optionen – kombinierbar:
|
|||
1. **Skill-/Facet-Overlap:** Punktezahl, wenn Übungs-Skills mit Ziel-/Matrix-Schwerpunkten übereinstimmen (bereits Daten in `exercise_skills`).
|
||||
2. **Diversitäts-/Wiederholungsstrafe:** häufig in letzten Wochen geübte Übungen abwerten.
|
||||
3. **Textsuche:** PostgreSQL **`tsvector`/Volltext** auf `title`, `summary`, ggf. gekürzte `goal` – für Trainer-Stichwort „Koordination Sprung“.
|
||||
4. **Semantische Suche:** Embeddings + **Ähnlichkeitsuche** auf Kurztexte (siehe §5).
|
||||
4. **Semantische Suche:** Embeddings + **Ähnlichkeitsuche** auf Kurztexte (siehe §5).
|
||||
5. **Skill-Discovery über Planungs-Artefakte (bereits implementiert):** `GET /api/skill-discovery/suggestions` matching **Bibliotheksartefakte** (u. a. Rahmenprogramm, Trainingsmodul, Progressionsgraph) gegen gegebene `skill_ids`; `GET …/skill-profile` liefert **gewichtete Fähigkeitsprofile** aus den dort verknüpften Übungen (siehe `SKILL_SCORING_SPEC.md`). Das ist ein **deterministischer** Baustein für „welche Artefakte passen zu diesen Skills?“, **nicht** der Ersatz für **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.
|
||||
|
||||
|
|
@ -128,7 +148,8 @@ Sinnvoller zeitlicher Punkt oder technische Auslöser:
|
|||
|
||||
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;
|
||||
- **Progressionsgraph** dort, wo pädagogische Ketten gefestigt sind;
|
||||
- konsistente **Fokusbereich/Stil**-Zuordnung.
|
||||
|
|
@ -139,15 +160,18 @@ Das fachliche Konzept sollte entscheiden: **wie viel automatische Pflege vs. Tra
|
|||
|
||||
## 7. Produkt-/Release-Stufen (Anknüpfung)
|
||||
|
||||
Priorität **jetzt**: **Übungsanlage**, danach **Planung**.
|
||||
|
||||
| Stufe | Nutzen | Technik-Schwerpunkt |
|
||||
|-------|--------|---------------------|
|
||||
| A | Backend-KI-Service + Prompt-Slugs unter `technical/AI_PROMPT_SYSTEM_SPEC.md` | OpenRouter, Timeouts, 503 ohne Key |
|
||||
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 1–3 + Prompt mit Top‑K |
|
||||
| **A0** | **Zentraler KI-Service** (ein Modul/Hilfslayer), Prompts aus `ai_prompts` | OpenRouter oder vereinbarter Provider, Timeouts, `503` ohne Key, Parsing/Validation |
|
||||
| **A1** | **Übungsanlage** (vgl. `KI_FEATURES_SPEC`): `summary`, Skill-Vorschläge inkl. Stufen/Intensität, optional Textglättung | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate`; Prompt-Kontext: Skills mit `description`, `karate_relevance`, `relevance_level`, optional `skill_level_definitions` für Kurzliste; DB: `summary_ai_generated`, `exercise_skills.ai_suggested` |
|
||||
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 1–3 + Prompt mit Top‑K (Übungsliste **keyset-pagination** beachten) |
|
||||
| C | Reihenfolge / Zeitslots innerhalb bestehender Sektion | Graph + LLM Ranking |
|
||||
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertere JSON-Ausgabe, strikte Schema-Validation |
|
||||
| E | Mehreinheiten / Rahmen‑Alignment | Ziele aus Rahmenprogramm, Serie von Slots |
|
||||
| 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; **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
|
||||
|
||||
**Version:** 1.5
|
||||
**Datum:** 2026-05-08
|
||||
**Version:** 1.6
|
||||
**Datum:** 2026-05-20
|
||||
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
|
||||
**Autor:** Claude Code
|
||||
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
|
||||
**Änderungen v1.6:** Freigabelevel-UI-Hinweis; `exercise_skills` ohne `is_primary` in Requests (Legacy-Feld wird ignoriert/forciert false); Permissions-Bereich an Ist-Code angeglichen; Intensität kanonisch `niedrig|mittel|hoch`
|
||||
**Änderungen v1.5:** Medien-/Inline-Workflow aktualisiert (Modal-Picker, Drag&Drop UX im Frontend), Klarstellung zu `context` (legacy/optional), Hinweise zu Platzhaltern in Rich-Text-Feldern.
|
||||
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
|
||||
**Änderungen v1.3:** `GET /exercises` erweiterte Query-Parameter (`include_variants`, Multi-Filter, `ai_search`-Platzhalter); Dokumentation angepasst
|
||||
**Änderungen v1.2:** KI-Assistenz Endpoints, Skill-Level-System (benannte Stufen), intensity als low/medium/high
|
||||
**Änderungen v1.1:** Exercise Blocks Endpoints, Permissions dokumentiert, age_groups korrigiert
|
||||
|
|
@ -185,11 +186,11 @@ Lightweight-Liste; bei `include_variants=true` zusätzlich z. B.:
|
|||
"skill_id": 10,
|
||||
"skill_name": "Distanzgefühl",
|
||||
"skill_category": "Kumite",
|
||||
"is_primary": true,
|
||||
"intensity": "hoch",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"ai_suggested": false
|
||||
"ai_suggested": false,
|
||||
"is_primary": false
|
||||
}
|
||||
],
|
||||
|
||||
|
|
@ -307,7 +308,6 @@ Lightweight-Liste; bei `include_variants=true` zusätzlich z. B.:
|
|||
"skills": [
|
||||
{
|
||||
"skill_id": 10,
|
||||
"is_primary": true,
|
||||
"intensity": "hoch",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau"
|
||||
|
|
@ -578,7 +578,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
|||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"intensity": "hoch",
|
||||
"is_primary": true,
|
||||
"confidence": 0.92
|
||||
},
|
||||
{
|
||||
|
|
@ -588,7 +587,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
|||
"required_level": "einsteiger",
|
||||
"target_level": "grundlagen",
|
||||
"intensity": "mittel",
|
||||
"is_primary": false,
|
||||
"confidence": 0.74
|
||||
}
|
||||
]
|
||||
|
|
@ -621,6 +619,38 @@ Trainer muss im Frontend aktiv übernehmen.
|
|||
|
||||
## Permissions
|
||||
|
||||
**UI-Hinweis:** Das Feld `visibility` heißt in der Oberfläche **Freigabelevel** (`exerciseGovernanceLabels.js`).
|
||||
|
||||
### Lesen (`GET /exercises`, `GET /exercises/{id}`)
|
||||
|
||||
| `visibility` | Wer darf lesen? |
|
||||
|--------------|-----------------|
|
||||
| `official` | Plattform-weit |
|
||||
| `private` | Ersteller (`created_by`); Plattform-Admin |
|
||||
| `club` | Aktive Mitglieder des Objekt-`club_id`; Plattform-Admin ohne Mitgliedschaft (Audit-Zugang) |
|
||||
|
||||
Implementierung: `library_content_visible_to_profile` / `exercise_visible_to_profile` in `club_tenancy.py`.
|
||||
|
||||
### Bearbeiten (`PUT`, Varianten-CRUD, Medien an Übung)
|
||||
|
||||
| Bedingung | Wer darf bearbeiten? |
|
||||
|-----------|----------------------|
|
||||
| Ersteller | Immer (eigene Übung) |
|
||||
| Plattform-Admin | Immer |
|
||||
| `visibility=club` | Zusätzlich **`can_plan_in_club`** im Objekt-Verein: `club_admin`, `trainer`, `content_editor`, `division_lead` |
|
||||
|
||||
Implementierung: `_assert_can_edit_exercise` in `exercises.py`. **Varianten** haben kein eigenes Owner-Feld — gleiche Prüfung wie Eltern-Übung.
|
||||
|
||||
### Löschen (`DELETE /exercises/{id}`)
|
||||
|
||||
| `visibility` | Wer darf löschen? |
|
||||
|--------------|-------------------|
|
||||
| `official` | Nur Plattform-Admin |
|
||||
| `club` | Nur **`club_admin`** im Objekt-Verein |
|
||||
| `private` | Ersteller; oder Vereins-Admin, der mit dem Ersteller einen gemeinsamen Verein teilt |
|
||||
|
||||
Implementierung: `_assert_can_delete_exercise` in `exercises.py`.
|
||||
|
||||
### Sichtbarkeits-Workflow
|
||||
|
||||
| Von → Nach | Wer darf das? |
|
||||
|
|
@ -638,11 +668,12 @@ Trainer muss im Frontend aktiv übernehmen.
|
|||
| `club → official` | Club-Admin, Super-Admin |
|
||||
| `official → club` | Super-Admin |
|
||||
|
||||
### Owner-Checks
|
||||
### Owner-Checks (veraltet — siehe Tabellen oben)
|
||||
|
||||
- **Bearbeiten** (PUT): Nur Ersteller oder Club-Admin
|
||||
- **Löschen** (DELETE): Nur Ersteller oder Super-Admin
|
||||
- **Lesen** (`private`): Nur Ersteller
|
||||
Die folgenden Kurzregeln sind durch die Ist-Implementierung ersetzt; nur zur historischen Einordnung:
|
||||
|
||||
- ~~Bearbeiten (PUT): Nur Ersteller oder Club-Admin~~ → siehe **Bearbeiten**-Tabelle (`can_plan_in_club`)
|
||||
- ~~Löschen (DELETE): Nur Ersteller oder Super-Admin~~ → siehe **Löschen**-Tabelle
|
||||
|
||||
**403 Fehler-Beispiel:**
|
||||
```json
|
||||
|
|
@ -904,7 +935,8 @@ Trainer muss im Frontend aktiv übernehmen.
|
|||
### Exercise Skills
|
||||
- `required_level`: enum – `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
|
||||
- `target_level`: enum – gleiche Werte (optional/nullable)
|
||||
- `intensity`: enum – `niedrig | mittel | hoch` (optional/nullable)
|
||||
- `intensity`: enum – **`niedrig | mittel | hoch`** (optional/nullable; Default beim Speichern **`mittel`**)
|
||||
- `is_primary`: **Legacy** — Spalte existiert in DB, wird bei POST/PUT **nicht ausgewertet** (immer `false` gespeichert); UI liefert/speichert kein Primär-Flag mehr; Scoring ignoriert das Feld
|
||||
- `target_level` sollte >= `required_level` sein (Warnung, kein Fehler)
|
||||
|
||||
### Exercise Block Item
|
||||
|
|
|
|||
|
|
@ -99,20 +99,21 @@ Exercise Block ──── (N) Block Items ──── (1) Exercise
|
|||
|
||||
### 1.3 M:N Beziehungen (Primary/Secondary Pattern)
|
||||
|
||||
**Regel:** Alle Katalog-Zuordnungen nutzen M:N mit `is_primary` Flag.
|
||||
**Regel:** Katalog-Zuordnungen (Fokus, Stil, Zielgruppe, …) nutzen M:N mit optionalem `is_primary`-Flag.
|
||||
|
||||
**Betroffene Relationen:**
|
||||
**Betroffene Relationen (mit `is_primary`):**
|
||||
- `exercise_focus_areas` (Übung ↔ Fokusbereiche)
|
||||
- `exercise_styles` (Übung ↔ Trainingsstile)
|
||||
- `exercise_styles` / `exercise_style_directions` (Übung ↔ Stilrichtungen)
|
||||
- `exercise_training_types` (Übung ↔ Trainingsstile)
|
||||
- `exercise_target_groups` (Übung ↔ Zielgruppen)
|
||||
- `exercise_training_characters` (Übung ↔ Trainingscharaktere)
|
||||
- `exercise_skills` (Übung ↔ Fähigkeiten)
|
||||
|
||||
**Primary/Secondary Semantik:**
|
||||
**Ausnahme — `exercise_skills`:** Kein Primär-Flag in UI/API mehr; stattdessen **`intensity`** (`niedrig` \| `mittel` \| `hoch`, Default `mittel`). Spalte `is_primary` bleibt Legacy (Backend speichert immer `false`).
|
||||
|
||||
**Primary/Secondary Semantik (Katalog-Dimensionen):**
|
||||
- **Primary:** Hauptzuordnung, entscheidend für Filter/Suche
|
||||
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
|
||||
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension
|
||||
- **UI:** Primary wird visuell hervorgehoben (z.B. fett, farbig)
|
||||
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension (wo UI das noch anbietet)
|
||||
- **UI:** Primary wird visuell hervorgehoben (z. B. fett, farbig) — Fähigkeiten: Intensitäts-Segmente statt Primary
|
||||
|
||||
**Legacy-Felder (DEPRECATED):**
|
||||
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
# Frontend Routing & Navigation Specification
|
||||
|
||||
**Version:** 1.2
|
||||
**Datum:** 2026-04-30
|
||||
**Version:** 1.3
|
||||
**Datum:** 2026-05-20
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
**Autor:** Claude Code
|
||||
**Änderungen v1.3:** Übungsformular Tab-Navigation unter `/exercises/:id/edit` (Stammdaten … Medien & Mehr); Freigabelevel als UI-Begriff
|
||||
**Änderungen v1.2:** Übersicht **Übungen**: Tabs Liste \| Progressionsgraphen auf `/exercises`; Progressions-Editor ohne neue Routen (Panel + Formularblock unter `/exercises/:id/edit`)
|
||||
**Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
|
||||
|
||||
|
|
@ -17,7 +18,7 @@
|
|||
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
||||
/exercises/new → ExerciseFormPage (Create)
|
||||
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
|
||||
/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline + Block Progressionsgraph)
|
||||
/exercises/{id}/edit → ExerciseFormPage (Edit: Registerkarten + Varianten inline + Progressionsgraph)
|
||||
|
||||
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
|
||||
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
||||
|
|
@ -35,6 +36,25 @@
|
|||
- Pagination: `/exercises?limit=50&offset=100`
|
||||
- Sortierung: `/exercises?sort=created_at&order=desc`
|
||||
|
||||
### 1.2 Übungsformular – Registerkarten (`/exercises/new`, `/exercises/:id/edit`)
|
||||
|
||||
**Implementierung:** `ExerciseFormPageRoot.jsx` + `ExerciseFormLayout.jsx` (`ExerciseFormTabBar`, `ExerciseFormPanel`).
|
||||
|
||||
| Tab-ID | Label | Verfügbarkeit |
|
||||
|--------|-------|---------------|
|
||||
| `stammdaten` | Stammdaten | immer |
|
||||
| `anleitung` | Anleitung | immer |
|
||||
| `einordnung` | Einordnung | immer |
|
||||
| `kombination` | Kombination | nur `exercise_kind=combination` |
|
||||
| `varianten` | Varianten | Edit-Modus; nicht bei Kombination; disabled bei Neuanlage |
|
||||
| `medien` | Medien & Mehr | Edit-Modus; disabled bei Neuanlage |
|
||||
|
||||
**UX-Regeln:**
|
||||
- Nur ein Panel sichtbar (`activeFormTab`); Navigation über `PageSectionNav`.
|
||||
- **Freigabelevel** (Feld `visibility`) in Stammdaten — Konstante `EXERCISE_VISIBILITY_FIELD_LABEL`.
|
||||
- Varianten-Änderungen werden mit **Speichern** in der Aktionsleiste persistiert (`persistPendingVariantChanges`); Button „Variante anlegen“ optional sofort.
|
||||
- Kein URL-Hash pro Tab (Tab-State nur lokal).
|
||||
|
||||
---
|
||||
|
||||
## 2. Navigation-Patterns
|
||||
|
|
@ -673,7 +693,7 @@ function App() {
|
|||
|
||||
---
|
||||
|
||||
**Version:** 1.2
|
||||
**Letzte Änderung:** 2026-04-30
|
||||
**Version:** 1.3
|
||||
**Letzte Änderung:** 2026-05-20
|
||||
**Status:** REVIEWED - Pending Implementation
|
||||
**Review-Änderungen:** Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)
|
||||
**Review-Änderungen:** Formular-Registerkarten; Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@
|
|||
**Änderungen v1.1:** Prompts sind nicht hardcoded – sie werden aus der DB geladen (AI_PROMPT_SYSTEM_SPEC.md)
|
||||
**Verwandte Specs:** AI_PROMPT_SYSTEM_SPEC.md (Prompt-DB + Platzhalter), SKILLS_MATRIX_SPEC.md (Fähigkeitsmatrix)
|
||||
|
||||
**Übergeordnete Produkt-Vision** (breiter Scope: Zielausbau, bereichsweise vs. Gesamtüberarbeitung, Varianten, Planungs-/Nachbereitungskontext, Admin-Masse):
|
||||
`functional/AI_EXERCISE_ASSISTANT_VISION.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht
|
||||
|
||||
Zwei KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen:
|
||||
KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen (Mindestpaket dieser Spec):
|
||||
|
||||
**Hinweis:** Die beiden folgenden Zeilen entsprechen **P0** der Phasierung in **`AI_EXERCISE_ASSISTANT_VISION.md`**; spätere Funkteile sind dort beschrieben.
|
||||
|
||||
| Funktion | Ziel |
|
||||
|---------|------|
|
||||
|
|
@ -155,7 +160,38 @@ KI gibt Vorschläge
|
|||
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
|
||||
Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||
|
||||
**Request Body:**
|
||||
**Required Fields:** mindestens `goal` ODER `execution`
|
||||
|
||||
**Optional – Skill-Katalogpriorisierung (Stand 068):**
|
||||
|
||||
```json
|
||||
{
|
||||
"focus_areas_context": [
|
||||
{ "focus_area_id": 3, "is_primary": true },
|
||||
{ "focus_area_id": 1, "is_primary": false }
|
||||
],
|
||||
"focus_area_hint": "Karate, Kumite…"
|
||||
}
|
||||
```
|
||||
|
||||
- `focus_areas_context`: IDs aus Stammdatum **Fokusbereiche**; Primär soll zuerst stehen (`is_primary`). Ohne Feld oder leere Liste gilt das DB-Profil **`is_default`** (`ai_skill_retrieval_profiles`).
|
||||
- `focus_area_hint`: bleibt lesbarer Text für den Prompt (bestehende Prompts).
|
||||
|
||||
|
||||
**Minimal-Beispiel (Mit Fokus für Retrieval):**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Maai - Distanzübung",
|
||||
"goal": "…",
|
||||
"execution": "…",
|
||||
"focus_areas_context": [ { "focus_area_id": 1, "is_primary": true } ]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
**Minimal-Beispiel ( ohne Fokus — nur Texts):**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Maai - Distanzübung",
|
||||
|
|
@ -164,8 +200,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
|||
}
|
||||
```
|
||||
|
||||
**Required Fields:** mindestens `goal` ODER `execution` (je länger, desto besser)
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
|
|
@ -182,7 +216,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
|||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"intensity": "hoch",
|
||||
"is_primary": true,
|
||||
"confidence": 0.92
|
||||
},
|
||||
{
|
||||
|
|
@ -192,7 +225,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
|||
"required_level": "einsteiger",
|
||||
"target_level": "grundlagen",
|
||||
"intensity": "mittel",
|
||||
"is_primary": false,
|
||||
"confidence": 0.74
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
|
||||
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
|
||||
| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; OpenRouter — suggest optional `focus_areas_context` für Retrieval-Profile |
|
||||
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
|
||||
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
|
||||
| dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) |
|
||||
|
|
@ -33,17 +34,21 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
|
||||
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
|
||||
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
||||
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
||||
|
||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||
|
||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
||||
|
||||
Letzte Änderung: 2026-05-13 — `GET /api/dashboard/kpis` (Kurzüberblick-Aggregat).
|
||||
Letzte Änderung: 2026-05-29 — Superadmin-CRUD `/api/admin/ai-skill-retrieval-profiles*` dokumentiert; `POST /api/exercises/ai/suggest` mit optionalem `focus_areas_context` (Migration 068).
|
||||
|
||||
---
|
||||
|
||||
### Changelog (Fortführung)
|
||||
|
||||
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
||||
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||
|
||||
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
|
||||
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||
|
|
|
|||
67
.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
Normal file
67
.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Umsetzungsplan – KI bei Übungen (stufenweise, Driftschutz)
|
||||
|
||||
**Version:** 0.2
|
||||
**Datum:** 2026-05-29
|
||||
**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · **`working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`** · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand)
|
||||
|
||||
---
|
||||
|
||||
## 1. Drift vermeiden – verbindliche Regeln
|
||||
|
||||
1. **Spec vor Code:** Request/Response-Felder und Statuscodes an `KI_FEATURES_SPEC.md` ausrichten; Abweichungen zuerst Spec oder dieses Dokument anpassen.
|
||||
2. **Prompts in der DB:** Keine produktionskritischen Prompt-Langtexte nur im Code; Defaults per **Migration** in `ai_prompts`, Anpassung durch Admins über vorgesehene Oberfläche (später) oder SQL.
|
||||
3. **Skill-Retrieval-Profile:** Gewichte/Quotes in **`ai_skill_retrieval_profiles.config`** — Spezifikation `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; kein zweites gleichzeitiges Truth-Repo im Sourcecode außer defensiver Fallback `_FALLBACK_RETRIEVAL_CONFIG` in `exercise_ai.py`.
|
||||
4. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen.
|
||||
5. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec).
|
||||
6. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`.
|
||||
7. **Schema:** Neue DB-Objekte nur nummerierte Migration **`backend/migrations/`** (aktuell bis **068**) und `DB_SCHEMA_VERSION` anheben.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stufen (Releases)
|
||||
|
||||
| Stufe | Inhalt | Exit-Kriterium |
|
||||
|-------|--------|------------------|
|
||||
| **S0** | Dieses Dokument + Verweise konsistent | Review abgehakt |
|
||||
| **S1** | Migration `ai_prompts` + Defaults `exercise_summary`, `exercise_skill_suggestions`; `exercises.summary_ai_generated` | Migrierte DB, App startet |
|
||||
| **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key |
|
||||
| **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key |
|
||||
| **S4** | Frontend: KI-Vorschlag, **Änderungsdialog** (Vorschau, Kurzfassung wählbar, Fähigkeiten pro Zeile an-/abwählbar), dann Übernahme ins Formular | Manuelle UX-Prüfung |
|
||||
| **S4b** | **Skill-Retrieval:** Migration **`ai_skill_retrieval_profiles`**, `focus_areas_context` am **`POST …/ai/suggest`**, `exercise_ai` kontextbezogener Katalog (Gewichte, Caps, Keyword-Patches) | Migration 068 angelegt; Smoke mit Gewaltschutz / ohne Fokus |
|
||||
| **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config |
|
||||
| **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics |
|
||||
|
||||
**Aktueller Implementierungsstand:** **S4 + S4b** im Code (`exercise_ai` + Formular übermittelt `focus_areas_context`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementierungs-Checkliste (Technik)
|
||||
|
||||
- [ ] `OPENROUTER_API_KEY` / `OPENROUTER_MODEL` in `.env.example` dokumentiert (bereits teils vorhanden – prüfen).
|
||||
- [ ] Fehlerbilder: `400` zu wenig Inhalt, `503` KI nicht konfiguriert, `502` Upstream-Fehler mit kurzer Message.
|
||||
- [ ] Logging: **keine** vollständigen Prompts mit personenbezogenen Daten in Prod-Logs (optional DEBUG).
|
||||
- [ ] Optional: Rate-Limit KI-Endpunkte (`slowapi`) – nach Bedarf.
|
||||
- [ ] `MODULE_VERSIONS["exercises"]` / Changelog bei API-Erweiterung setzen.
|
||||
|
||||
---
|
||||
|
||||
## 4. Changelog dieses Plans
|
||||
|
||||
- **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad.
|
||||
- **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen.
|
||||
- **2026-05-29:** **S4b:** Migration **068**, `ai_skill_retrieval_profiles`; suggest `focus_areas_context`; Frontend sendet gesetzte Fokusbereiche; Spec `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Umsetzungsstand (Zwischencheckpoint)
|
||||
|
||||
**Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert).
|
||||
|
||||
**Erledigt (2026-05-29):** Migration **`068`** / Profil **`ai_skill_retrieval_profiles`** (Standard + Profil Gewaltschutz wenn `focus_areas.name` vorhanden); **`exercise_ai`** — Score/Kategorie-Zapfen/Text-Overlap/Keyword-Zuschläge; **API:** `ExerciseAiSuggestBody.focus_areas_context`; **Regenerate** nutzt DB-Fokuszeilen.
|
||||
|
||||
**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen.
|
||||
|
||||
**Offen nächste Schritte Pflege/Umsetzung:** weitere Retrieval-Profile (z. B. Karate-/Fitness-Schwerpunkt) per SQL später Admin-UI; optionales Feld **`skills.ai_context`** Kurzbeschreibung für KI; automatische KI beim Speichern (**S5**); Prompt-/Profil-Admin-UI ohne SQL; Rate-Limits.
|
||||
|
||||
**Bewusst noch nicht (`summary_ai_generated`):** zurücksetzen bei manueller Kurzfassung im UI; Admin-Pflege `ai_skill_retrieval_profiles`.
|
||||
|
||||
112
.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md
Normal file
112
.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Mehrstufige KI für Trainingsplanung – Architektur-Vorschau (Anti-Refactoring)
|
||||
|
||||
**Version:** 0.1
|
||||
**Datum:** 2026-05-22
|
||||
**Status:** Planungs-/Architektur-Arbeitspapier (keine Implementierungspflicht)
|
||||
**Ziel:** Für die **spätere** Planungs-KI bereits **Schnittstellen und Schichten** vorzeichnen, damit die **kleinere, starre** Übungs-KI nicht zur impliziten Vorlage für einen viel größeren Kopf wird — **ohne** jetzt eine Mitai-artige Workflow-Engine zu bauen.
|
||||
|
||||
**Bezüge:** `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/SKILL_SCORING_SPEC.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-003) · Schwesterprojekt Mitai: `c:/dev/mitai-jinkendo` (Referenz: `prompt_executor`, `placeholder_resolver`, `workflow_*` — **nicht** Pflicht-Port).
|
||||
|
||||
---
|
||||
|
||||
## 1. Zwei getrennte Produktlinien (bewusst entkoppelt)
|
||||
|
||||
| Linie | Rolle | Orchestrator |
|
||||
|--------|--------|----------------|
|
||||
| **Übungs-KI** | wenige Eingaben → Kurzfassung / Skills; **starrer** Ablauf (1–2 Calls), kleines Kontextfenster | z. B. `exercise_ai.py` (heute) |
|
||||
| **Planungs-KI** | Gruppe, Zeit, Ziele, Historie, Katalogausschnitt, Phasen/Streams → **strukturierte Planelemente** | **eigenes** Modul + **mehrstufig** (siehe §3) |
|
||||
|
||||
**Regel:** Shared Library nur auf **niedriger Ebene** (`openrouter_chat`-Art: HTTP, Timeouts, Modellname, Fehler-Mapping) und **gemeinsame Prompt-Tabelle** `ai_prompts`. **Keine** Vermischung der Geschäftslogik „Übung erstellen“ mit „Einheit füllen“, um später keine Abhängigkeiten reißen zu müssen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Konzeptioneller „Planungs-Graph“ (Daten, nicht zwingend Graph-DB)
|
||||
|
||||
Für die Planungs-KI ist ein **Graph als Denkmodell** hilfreich — technisch reicht meist **PostgreSQL + bestehende FKs** (+ optional `exercise_progression_graphs`):
|
||||
|
||||
**Knoten-Typen (Auszug):** `training_groups`, `training_units`, `training_unit_sections` / Items, `exercises`, `skills`, `training_framework_programs` / Slots / Goals, ggf. Nachbearbeitungs-/Debrief-Metadaten.
|
||||
|
||||
**Kanten-Typen (Auszug):**
|
||||
|
||||
- **Zeitliche Folge:** Einheiten einer Gruppe nach `planned_date` / Reihenfolge
|
||||
- **Inhalt:** Section-Item → `exercise_id` (± Variante)
|
||||
- **Ziele:** Slot-/Framework-Ziele, Kopf-Notizen, Trainer-Zieltexte
|
||||
- **Progression:** Kanten aus `exercise_progression_graphs` (optional erweitern um „empfohlene Folge im Gruppenkontext“, bleibt Spekulationsfeld)
|
||||
- **Skills:** bereits über `exercise_skills`; aggregiert über `skill_scoring`-Pfad
|
||||
|
||||
**Wichtig:** Für KI **nicht** einen Riesen-Graphen serialisieren, sondern **Projektionen** („letzte *N* Einheiten“, „Nachbarn im Progressionsgraph zu zuletzt verwendeten Übungen“, „Skill-Gap Heuristik“).
|
||||
|
||||
---
|
||||
|
||||
## 3. Mehrstufiger Prozess (Pflichtidee für Planungs-KI)
|
||||
|
||||
Statt einem Prompt „mach den ganzen Plan“ mehrere **Schritte mit kleinen, validierbaren Outputs**:
|
||||
|
||||
| Stufe | Beispiel-Aufgabe | Deterministisch möglich? | Typischer LLM-Einsatz |
|
||||
|-------|-------------------|--------------------------|------------------------|
|
||||
| **S0** | Governance + Filter + Historie + Slot-Ziele zusammenstellen | Ja (SQL/API) | Nein |
|
||||
| **S1** | Kandidaten-Übungen auf Top‑K schrumpfen (Skills, Volltext, Score, Wiederholungsstrafe) | Teilweise | Optional Ranking |
|
||||
| **S2** | Reihenfolge je Section / Phase unter Constraints (Aufwärmen, Graphen-Nachbarn) | Teilweise | Ja (auf kleiner Liste) |
|
||||
| **S3** | Zeiten auf Section/Item vorschlagen oder Plausibilisieren | Teilweise | Ja |
|
||||
| **S4** | Trainer-sprachliche Kurzbegründung / Alternativen | Nein | Ja |
|
||||
|
||||
**Zwischen jeder Stufe:** starkes **Schema / Validierung** (z. B. nur erlaubte `exercise_id`s, nur erlaubte Slot-Struktur zu Phasen/Streams). So bleibt das System auch bei Modell-Fehlern stabil.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schnittstellen-Vorsorge im Code (ohne Big-Bang)
|
||||
|
||||
Minimal-Ausbaustufe später, die Refactoring vermeidet:
|
||||
|
||||
1. **`PlanningContextPack` (internes DTO)** — reines Python-`dict`/`dataclass` oder Pydantic: aggregierte, **tokenbewusst gekürzte** Ansicht (Gruppe, nächste Einheit-Ziele, Historie-IDs, Top‑K-Kandidaten, Constraints).
|
||||
2. **`planning_ai_steps` als rein **funktionale** Pipeline** — jede Stufe `(context) → context` oder `(context) → partial_suggestion`; keine globale „Prompt-String-Bastelei“ überall im Router.
|
||||
3. **Prompt-Slugs pro Stufe** in `ai_prompts` (analog Übung), z. B. `planning_rank_section_items`, `planning_explain_sequence`, mit **eigenem** Platzhalter-Katalog (nicht `{{skills_catalog}}` aus Übungen recyclen).
|
||||
4. **Router** `training_planning.py` (oder neuer `planning_ai.py`): nur **dünne** HTTP-Schicht, ruft Orchestrator.
|
||||
|
||||
Optional **später**, wenn nötig: zweite Tabelle `ai_prompt_chains` oder externe Workflow-Definition — **erst** wenn 3–4 feste Stufen nicht mehr reichen. Mitai-Workflow-Engine dann **bewusste** Option, kein Default.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kontextfenster und „Kaskade“
|
||||
|
||||
**Kerngedanke:** Je Stufe nur **neue** Information hinzufügen, die vorherige Stufen **ersetzen** oder **verdichten**, nicht duplizieren.
|
||||
|
||||
Beispiel:
|
||||
|
||||
- Stufe A (LLM oder Heuristik): „Priorisierte Skill-Ziele für diese Session“ (kurz)
|
||||
- Stufe B: Top‑40 Übungen mit **einer** Zeile pro Übung
|
||||
- Stufe C: Reihenfolge für 8 IDs + 2-Satz-Begründung
|
||||
|
||||
So bleibt dieselbe fachliche Tiefe erreichbar ohne Kontext-Explosion.
|
||||
|
||||
---
|
||||
|
||||
## 6. Schnittstellen zu bereits vorhandenen Bausteinen
|
||||
|
||||
- **`skill_profiles` / `skill-discovery`:** liefern **deterministische** Ziel-/Profil-Signale für S0/S1 (`SKILL_SCORING_SPEC.md`).
|
||||
- **`training_planning_prefs`:** weiche Constraints (Tone, Dauer, Split-Vorlieben).
|
||||
- **`exercise_progression_graphs`:** lokale Nachbarschaft um „zuletzt verwendet“.
|
||||
- **Mitai-Referenz:** Platzhalter-Katalog + Preview-API als **Inspiration** für Admin-UX; Workflow-Graph nur wenn Shinkan **wirklich** viele verzweigte Pipelines braucht.
|
||||
|
||||
---
|
||||
|
||||
## 7. Was wir **nicht** jetzt tun müssen
|
||||
|
||||
- Keine zweite Graph-Datenbank nur für KI.
|
||||
- Keine Workflow-UI-Kopie aus Mitai.
|
||||
- Keine Vereinheitlichung der Übungs-KI mit Planungs-KI über einen „Mega-Orchestrator“.
|
||||
|
||||
---
|
||||
|
||||
## 8. Kurz-Checkliste „Refactoring vermeiden“ vor erster Planungs-KI-Zeile Code
|
||||
|
||||
- [ ] Eigenes Modulbaum-„Root“ für Planung (nicht `exercise_ai` erweitern).
|
||||
- [ ] Prompt-Slugs mit **Planungs-**Präfix und **eigenem** Platzhalter-Set dokumentieren.
|
||||
- [ ] Outputs pro Stufe **JSON-Schema** oder Pydantic validieren.
|
||||
- [ ] Kandidatenlisten **immer** serverseitig auf erlaubte IDs begrenzen.
|
||||
|
||||
---
|
||||
|
||||
## 9. Changelog
|
||||
|
||||
- **2026-05-22:** Erstfassung als Vorschau-Dokument für mehrstufige Planungs-KI.
|
||||
121
.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
Normal file
121
.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# KI Skill-Retrieval-Profile (`ai_skill_retrieval_profiles`)
|
||||
|
||||
**Version:** 0.1
|
||||
**Datum:** 2026-05-29
|
||||
**Status:** Umsetzung gestartet (Migration **068**)
|
||||
**Ziel:** Für `POST /api/exercises/ai/suggest` (Skill-Katalogauszug) **Gewichte und Quoten** steuerbar machen:
|
||||
|
||||
- gebunden an **Übungs-Fokusbereich** (`focus_areas.id`),
|
||||
- ein **Standardprofil** ohne Fokus,
|
||||
- **optional zusammengeführte** Profile bei mehreren Fokusbereichen,
|
||||
- **optional Keyword-Übersteuerungen** aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung).
|
||||
|
||||
**Technische Basis:** Skills mit `skills.main_category_id` → `skill_main_categories.slug` (`karate` | `allgemeine`) und `skills.category_id` → `skill_categories.slug` (`kondition`, `selbstverteidigung`, …).
|
||||
|
||||
**Bezüge:** `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md` · `backend/exercise_ai.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. Datenmodell
|
||||
|
||||
### Tabelle `ai_skill_retrieval_profiles`
|
||||
|
||||
| Spalte | Typ | Beschreibung |
|
||||
|--------|-----|--------------|
|
||||
| `id` | serial | Primärschlüssel |
|
||||
| `focus_area_id` | int NULL FK → `focus_areas(id)` ON DELETE SET NULL | **`NULL`** nur für Standardeintrag möglich (siehe `is_default`) |
|
||||
| `is_default` | boolean | Genau **eine** Zeile mit `true` |
|
||||
| `name` | varchar | Kurzer Name (Admin später) |
|
||||
| `description` | text | Hinweise für Pflege |
|
||||
| `active` | boolean | Nur aktive werden geladen |
|
||||
| `config` | jsonb | Siehe §2 |
|
||||
|
||||
**Constraints / Indizes**
|
||||
|
||||
- Eindeutig: `(focus_area_id)` WHERE `focus_area_id IS NOT NULL`
|
||||
- Eindeutig: `(is_default)` WHERE `is_default = true`
|
||||
|
||||
---
|
||||
|
||||
## 2. JSON-Konfiguration `config.version = 1`
|
||||
|
||||
Alle Schlüssel **optional**; fehlende Werte fallen auf **einprogrammierten Fallback** in `exercise_ai.py` zurück (entspricht bisher grob „neutral“).
|
||||
|
||||
### 2.1 Gewichtungen (Ranking)
|
||||
|
||||
| Schlüssel | Typ | Bedeutung |
|
||||
|-----------|-----|------------|
|
||||
| `main_slug_weights` | `object[str, float]` | Multiplikator pro Hauptkategorie-Slug (`karate`, `allgemeine`) |
|
||||
| `category_slug_weights` | `object[str, float]` | Multiplikator pro `skill_categories.slug` |
|
||||
|
||||
Basis-Score (vereinfacht):
|
||||
`(importance oder 3) × main_w × cat_w × text_overlap_bonus × importance_multiplier`
|
||||
|
||||
### 2.2 Kapazitätsbegrenzung (Liste)
|
||||
|
||||
`_MAX_SKILLS_CATALOG_LINES` (aktuell **240**) Zeilen Gesamt:
|
||||
|
||||
| Schlüssel | Typ | Bedeutung |
|
||||
|-----------|-----|------------|
|
||||
| `category_max_share` | `object[str, float]` | Max. Anteil dieser **Unterkategorie** am Endergebnis (0–1), z. B. `{ "kondition": 0.25 }` |
|
||||
| `main_min_share` | `object[str, float]` | Mindest-Zielanteil Hauptkategorie beim **Auswahl-Greedy** (weich; Rest nach Score aufgefüllt) |
|
||||
|
||||
### 2.3 Text / Token-Sparen
|
||||
|
||||
| Schlüssel | Typ | Standard | Bedeutung |
|
||||
|-----------|-----|----------|------------|
|
||||
| `description_plain_max_len` | int | 160 | Gekürzte Beschreibung pro Zeile |
|
||||
| `karate_relevance_max_len` | int | **0** oder 80 | **`0`** = Feld `karate_relevance`/`relevance_level` in der Promptzeile **weglassen** |
|
||||
|
||||
### 2.4 Keyword-Overrides (optional)
|
||||
|
||||
Liste `keyword_overrides`: jedes Element:
|
||||
|
||||
```json
|
||||
{
|
||||
"keywords_any": ["befreiung", "haltegriff"],
|
||||
"case_insensitive": true,
|
||||
"patch": {
|
||||
"category_slug_weights": { "selbstverteidigung": 2.5 },
|
||||
"category_max_share": { "koordination": 0.1 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Textsuche in verkettetem Korpus **Titel, Ziel, Durchführung, Focus-Hint** (bereits plaintext). Reihenfolge: erst Basis-Profile zusammenmergen, dann **alle treffenden Overrides**‑`patch`‑Objekte **flach zusammenführen** (Gewichte multiplikativ übereinander, Caps den strengsten Wert nehmen – aktuelle Implementierung im Code dokumentiert).
|
||||
|
||||
---
|
||||
|
||||
## 3. Mehrere Fokusbereiche auf der Übung
|
||||
|
||||
Request-Body: `focus_areas_context: [{ "focus_area_id": n, "is_primary": bool }, …]`
|
||||
|
||||
**Aktuelle Merge-Strategie (v1):** Profile laden → **gleichgewichtete Mittelwert-Bildung** der numerischen Gewichte / Caps (implementiert für `main_slug_weights`, `category_slug_weights`, `category_max_share`, `main_min_share`, `*_max_len`). Anschließend **Keyword-Overrides** anwenden.
|
||||
|
||||
**Primär-Fokus:** Im Frontend soll die **primäre** Zeile aus `focus_areas_multi` **zuerst** in der Liste stehen; die Merge-Strategie kann später zu „Primär dominate“ erweitert werden.
|
||||
|
||||
Ohne Kontext oder ohne Treffer auf aktive Profile: **nur Standardprofil** (`is_default`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Seed-Daten (Migration)
|
||||
|
||||
- **`is_default=true`:** ausgewogene Standard-Gewichte, moderate Caps auf `kondition`/`koordination`, Karate-Relevanz gekürzt.
|
||||
- **`Gewaltschutz`:** `focus_area_id` per `(SELECT id FROM focus_areas WHERE name = 'Gewaltschutz' LIMIT 1)` — höhere Gewichte für `kognition`, `psychische_faehigkeiten`, `soziale_faehigkeiten`, `selbstverteidigung`; gedrosseltes `kondition`/`koordination`; `karate_relevance_max_len`: 0; Keyword-Patches wie oben können nachgeschärft werden.
|
||||
|
||||
Weitere Profile (Karate-Schwerpunkt etc.) später per Admin-SQL oder UI.
|
||||
|
||||
---
|
||||
|
||||
## 5. API
|
||||
|
||||
`ExerciseAiSuggestBody` erweitert um **`focus_areas_context`** (Liste). Feld **`focus_area_hint`** bleibt für den **Prompt-Kontext** (bestehende Prompts).
|
||||
|
||||
`POST …/ai/regenerate` nutzt gespeicherte `exercise_focus_areas` zur gleichen Retrieval-Logik wie Suggest.
|
||||
|
||||
**Pflege der Profile:** Superadmin ohne Mandantenwahl — **`GET|POST /api/admin/ai-skill-retrieval-profiles`**, **`GET|PUT|DELETE /api/admin/ai-skill-retrieval-profiles/{id}`** (`routers/ai_skill_retrieval_admin.py`); Web-UI Superadmin unter **`/admin/ai-skill-retrieval`**.
|
||||
|
||||
## 6. Changelog
|
||||
|
||||
- **2026-05-29:** Superadmin-Pflege-Endpoints + UI‑Route dokumentiert (`/admin/ai-skill-retrieval`).
|
||||
- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration.
|
||||
|
|
@ -35,6 +35,10 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
|||
OPENROUTER_API_KEY=your_api_key_here
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
||||
|
||||
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
|
||||
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).
|
||||
# SHINKAN_AI_DEBUG=1
|
||||
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@jinkendo.de
|
||||
|
|
|
|||
|
|
@ -180,12 +180,17 @@ def init_db():
|
|||
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
|
||||
if cur.fetchone()['count'] == 0:
|
||||
cur.execute("""
|
||||
INSERT INTO ai_prompts (slug, name, description, template, active, sort_order)
|
||||
INSERT INTO ai_prompts (
|
||||
slug, display_name, description, template,
|
||||
category, output_format, active, sort_order
|
||||
)
|
||||
VALUES (
|
||||
'pipeline',
|
||||
'Mehrstufige Gesamtanalyse',
|
||||
'Master-Schalter für die gesamte Pipeline. Deaktiviere diese Analyse, um die Pipeline komplett zu verstecken.',
|
||||
'Master-Schalter fuer die gesamte Pipeline. Deaktiviere diese Zeile um die Pipeline zu verstecken.',
|
||||
'PIPELINE_MASTER',
|
||||
'admin',
|
||||
'text',
|
||||
true,
|
||||
-10
|
||||
)
|
||||
|
|
|
|||
813
backend/exercise_ai.py
Normal file
813
backend/exercise_ai.py
Normal file
|
|
@ -0,0 +1,813 @@
|
|||
"""
|
||||
KI-Vorschlaege fuer Uebungsformular: Laedt Prompts aus ai_prompts, ruft OpenRouter auf.
|
||||
Keine persistente Aenderung an exercises — nur Response-DTO fuer das Frontend.
|
||||
|
||||
Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, Fallback-Heuristik).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
||||
|
||||
_LOGGER = logging.getLogger("shinkan.exercise_ai")
|
||||
|
||||
|
||||
def _ai_debug_on() -> bool:
|
||||
return os.getenv("SHINKAN_AI_DEBUG", "").strip().lower() in ("1", "true", "yes", "full")
|
||||
|
||||
|
||||
_CANONICAL_SKILL_LEVELS = frozenset({"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"})
|
||||
_LEGACY_SKILL_LEVEL_SLUG = {
|
||||
"einsteiger": "basis",
|
||||
"experte": "optimierung",
|
||||
"1": "basis",
|
||||
"2": "grundlagen",
|
||||
"3": "aufbau",
|
||||
"4": "fortgeschritten",
|
||||
"5": "optimierung",
|
||||
}
|
||||
_ALLOWED_SKILL_INTENSITY = frozenset({"niedrig", "mittel", "hoch"})
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE)
|
||||
_TOKEN_FIND = re.compile(r"[a-zäöüß0-9]+", re.IGNORECASE)
|
||||
|
||||
_MAX_PLAIN_FIELD = 28_000
|
||||
_MAX_SKILLS_CATALOG_LINES = 240
|
||||
_MAX_SUMMARY_CHARS = 220
|
||||
_MAX_SANITIZE_SKILL_INPUT_ROWS = 250
|
||||
|
||||
_FALLBACK_RETRIEVAL_CONFIG: Dict[str, Any] = {
|
||||
"version": 1,
|
||||
"importance_multiplier": 1.0,
|
||||
"text_overlap_bonus": 2.0,
|
||||
"main_slug_weights": {"karate": 1.0, "allgemeine": 1.0},
|
||||
"category_slug_weights": {},
|
||||
"category_max_share": {"kondition": 0.38, "koordination": 0.35},
|
||||
"main_min_share": {},
|
||||
"description_plain_max_len": 160,
|
||||
"karate_relevance_max_len": 72,
|
||||
"keyword_overrides": [],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_exercise_skill_level(value) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if s in _CANONICAL_SKILL_LEVELS:
|
||||
return s
|
||||
return _LEGACY_SKILL_LEVEL_SLUG.get(s)
|
||||
|
||||
|
||||
def _normalize_exercise_skill_intensity(value) -> str:
|
||||
if value is None:
|
||||
return "mittel"
|
||||
key = str(value).strip().lower()
|
||||
if key in ("low",):
|
||||
return "niedrig"
|
||||
if key in ("medium",):
|
||||
return "mittel"
|
||||
if key in ("high",):
|
||||
return "hoch"
|
||||
if key in _ALLOWED_SKILL_INTENSITY:
|
||||
return key
|
||||
return "mittel"
|
||||
|
||||
|
||||
def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) -> str:
|
||||
if not html:
|
||||
return ""
|
||||
t = _TAG_RE.sub(" ", str(html))
|
||||
t = re.sub(r"\s+", " ", t).strip()
|
||||
if len(t) > max_len:
|
||||
t = t[: max_len - 1].rstrip() + "…"
|
||||
return t
|
||||
|
||||
|
||||
def _corpus_tokens(*parts: str) -> frozenset:
|
||||
hay = " ".join(p.strip() for p in parts if p and p.strip())
|
||||
ws = {_m.group(0).lower() for _m in _TOKEN_FIND.finditer(hay)}
|
||||
return frozenset(w for w in ws if len(w) > 1)
|
||||
|
||||
|
||||
def _ai_profiles_table_ready(cur) -> bool:
|
||||
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_skill_retrieval_profiles",))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
val = row["t"] if isinstance(row, dict) else row[0]
|
||||
return val is not None and str(val).strip() != ""
|
||||
|
||||
|
||||
def _average_float_dict(dicts: Sequence[Mapping[str, Any]], *, fallback: float) -> Dict[str, float]:
|
||||
keys: set = set()
|
||||
for d in dicts:
|
||||
keys |= set(d.keys())
|
||||
out: Dict[str, float] = {}
|
||||
for k in keys:
|
||||
vals = []
|
||||
for d in dicts:
|
||||
if k not in d or d[k] is None:
|
||||
continue
|
||||
try:
|
||||
vals.append(float(d[k]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out[k] = (sum(vals) / len(vals)) if vals else fallback
|
||||
return out
|
||||
|
||||
|
||||
def _merge_retrieval_configs(configs: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
base = copy.deepcopy(_FALLBACK_RETRIEVAL_CONFIG)
|
||||
if not configs:
|
||||
return base
|
||||
|
||||
base["main_slug_weights"] = _average_float_dict(
|
||||
[c.get("main_slug_weights") or {} for c in configs],
|
||||
fallback=1.0,
|
||||
)
|
||||
for slug in ("karate", "allgemeine"):
|
||||
base["main_slug_weights"].setdefault(slug, 1.0)
|
||||
|
||||
base["category_slug_weights"] = _average_float_dict(
|
||||
[c.get("category_slug_weights") or {} for c in configs],
|
||||
fallback=1.0,
|
||||
)
|
||||
base["category_max_share"] = _average_float_dict(
|
||||
[c.get("category_max_share") or {} for c in configs],
|
||||
fallback=1.0,
|
||||
)
|
||||
base["main_min_share"] = _average_float_dict(
|
||||
[c.get("main_min_share") or {} for c in configs],
|
||||
fallback=0.0,
|
||||
)
|
||||
|
||||
ims = []
|
||||
tbs = []
|
||||
dmx = []
|
||||
krm = []
|
||||
for c in configs:
|
||||
try:
|
||||
if c.get("importance_multiplier") is not None:
|
||||
ims.append(float(c["importance_multiplier"]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
try:
|
||||
if c.get("text_overlap_bonus") is not None:
|
||||
tbs.append(float(c["text_overlap_bonus"]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
try:
|
||||
if c.get("description_plain_max_len") is not None:
|
||||
dmx.append(int(c["description_plain_max_len"]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
try:
|
||||
if c.get("karate_relevance_max_len") is not None:
|
||||
krm.append(int(c["karate_relevance_max_len"]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if ims:
|
||||
base["importance_multiplier"] = sum(ims) / len(ims)
|
||||
if tbs:
|
||||
base["text_overlap_bonus"] = sum(tbs) / len(tbs)
|
||||
if dmx:
|
||||
base["description_plain_max_len"] = int(round(sum(dmx) / len(dmx)))
|
||||
if krm:
|
||||
base["karate_relevance_max_len"] = int(round(sum(krm) / len(krm)))
|
||||
|
||||
overrides: List[Any] = []
|
||||
for c in configs:
|
||||
overrides.extend(c.get("keyword_overrides") or [])
|
||||
base["keyword_overrides"] = overrides
|
||||
return base
|
||||
|
||||
|
||||
def _mul_weight_dict(target: MutableMapping[str, float], patch: Mapping[str, Any]) -> None:
|
||||
for k, v in patch.items():
|
||||
try:
|
||||
mul = float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
target[k] = float(target.get(k, 1.0)) * mul
|
||||
|
||||
|
||||
def _apply_keyword_overrides(cfg: Dict[str, Any], corpus_lower: str) -> None:
|
||||
caps = cfg.setdefault("category_max_share", {})
|
||||
for ov in cfg.get("keyword_overrides") or []:
|
||||
keys_any = ov.get("keywords_any") or []
|
||||
if not keys_any or not corpus_lower.strip():
|
||||
continue
|
||||
hay = corpus_lower.lower() if corpus_lower else ""
|
||||
hit = False
|
||||
for kw in keys_any:
|
||||
ks = str(kw or "").strip()
|
||||
if not ks:
|
||||
continue
|
||||
ks_l = ks.lower()
|
||||
hit = ks_l in hay
|
||||
if hit:
|
||||
break
|
||||
if not hit:
|
||||
continue
|
||||
patch = ov.get("patch") or {}
|
||||
_mul_weight_dict(cfg.setdefault("category_slug_weights", {}), patch.get("category_slug_weights") or {})
|
||||
_mul_weight_dict(cfg.setdefault("main_slug_weights", {}), patch.get("main_slug_weights") or {})
|
||||
for slug, mx in (patch.get("category_max_share") or {}).items():
|
||||
try:
|
||||
mx_f = float(mx)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
cur = float(caps.get(slug, 1.0))
|
||||
caps[slug] = min(cur, mx_f)
|
||||
|
||||
|
||||
def _ordered_focus_ids(focus_ctx: Optional[Sequence[Tuple[int, bool]]]) -> List[int]:
|
||||
"""Primär zuerst, dann stabil nach ID."""
|
||||
if not focus_ctx:
|
||||
return []
|
||||
seen = set()
|
||||
ordered: List[Tuple[int, bool]] = []
|
||||
for fid, isp in sorted(focus_ctx, key=lambda x: (not x[1], x[0])):
|
||||
try:
|
||||
i = int(fid)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if i < 1 or i in seen:
|
||||
continue
|
||||
seen.add(i)
|
||||
ordered.append((i, bool(isp)))
|
||||
return [fid for fid, _ in ordered]
|
||||
|
||||
|
||||
def _load_merged_retrieval_config(
|
||||
cur, focus_ctx: Optional[Sequence[Tuple[int, bool]]]
|
||||
) -> Dict[str, Any]:
|
||||
if not _ai_profiles_table_ready(cur):
|
||||
return copy.deepcopy(_FALLBACK_RETRIEVAL_CONFIG)
|
||||
|
||||
loaded: List[Dict[str, Any]] = []
|
||||
for fid in _ordered_focus_ids(focus_ctx):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT config
|
||||
FROM ai_skill_retrieval_profiles
|
||||
WHERE active = true AND focus_area_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(fid,),
|
||||
)
|
||||
rw = cur.fetchone()
|
||||
if not rw:
|
||||
continue
|
||||
raw = rw["config"] if isinstance(rw, dict) else rw[0]
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(raw, dict):
|
||||
loaded.append(raw)
|
||||
|
||||
if not loaded:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT config
|
||||
FROM ai_skill_retrieval_profiles
|
||||
WHERE active = true AND is_default = true
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
rw = cur.fetchone()
|
||||
if rw:
|
||||
raw = rw["config"] if isinstance(rw, dict) else rw[0]
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raw = None
|
||||
if isinstance(raw, dict):
|
||||
loaded.append(raw)
|
||||
|
||||
return _merge_retrieval_configs(loaded)
|
||||
|
||||
|
||||
def _fetch_all_active_skills_for_catalog(cur) -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id,
|
||||
s.name,
|
||||
s.category,
|
||||
s.description,
|
||||
s.karate_relevance,
|
||||
s.relevance_level,
|
||||
s.importance,
|
||||
COALESCE(m.slug, '') AS main_slug,
|
||||
COALESCE(c.slug, '') AS category_slug,
|
||||
c.name AS subcategory_name
|
||||
FROM skills s
|
||||
LEFT JOIN skill_main_categories m ON m.id = s.main_category_id
|
||||
LEFT JOIN skill_categories c ON c.id = s.category_id
|
||||
WHERE (s.status IS NULL OR s.status = 'active')
|
||||
"""
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _score_skill_row(
|
||||
row: Mapping[str, Any],
|
||||
cfg: Mapping[str, Any],
|
||||
corpus_tokens: frozenset,
|
||||
) -> float:
|
||||
main_slug = str(row.get("main_slug") or "").strip().lower()
|
||||
cat_slug = str(row.get("category_slug") or "").strip().lower()
|
||||
main_w = float((cfg.get("main_slug_weights") or {}).get(main_slug, 1.0))
|
||||
cat_w = float((cfg.get("category_slug_weights") or {}).get(cat_slug, 1.0))
|
||||
try:
|
||||
imp = int(row["importance"]) if row.get("importance") is not None else 3
|
||||
except (TypeError, ValueError):
|
||||
imp = 3
|
||||
imp = max(1, min(5, imp))
|
||||
imp_mult = float(cfg.get("importance_multiplier") or 1.0)
|
||||
base = float(imp) * imp_mult * max(main_w, 0.05) * max(cat_w, 0.05)
|
||||
|
||||
name = strip_html_to_plain(row.get("name"), max_len=400)
|
||||
dsc = strip_html_to_plain(row.get("description"), max_len=520)
|
||||
search_blob = " ".join(
|
||||
[
|
||||
name,
|
||||
dsc,
|
||||
cat_slug.replace("_", " "),
|
||||
str(row.get("category") or ""),
|
||||
str(row.get("subcategory_name") or ""),
|
||||
]
|
||||
).lower()
|
||||
|
||||
overlaps = sum(1 for t in corpus_tokens if t and t in search_blob)
|
||||
tob = float(cfg.get("text_overlap_bonus") or 0.0)
|
||||
|
||||
return base + overlaps * tob
|
||||
|
||||
|
||||
def _category_cap_limits(cfg: Mapping[str, Any], n_max: int) -> Dict[str, int]:
|
||||
out: Dict[str, int] = {}
|
||||
mx = cfg.get("category_max_share") or {}
|
||||
if not isinstance(mx, dict):
|
||||
return out
|
||||
for slug, raw in mx.items():
|
||||
ks = str(slug or "").strip()
|
||||
if not ks:
|
||||
continue
|
||||
try:
|
||||
sh = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 0 < sh < 1.0:
|
||||
out[ks] = max(1, int(math.floor(sh * n_max)))
|
||||
elif sh >= 1.0:
|
||||
out[ks] = n_max + 99999
|
||||
else:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _pick_catalog_rows(rows_scored: List[Tuple[float, Dict[str, Any]]], cfg: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""rows_scored: (score, row_dict) ohne Sortierung-Anforderung."""
|
||||
cap_limits = _category_cap_limits(cfg, _MAX_SKILLS_CATALOG_LINES)
|
||||
ordered = sorted(rows_scored, key=lambda x: (-x[0], str(x[1].get("name") or "")))
|
||||
picked: List[Dict[str, Any]] = []
|
||||
picked_ids: set = set()
|
||||
cat_counts: Dict[str, int] = {}
|
||||
|
||||
def under_cap(cat_slug: str) -> bool:
|
||||
if not cat_slug or cat_slug not in cap_limits:
|
||||
return True
|
||||
return cat_counts.get(cat_slug, 0) < cap_limits[cat_slug]
|
||||
|
||||
# Pass 1: Cap respektieren
|
||||
for _sc, rw in ordered:
|
||||
if len(picked) >= _MAX_SKILLS_CATALOG_LINES:
|
||||
break
|
||||
sid = rw["id"]
|
||||
if sid in picked_ids:
|
||||
continue
|
||||
cslug = str(rw.get("category_slug") or "").strip().lower()
|
||||
if cslug and not under_cap(cslug):
|
||||
continue
|
||||
picked.append(rw)
|
||||
picked_ids.add(sid)
|
||||
if cslug:
|
||||
cat_counts[cslug] = cat_counts.get(cslug, 0) + 1
|
||||
|
||||
# Pass 2: auffüllen
|
||||
if len(picked) < _MAX_SKILLS_CATALOG_LINES:
|
||||
for _sc, rw in ordered:
|
||||
if len(picked) >= _MAX_SKILLS_CATALOG_LINES:
|
||||
break
|
||||
sid = rw["id"]
|
||||
if sid in picked_ids:
|
||||
continue
|
||||
picked.append(rw)
|
||||
picked_ids.add(sid)
|
||||
|
||||
return picked[:_MAX_SKILLS_CATALOG_LINES]
|
||||
|
||||
|
||||
def _format_skill_catalog_line(row: Mapping[str, Any], cfg: Mapping[str, Any]) -> str:
|
||||
rid = int(row["id"])
|
||||
nm = (row.get("name") or "").strip() or f"Skill #{rid}"
|
||||
cat_legacy = str(row.get("category") or "").strip()
|
||||
sub = str(row.get("subcategory_name") or "").strip()
|
||||
main_slug = str(row.get("main_slug") or "").strip()
|
||||
cats = " / ".join(x for x in (main_slug.upper() if main_slug else "", cat_legacy, sub) if x)
|
||||
|
||||
dmax = int(cfg.get("description_plain_max_len") or 160)
|
||||
dsc = strip_html_to_plain(row.get("description"), max_len=max(40, min(400, dmax)))
|
||||
|
||||
krmax = int(cfg.get("karate_relevance_max_len") or 0)
|
||||
kr = strip_html_to_plain(row.get("karate_relevance"), max_len=min(280, krmax)) if krmax > 0 else ""
|
||||
rel = row.get("relevance_level")
|
||||
rel_s = str(rel).strip() if rel is not None else ""
|
||||
|
||||
parts = [
|
||||
f"- id={rid} | name={nm}",
|
||||
f" | kategorie={cats or '-'}",
|
||||
f" | beschreibung={dsc or '-'}",
|
||||
]
|
||||
if krmax > 0 and (kr.strip() or rel_s):
|
||||
parts.append(f" | karate_relevanz={kr or '-'} | relevanz_stufe={rel_s or '-'}")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _safe_int_importance(value: Any) -> int:
|
||||
try:
|
||||
iv = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return max(1, min(5, iv)) if iv else 0
|
||||
|
||||
|
||||
def build_contextual_skills_catalog_block(
|
||||
cur,
|
||||
*,
|
||||
title: Optional[str],
|
||||
goal_plain: str,
|
||||
execution_plain: str,
|
||||
focus_hint: Optional[str],
|
||||
focus_ctx: Optional[Sequence[Tuple[int, bool]]],
|
||||
) -> str:
|
||||
cfg = _load_merged_retrieval_config(cur, focus_ctx)
|
||||
corpus_lower = " ".join([title or "", goal_plain or "", execution_plain or "", focus_hint or ""]).lower()
|
||||
_apply_keyword_overrides(cfg, corpus_lower)
|
||||
|
||||
tok = _corpus_tokens(title or "", goal_plain, execution_plain, focus_hint or "")
|
||||
skill_rows = _fetch_all_active_skills_for_catalog(cur)
|
||||
scored: List[Tuple[float, Dict[str, Any]]] = []
|
||||
for r in skill_rows:
|
||||
scored.append((_score_skill_row(r, cfg, tok), r))
|
||||
picked = _pick_catalog_rows(scored, cfg)
|
||||
picked.sort(
|
||||
key=lambda r: (
|
||||
-_safe_int_importance(r.get("importance")),
|
||||
str(r.get("name") or "").lower(),
|
||||
)
|
||||
)
|
||||
|
||||
lines = [_format_skill_catalog_line(row, cfg) for row in picked]
|
||||
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
|
||||
|
||||
|
||||
def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT slug, display_name, template, output_format, active
|
||||
FROM ai_prompts
|
||||
WHERE slug = %s
|
||||
""",
|
||||
(slug,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
if not d.get("active", True):
|
||||
return None
|
||||
return d
|
||||
|
||||
|
||||
def _render_template(template: str, ctx: Dict[str, str]) -> str:
|
||||
out = template or ""
|
||||
for key, val in ctx.items():
|
||||
placeholder = "{{" + key + "}}"
|
||||
out = out.replace(placeholder, val if val is not None else "")
|
||||
return out
|
||||
|
||||
|
||||
def _first_balanced_json_array(text: str) -> Optional[str]:
|
||||
"""Findet das erste vollständig geschlossene Top-Level-JSON-Array in beliebigem Fließtext."""
|
||||
i = text.find("[")
|
||||
if i < 0:
|
||||
return None
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for j in range(i, len(text)):
|
||||
ch = text[j]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif ch == "\\":
|
||||
esc = True
|
||||
elif ch == '"':
|
||||
in_str = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
continue
|
||||
if ch == "[":
|
||||
depth += 1
|
||||
elif ch == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[i : j + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _extract_json_array(text: str) -> Any:
|
||||
s = text.strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||
if s.endswith("```"):
|
||||
s = s[:-3].strip()
|
||||
if s.startswith("["):
|
||||
end = s.rfind("]")
|
||||
if end > 0:
|
||||
s = s[: end + 1]
|
||||
parsed = json.loads(s)
|
||||
if isinstance(parsed, list) and len(parsed) > _MAX_SANITIZE_SKILL_INPUT_ROWS:
|
||||
parsed = parsed[:_MAX_SANITIZE_SKILL_INPUT_ROWS]
|
||||
return parsed
|
||||
if s.startswith("{"):
|
||||
obj = json.loads(s)
|
||||
if isinstance(obj, dict):
|
||||
for k in ("skills", "items", "data"):
|
||||
v = obj.get(k)
|
||||
if isinstance(v, list):
|
||||
if len(v) > _MAX_SANITIZE_SKILL_INPUT_ROWS:
|
||||
return v[:_MAX_SANITIZE_SKILL_INPUT_ROWS]
|
||||
return v
|
||||
raise ValueError("JSON-Objekt ohne Skills-Liste")
|
||||
parsed_end = json.loads(s)
|
||||
if isinstance(parsed_end, list) and len(parsed_end) > _MAX_SANITIZE_SKILL_INPUT_ROWS:
|
||||
return parsed_end[:_MAX_SANITIZE_SKILL_INPUT_ROWS]
|
||||
return parsed_end
|
||||
|
||||
|
||||
def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
cap = rows[:_MAX_SANITIZE_SKILL_INPUT_ROWS]
|
||||
for raw in cap:
|
||||
if len(out) >= 5:
|
||||
break
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
sid = raw.get("skill_id")
|
||||
try:
|
||||
skill_id = int(sid)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id, s.name, s.category,
|
||||
sc.name AS subcategory_name
|
||||
FROM skills s
|
||||
LEFT JOIN skill_categories sc ON s.category_id = sc.id
|
||||
WHERE s.id = %s AND (s.status IS NULL OR s.status = 'active')
|
||||
""",
|
||||
(skill_id,),
|
||||
)
|
||||
sk = cur.fetchone()
|
||||
if not sk:
|
||||
continue
|
||||
|
||||
req = _normalize_exercise_skill_level(raw.get("required_level")) or "grundlagen"
|
||||
tgt = _normalize_exercise_skill_level(raw.get("target_level")) or req
|
||||
if req not in _CANONICAL_SKILL_LEVELS:
|
||||
req = _LEGACY_SKILL_LEVEL_SLUG.get(str(raw.get("required_level") or "").strip().lower(), "grundlagen")
|
||||
if req not in _CANONICAL_SKILL_LEVELS:
|
||||
req = "grundlagen"
|
||||
if tgt not in _CANONICAL_SKILL_LEVELS:
|
||||
tgt = _LEGACY_SKILL_LEVEL_SLUG.get(str(raw.get("target_level") or "").strip().lower(), req)
|
||||
if tgt not in _CANONICAL_SKILL_LEVELS:
|
||||
tgt = req
|
||||
|
||||
inten = _normalize_exercise_skill_intensity(raw.get("intensity"))
|
||||
|
||||
is_primary = bool(raw.get("is_primary")) if raw.get("is_primary") is not None else len(out) == 0
|
||||
|
||||
cat = (sk.get("category") or "").strip()
|
||||
sub = (sk.get("subcategory_name") or "").strip()
|
||||
skill_category = " / ".join(x for x in (cat, sub) if x) or (cat or None)
|
||||
|
||||
conf = raw.get("confidence")
|
||||
try:
|
||||
conf_f = float(conf) if conf is not None else None
|
||||
except (TypeError, ValueError):
|
||||
conf_f = None
|
||||
|
||||
item: Dict[str, Any] = {
|
||||
"skill_id": skill_id,
|
||||
"skill_name": (sk.get("name") or "").strip() or f"Skill #{skill_id}",
|
||||
"required_level": req,
|
||||
"target_level": tgt,
|
||||
"intensity": inten,
|
||||
"is_primary": is_primary,
|
||||
}
|
||||
if skill_category:
|
||||
item["skill_category"] = skill_category
|
||||
if conf_f is not None:
|
||||
item["confidence"] = conf_f
|
||||
out.append(item)
|
||||
|
||||
return out[:5]
|
||||
|
||||
|
||||
def _require_openrouter() -> Tuple[str, str]:
|
||||
key, model = normalize_openrouter_env()
|
||||
if not key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
|
||||
)
|
||||
return key, model
|
||||
|
||||
|
||||
def run_exercise_ai_suggestion(
|
||||
cur,
|
||||
*,
|
||||
title: Optional[str],
|
||||
goal: Optional[str],
|
||||
execution: Optional[str],
|
||||
focus_area_hint: Optional[str],
|
||||
focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
|
||||
want_summary: bool,
|
||||
want_skills: bool,
|
||||
) -> Dict[str, Any]:
|
||||
key, model = _require_openrouter()
|
||||
|
||||
g_plain = strip_html_to_plain(goal)
|
||||
e_plain = strip_html_to_plain(execution)
|
||||
if not (g_plain.strip() or e_plain.strip()):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).",
|
||||
)
|
||||
|
||||
t_title = (title or "").strip()
|
||||
focus = (focus_area_hint or "").strip()
|
||||
|
||||
result: Dict[str, Any] = {"model": model}
|
||||
|
||||
if _ai_debug_on():
|
||||
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
|
||||
_LOGGER.warning(
|
||||
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s "
|
||||
"exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
|
||||
want_summary,
|
||||
want_skills,
|
||||
len(t_title),
|
||||
len(g_plain),
|
||||
len(e_plain),
|
||||
len(focus),
|
||||
fid_list,
|
||||
)
|
||||
|
||||
if want_summary:
|
||||
prow = _load_prompt_row(cur, "exercise_summary")
|
||||
if not prow:
|
||||
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
|
||||
ctx = {
|
||||
"exercise_title": t_title or "-",
|
||||
"exercise_focus_area": focus or "-",
|
||||
"exercise_goal": g_plain or "-",
|
||||
"exercise_execution": e_plain or "-",
|
||||
}
|
||||
prompt = _render_template(str(prow["template"]), ctx)
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
"AI_DEBUG exercise_ai summary prompt_slug=exercise_summary prompt_chars=%s unreplaced_mustache_pairs=%s",
|
||||
len(prompt),
|
||||
prompt.count("{{"),
|
||||
)
|
||||
try:
|
||||
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
|
||||
except OpenRouterError as e:
|
||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning("AI_DEBUG exercise_ai summary response_chars=%s", len(raw or ""))
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="OpenRouter/KI lieferte eine leere Kurzfassung (kein Modelltext).",
|
||||
)
|
||||
if len(text) > _MAX_SUMMARY_CHARS:
|
||||
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
|
||||
result["summary"] = {"text": text, "ai_generated": True, "model": model}
|
||||
|
||||
if want_skills:
|
||||
srow = _load_prompt_row(cur, "exercise_skill_suggestions")
|
||||
if not srow:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
||||
)
|
||||
catalog = build_contextual_skills_catalog_block(
|
||||
cur,
|
||||
title=t_title,
|
||||
goal_plain=g_plain,
|
||||
execution_plain=e_plain,
|
||||
focus_hint=focus or None,
|
||||
focus_ctx=focus_areas_context,
|
||||
)
|
||||
ctx = {
|
||||
"exercise_title": t_title or "-",
|
||||
"exercise_focus_area": focus or "-",
|
||||
"exercise_goal": g_plain or "-",
|
||||
"exercise_execution": e_plain or "-",
|
||||
"skills_catalog": catalog,
|
||||
}
|
||||
prompt = _render_template(str(srow["template"]), ctx)
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
"AI_DEBUG exercise_ai skills prompt_slug=exercise_skill_suggestions catalog_chars=%s prompt_chars=%s "
|
||||
"template_has_skills_placeholder=%s",
|
||||
len(catalog),
|
||||
len(prompt),
|
||||
"{{skills_catalog}}" in str(srow.get("template") or ""),
|
||||
)
|
||||
sys_hint = (
|
||||
"Du antwortest nur mit validem JSON (Array). Keine Kommentare, keine Erklaerungen ausserhalb des JSON."
|
||||
)
|
||||
try:
|
||||
raw = openrouter_chat_completion(
|
||||
api_key=key,
|
||||
model=model,
|
||||
user_content=prompt,
|
||||
system_content=sys_hint,
|
||||
temperature=0.15,
|
||||
)
|
||||
except OpenRouterError as e:
|
||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning("AI_DEBUG exercise_ai skills response_chars=%s", len(raw or ""))
|
||||
body = (raw or "").strip()
|
||||
if not body:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="OpenRouter/KI lieferte leeren Inhalt für Skill-JSON.",
|
||||
)
|
||||
frag = _first_balanced_json_array(body)
|
||||
if frag:
|
||||
body = frag
|
||||
try:
|
||||
parsed = _extract_json_array(body)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
"AI_DEBUG exercise_ai skills JSON parse_failed err=%s head=%s",
|
||||
e,
|
||||
(body.replace("\r", "").replace("\n", " ").strip())[:400],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="KI lieferte kein verwertbares JSON fuer Skills.",
|
||||
) from e
|
||||
skills = _sanitize_skill_entries(cur, parsed)
|
||||
if _ai_debug_on():
|
||||
cand_n = len(parsed) if isinstance(parsed, list) else -1
|
||||
_LOGGER.warning("AI_DEBUG exercise_ai skills parsed_len=%s sanitized_kept=%s", cand_n, len(skills))
|
||||
|
||||
result["skills"] = skills
|
||||
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_contextual_skills_catalog_block",
|
||||
"run_exercise_ai_suggestion",
|
||||
"strip_html_to_plain",
|
||||
]
|
||||
|
|
@ -193,7 +193,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_skill_retrieval_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -220,6 +220,7 @@ app.include_router(import_wiki.router)
|
|||
app.include_router(import_wiki_admin.router)
|
||||
app.include_router(legal_documents.router)
|
||||
app.include_router(content_reports.router)
|
||||
app.include_router(ai_skill_retrieval_admin.router)
|
||||
|
||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||
|
|
|
|||
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');
|
||||
125
backend/migrations/068_ai_skill_retrieval_profiles.sql
Normal file
125
backend/migrations/068_ai_skill_retrieval_profiles.sql
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
-- Migration 068: KI Skill-Retrieval-Profile pro Fokusbereich (+ Standardprofil)
|
||||
-- Purpose: Gewichtungen/Quota fuer exercise_ai Skill-Katalog (OpenRouter Kontext)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_skill_retrieval_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_focus_area
|
||||
ON ai_skill_retrieval_profiles (focus_area_id)
|
||||
WHERE focus_area_id IS NOT NULL AND active = TRUE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_default_only
|
||||
ON ai_skill_retrieval_profiles (is_default)
|
||||
WHERE is_default IS TRUE AND active = TRUE;
|
||||
|
||||
COMMENT ON TABLE ai_skill_retrieval_profiles IS
|
||||
'Gewichte/Quota fuer Skill-Katalog in exercise_ai; optional gebunden an focus_areas, genau eine is_default=TRUE';
|
||||
|
||||
INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config)
|
||||
VALUES (
|
||||
NULL,
|
||||
TRUE,
|
||||
'Standard',
|
||||
'Kein/Undefinierter Fokusbereich: neutrale Gewichte mit sanften Caps auf sehr breite Unterkategorien.',
|
||||
TRUE,
|
||||
'{
|
||||
"version": 1,
|
||||
"importance_multiplier": 1,
|
||||
"text_overlap_bonus": 2,
|
||||
"main_slug_weights": { "karate": 1, "allgemeine": 1 },
|
||||
"category_slug_weights": {},
|
||||
"category_max_share": {
|
||||
"kondition": 0.38,
|
||||
"koordination": 0.35
|
||||
},
|
||||
"main_min_share": {},
|
||||
"description_plain_max_len": 160,
|
||||
"karate_relevance_max_len": 72,
|
||||
"keyword_overrides": [
|
||||
{
|
||||
"keywords_any": ["rollenspiel", "szenario", "deesk", "diskussion"],
|
||||
"case_insensitive": true,
|
||||
"patch": {
|
||||
"category_slug_weights": {
|
||||
"psychische_faehigkeiten": 1.65,
|
||||
"soziale_faehigkeiten": 1.65,
|
||||
"kognition": 1.4
|
||||
},
|
||||
"category_max_share": {
|
||||
"kondition": 0.08,
|
||||
"koordination": 0.1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"keywords_any": ["befreiung", "haltegriff", "greifer", "umklammer"],
|
||||
"case_insensitive": true,
|
||||
"patch": {
|
||||
"category_slug_weights": {
|
||||
"selbstverteidigung": 2.2,
|
||||
"koordination": 0.9
|
||||
},
|
||||
"main_slug_weights": { "karate": 1.35 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}'::jsonb
|
||||
);
|
||||
|
||||
INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config)
|
||||
SELECT
|
||||
fa.id,
|
||||
FALSE,
|
||||
'Gewaltschutz',
|
||||
'Kaum klassische Sportfaehigkeit; Gewicht auf Deeskalation, Kognition/Soziales; SV-Schwerpunkt per Keywords verstaerken.',
|
||||
TRUE,
|
||||
'{
|
||||
"version": 1,
|
||||
"importance_multiplier": 1,
|
||||
"text_overlap_bonus": 2.25,
|
||||
"main_slug_weights": { "karate": 1.08, "allgemeine": 1.06 },
|
||||
"category_slug_weights": {
|
||||
"kognition": 1.72,
|
||||
"psychische_faehigkeiten": 1.78,
|
||||
"soziale_faehigkeiten": 1.78,
|
||||
"selbstverteidigung": 1.82,
|
||||
"kondition": 0.32,
|
||||
"koordination": 0.4
|
||||
},
|
||||
"category_max_share": {
|
||||
"kondition": 0.12,
|
||||
"koordination": 0.16
|
||||
},
|
||||
"main_min_share": {},
|
||||
"description_plain_max_len": 150,
|
||||
"karate_relevance_max_len": 0,
|
||||
"keyword_overrides": [
|
||||
{
|
||||
"keywords_any": ["befreiung", "haltegriff", "greifer"],
|
||||
"case_insensitive": true,
|
||||
"patch": {
|
||||
"category_slug_weights": {
|
||||
"selbstverteidigung": 3.25,
|
||||
"koordination": 1.08
|
||||
},
|
||||
"main_slug_weights": { "karate": 1.5 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}'::jsonb
|
||||
FROM focus_areas fa
|
||||
WHERE fa.name = 'Gewaltschutz'
|
||||
AND (fa.status IS NULL OR fa.status = 'active')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ai_skill_retrieval_profiles p
|
||||
WHERE p.focus_area_id = fa.id AND p.active = TRUE
|
||||
)
|
||||
LIMIT 1;
|
||||
205
backend/openrouter_chat.py
Normal file
205
backend/openrouter_chat.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""
|
||||
Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MODEL / OPENROUTER_BASE_URL from env.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
_logger = logging.getLogger("shinkan.openrouter")
|
||||
|
||||
_SKIP_ANTHROPIC_BLOCK_TYPES = frozenset(
|
||||
{
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
"reasoning",
|
||||
"tool_use",
|
||||
"tool_calls",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _shinkan_ai_debug() -> bool:
|
||||
return os.getenv("SHINKAN_AI_DEBUG", "").strip().lower() in ("1", "true", "yes", "full")
|
||||
|
||||
|
||||
def _coerce_nested_text(val: Any) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, str):
|
||||
return val.strip()
|
||||
if isinstance(val, bool) or isinstance(val, (int, float)):
|
||||
return str(val).strip()
|
||||
if isinstance(val, list):
|
||||
return "".join(_coerce_nested_text(x) for x in val).strip()
|
||||
if isinstance(val, dict):
|
||||
# OpenRouter/Anthropic: verschachtelte text/content-Hüllen
|
||||
for key in ("text", "content", "value"):
|
||||
if key in val:
|
||||
nested = _coerce_nested_text(val.get(key))
|
||||
if nested:
|
||||
return nested
|
||||
return ""
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def _flatten_message_content(content: Any) -> str:
|
||||
"""
|
||||
Chat-Completion: `content` als String oder als Liste strukturierter Blöcke
|
||||
(Anthropic Claude über OpenRouter/Bedrock, teils verschachtelt).
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
bits = _coerce_nested_text(block)
|
||||
if bits:
|
||||
parts.append(bits)
|
||||
elif isinstance(block, dict):
|
||||
t_raw = block.get("type")
|
||||
ts = str(t_raw or "").strip().lower()
|
||||
if ts and (ts in _SKIP_ANTHROPIC_BLOCK_TYPES or ts.endswith("_thinking")):
|
||||
continue
|
||||
txt = None
|
||||
if ts in ("text", "output_text", ""):
|
||||
txt = block.get("text")
|
||||
if txt is None:
|
||||
txt = block.get("content")
|
||||
else:
|
||||
# unbekannten Typ weiter versuchen (Provider-Varianten), aber tool-use überspringen
|
||||
low = ts
|
||||
if "tool_use" in low or low.startswith("tool_"):
|
||||
continue
|
||||
txt = block.get("text") if block.get("text") is not None else block.get("content")
|
||||
bits = _coerce_nested_text(txt)
|
||||
if bits:
|
||||
parts.append(bits)
|
||||
return "".join(parts).strip()
|
||||
if isinstance(content, dict):
|
||||
return _coerce_nested_text(content)
|
||||
return str(content).strip()
|
||||
|
||||
|
||||
class OpenRouterError(Exception):
|
||||
"""Upstream or transport failure."""
|
||||
|
||||
|
||||
def openrouter_chat_completion(
|
||||
*,
|
||||
api_key: str,
|
||||
model: str,
|
||||
user_content: str,
|
||||
system_content: Optional[str] = None,
|
||||
timeout_sec: float = 120.0,
|
||||
temperature: float = 0.25,
|
||||
site_url: Optional[str] = None,
|
||||
app_title: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Returns assistant message content (plain string). Caller validates empty responses.
|
||||
"""
|
||||
base = (os.getenv("OPENROUTER_BASE_URL") or "").strip().rstrip("/") or "https://openrouter.ai/api/v1"
|
||||
url = f"{base}/chat/completions"
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
referer = (site_url or os.getenv("APP_URL") or "").strip()
|
||||
if referer:
|
||||
headers["HTTP-Referer"] = referer
|
||||
title = (app_title or os.getenv("OPENROUTER_APP_TITLE") or "Shinkan Jinkendo").strip()
|
||||
if title:
|
||||
headers["X-Title"] = title
|
||||
|
||||
messages: List[Dict[str, str]] = []
|
||||
if system_content and str(system_content).strip():
|
||||
messages.append({"role": "system", "content": str(system_content).strip()})
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=timeout_sec) as client:
|
||||
resp = client.post(url, headers=headers, json=payload)
|
||||
except httpx.RequestError as e:
|
||||
raise OpenRouterError(str(e)) from e
|
||||
|
||||
if resp.status_code >= 400:
|
||||
detail = ""
|
||||
try:
|
||||
j = resp.json()
|
||||
detail = (
|
||||
str(j.get("error", {}).get("message"))
|
||||
if isinstance(j.get("error"), dict)
|
||||
else str(j.get("message") or j)
|
||||
)
|
||||
except Exception:
|
||||
detail = (resp.text or "")[:600]
|
||||
raise OpenRouterError(f"HTTP {resp.status_code}: {detail}".strip())
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except json.JSONDecodeError as e:
|
||||
raise OpenRouterError("Ungueltige JSON-Antwort von OpenRouter") from e
|
||||
|
||||
choices = data.get("choices") if isinstance(data, dict) else None
|
||||
if not choices or not isinstance(choices, list):
|
||||
raise OpenRouterError("OpenRouter: keine choices in Antwort")
|
||||
|
||||
msg0 = choices[0] if choices else {}
|
||||
inner = msg0.get("message") if isinstance(msg0, dict) else None
|
||||
|
||||
blobs: List[Any] = []
|
||||
if isinstance(inner, dict):
|
||||
if inner.get("content") is not None:
|
||||
blobs.append(inner.get("content"))
|
||||
if inner.get("refusal") is not None:
|
||||
blobs.append(inner.get("refusal"))
|
||||
elif isinstance(inner, str):
|
||||
blobs.append(inner)
|
||||
if isinstance(msg0, dict) and msg0.get("content") is not None and msg0.get("content") not in blobs:
|
||||
blobs.append(msg0.get("content"))
|
||||
|
||||
pieces = [_flatten_message_content(b).strip() for b in blobs if b is not None]
|
||||
joined = ("\n".join(p for p in pieces if p)).strip()
|
||||
|
||||
if _shinkan_ai_debug():
|
||||
fr = str(msg0.get("finish_reason") or "") if isinstance(msg0, dict) else ""
|
||||
fu = data.get("usage") if isinstance(data.get("usage"), dict) else {}
|
||||
pu = str(fu.get("prompt_tokens") or "")
|
||||
pc = str(fu.get("completion_tokens") or "")
|
||||
pt = str(fu.get("total_tokens") or "")
|
||||
raw_cls = type(blobs[0]).__name__ if blobs else "none"
|
||||
cc = str(len(joined))
|
||||
_logger.warning(
|
||||
"[AI_DEBUG/openrouter] model=%s finish_reason=%s usage_prompt=%s usage_completion=%s usage_total=%s "
|
||||
"raw_content_cls=%s out_chars=%s",
|
||||
model,
|
||||
fr,
|
||||
pu,
|
||||
pc,
|
||||
pt,
|
||||
raw_cls,
|
||||
cc,
|
||||
)
|
||||
|
||||
return joined
|
||||
|
||||
|
||||
def normalize_openrouter_env() -> tuple[str, str]:
|
||||
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
|
||||
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
|
||||
return key, model
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
httpx==0.27.2
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
anthropic==0.26.0
|
||||
|
|
|
|||
370
backend/routers/ai_skill_retrieval_admin.py
Normal file
370
backend/routers/ai_skill_retrieval_admin.py
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
"""
|
||||
Superadmin API: Pflege von ai_skill_retrieval_profiles (KI Skill-Katalog / exercise_ai).
|
||||
|
||||
Kein Vereinsbezug — require_auth + is_superadmin; kein TenantContext.
|
||||
Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from auth import require_auth
|
||||
from club_tenancy import is_superadmin
|
||||
from db import get_cursor, get_db, r2d
|
||||
|
||||
router = APIRouter(tags=["admin_ai_skill_retrieval"])
|
||||
|
||||
|
||||
def _require_superadmin(session: dict) -> dict:
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if not is_superadmin(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Superadmins")
|
||||
return session
|
||||
|
||||
|
||||
def _table_ready(cur) -> bool:
|
||||
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_skill_retrieval_profiles",))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
val = row.get("t") if isinstance(row, dict) else row[0]
|
||||
return val is not None and str(val).strip() != ""
|
||||
|
||||
|
||||
def _assert_table(cur) -> None:
|
||||
if not _table_ready(cur):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Tabelle ai_skill_retrieval_profiles fehlt — Migration 068 ausführen.",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_config(raw: Any) -> Dict[str, Any]:
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"config: ungültiges JSON — {e}") from e
|
||||
if not isinstance(parsed, dict):
|
||||
raise HTTPException(status_code=400, detail="config muss ein JSON-Objekt sein.")
|
||||
return parsed
|
||||
raise HTTPException(status_code=400, detail="config muss ein Objekt sein.")
|
||||
|
||||
|
||||
class AiRetrievalProfileCreate(BaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=200)
|
||||
description: Optional[str] = Field("", max_length=4000)
|
||||
active: bool = True
|
||||
is_default: bool = False
|
||||
focus_area_id: Optional[int] = Field(None, ge=1)
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def default_vs_focus(self):
|
||||
if self.is_default and self.focus_area_id is not None:
|
||||
raise ValueError("Standardprofil darf keinen Fokusbereich (focus_area_id) haben.")
|
||||
if not self.is_default and self.focus_area_id is None:
|
||||
raise ValueError("Profil ohne Standard-Flag benötigt eine focus_area_id.")
|
||||
return self
|
||||
|
||||
|
||||
class AiRetrievalProfileUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=4000)
|
||||
active: Optional[bool] = None
|
||||
is_default: Optional[bool] = None
|
||||
focus_area_id: Optional[int] = Field(None, ge=1)
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def _row_to_out(row: dict) -> dict:
|
||||
cfg = row.get("config")
|
||||
if isinstance(cfg, str):
|
||||
try:
|
||||
cfg = json.loads(cfg)
|
||||
except json.JSONDecodeError:
|
||||
cfg = {}
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
out = {
|
||||
"id": row["id"],
|
||||
"focus_area_id": row.get("focus_area_id"),
|
||||
"focus_area_name": row.get("focus_area_name"),
|
||||
"is_default": bool(row.get("is_default")),
|
||||
"name": row.get("name") or "",
|
||||
"description": row.get("description") or "",
|
||||
"active": bool(row.get("active", True)),
|
||||
"config": cfg,
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _active_focus_conflict(cur, focus_area_id: int, exclude_id: Optional[int] = None) -> bool:
|
||||
if exclude_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM ai_skill_retrieval_profiles
|
||||
WHERE active = true AND focus_area_id = %s AND id != %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(focus_area_id, exclude_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM ai_skill_retrieval_profiles
|
||||
WHERE active = true AND focus_area_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(focus_area_id,),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _focus_area_exists(cur, focus_area_id: int) -> bool:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM focus_areas WHERE id = %s AND (status IS NULL OR status = 'active') LIMIT 1",
|
||||
(focus_area_id,),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
@router.get("/api/admin/ai-skill-retrieval-profiles")
|
||||
def list_ai_skill_retrieval_profiles(session: dict = Depends(require_auth)):
|
||||
_require_superadmin(session)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_table(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
|
||||
fa.name AS focus_area_name
|
||||
FROM ai_skill_retrieval_profiles p
|
||||
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
|
||||
ORDER BY p.is_default DESC NULLS LAST, fa.name NULLS LAST, p.name
|
||||
"""
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
return [_row_to_out(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
|
||||
def get_ai_skill_retrieval_profile(profile_id: int, session: dict = Depends(require_auth)):
|
||||
_require_superadmin(session)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_table(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
|
||||
fa.name AS focus_area_name
|
||||
FROM ai_skill_retrieval_profiles p
|
||||
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
|
||||
WHERE p.id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||
return _row_to_out(r2d(row))
|
||||
|
||||
|
||||
@router.post("/api/admin/ai-skill-retrieval-profiles", status_code=201)
|
||||
def create_ai_skill_retrieval_profile(
|
||||
body: AiRetrievalProfileCreate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
cfg = _normalize_config(body.config)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_table(cur)
|
||||
|
||||
if body.is_default:
|
||||
cur.execute(
|
||||
"UPDATE ai_skill_retrieval_profiles SET is_default = false, updated_at = NOW() WHERE is_default = true"
|
||||
)
|
||||
else:
|
||||
if not _focus_area_exists(cur, int(body.focus_area_id)):
|
||||
raise HTTPException(status_code=400, detail="Unbekannter oder inaktiver Fokusbereich.")
|
||||
if body.active and _active_focus_conflict(cur, int(body.focus_area_id)):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Für diesen Fokusbereich existiert bereits ein aktives Profil.",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ai_skill_retrieval_profiles
|
||||
(focus_area_id, is_default, name, description, active, config, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
None if body.is_default else body.focus_area_id,
|
||||
body.is_default,
|
||||
body.name.strip(),
|
||||
(body.description or "").strip(),
|
||||
body.active,
|
||||
Json(cfg),
|
||||
),
|
||||
)
|
||||
new_id_row = cur.fetchone()
|
||||
new_id = int(new_id_row["id"] if isinstance(new_id_row, dict) else new_id_row[0])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
|
||||
fa.name AS focus_area_name
|
||||
FROM ai_skill_retrieval_profiles p
|
||||
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
|
||||
WHERE p.id = %s
|
||||
""",
|
||||
(new_id,),
|
||||
)
|
||||
row = r2d(cur.fetchone())
|
||||
return _row_to_out(row)
|
||||
|
||||
|
||||
@router.put("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
|
||||
def update_ai_skill_retrieval_profile(
|
||||
profile_id: int,
|
||||
body: AiRetrievalProfileUpdate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_table(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, focus_area_id, is_default, name, description, active, config, updated_at
|
||||
FROM ai_skill_retrieval_profiles WHERE id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
old = cur.fetchone()
|
||||
if not old:
|
||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||
old_d = dict(old)
|
||||
|
||||
if body.is_default is True and body.focus_area_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Standardprofil und Fokusbereich schließen sich aus.",
|
||||
)
|
||||
|
||||
nm = body.name.strip() if body.name is not None else (old_d["name"] or "")
|
||||
nm = nm.strip()
|
||||
desc = (
|
||||
body.description.strip() if body.description is not None else (old_d.get("description") or "")
|
||||
).strip()
|
||||
active = body.active if body.active is not None else bool(old_d.get("active", True))
|
||||
next_is_default = body.is_default if body.is_default is not None else bool(old_d.get("is_default"))
|
||||
next_focus_raw = (
|
||||
body.focus_area_id if body.focus_area_id is not None else old_d.get("focus_area_id")
|
||||
)
|
||||
cfg = (
|
||||
_normalize_config(body.config) if body.config is not None else _normalize_config(old_d.get("config"))
|
||||
)
|
||||
|
||||
if next_is_default:
|
||||
focus_id_sql: Optional[int] = None
|
||||
else:
|
||||
if next_focus_raw is None:
|
||||
raise HTTPException(status_code=400, detail="Nicht-Standard-Profil benötigt focus_area_id.")
|
||||
focus_id_sql = int(next_focus_raw)
|
||||
if not _focus_area_exists(cur, focus_id_sql):
|
||||
raise HTTPException(status_code=400, detail="Unbekannter oder inaktiver Fokusbereich.")
|
||||
if active and _active_focus_conflict(cur, focus_id_sql, exclude_id=profile_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Für diesen Fokusbereich existiert bereits ein anderes aktives Profil.",
|
||||
)
|
||||
|
||||
old_default = bool(old_d.get("is_default"))
|
||||
|
||||
if old_default and not next_is_default:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Das Standardprofil kann hier nicht zum Fokusbereichsprofil geändert werden — zuerst ein anderes Profil als Standard aktivieren.",
|
||||
)
|
||||
|
||||
if old_default and active is False:
|
||||
raise HTTPException(status_code=400, detail="Standardprofil kann nicht deaktiviert werden.")
|
||||
|
||||
if next_is_default:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE ai_skill_retrieval_profiles
|
||||
SET is_default = false, updated_at = NOW()
|
||||
WHERE is_default = true AND id != %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE ai_skill_retrieval_profiles
|
||||
SET focus_area_id = %s,
|
||||
is_default = %s,
|
||||
name = %s,
|
||||
description = %s,
|
||||
active = %s,
|
||||
config = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
focus_id_sql,
|
||||
next_is_default,
|
||||
nm,
|
||||
desc,
|
||||
active,
|
||||
Json(cfg),
|
||||
profile_id,
|
||||
),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
|
||||
fa.name AS focus_area_name
|
||||
FROM ai_skill_retrieval_profiles p
|
||||
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
|
||||
WHERE p.id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
row = r2d(cur.fetchone())
|
||||
return _row_to_out(row)
|
||||
|
||||
|
||||
@router.delete("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
|
||||
def delete_ai_skill_retrieval_profile(profile_id: int, session: dict = Depends(require_auth)):
|
||||
_require_superadmin(session)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_table(cur)
|
||||
cur.execute(
|
||||
"SELECT id, is_default FROM ai_skill_retrieval_profiles WHERE id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||
if bool(row.get("is_default") if isinstance(row, dict) else row[1]):
|
||||
raise HTTPException(status_code=400, detail="Standardprofil kann nicht gelöscht werden.")
|
||||
cur.execute("DELETE FROM ai_skill_retrieval_profiles WHERE id = %s", (profile_id,))
|
||||
return {"deleted": True, "id": profile_id}
|
||||
|
|
@ -35,6 +35,8 @@ from tenant_context import TenantContext, get_tenant_context, get_tenant_context
|
|||
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
|
||||
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
|
||||
from media_legal_hold import assert_not_under_legal_hold
|
||||
from exercise_ai import run_exercise_ai_suggestion
|
||||
|
||||
from exercise_rich_text import (
|
||||
RICH_HTML_EXERCISE_FIELDS,
|
||||
assert_no_inline_media_references_on_create,
|
||||
|
|
@ -356,6 +358,53 @@ class ExerciseMediaFromAsset(BaseModel):
|
|||
media_type: Optional[str] = None
|
||||
|
||||
|
||||
class ExerciseAiFocusCtx(BaseModel):
|
||||
"""Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles)."""
|
||||
|
||||
focus_area_id: int = Field(..., ge=1)
|
||||
is_primary: Optional[bool] = False
|
||||
|
||||
|
||||
class ExerciseAiSuggestBody(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=300)
|
||||
goal: Optional[str] = Field(None, max_length=64000)
|
||||
execution: Optional[str] = Field(None, max_length=128000)
|
||||
focus_area_hint: Optional[str] = Field(None, max_length=1200)
|
||||
focus_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field(
|
||||
None,
|
||||
description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung",
|
||||
)
|
||||
include_summary: bool = True
|
||||
include_skills: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_include_any(self):
|
||||
if not self.include_summary and not self.include_skills:
|
||||
raise ValueError("Mindestens include_summary oder include_skills aktivieren.")
|
||||
return self
|
||||
|
||||
|
||||
class ExerciseAiRegenerateBody(BaseModel):
|
||||
"""Welche Artefakte neu angefragt werden sollen."""
|
||||
|
||||
regenerate: list[str] = Field(default_factory=lambda: ["summary", "skills"])
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_regs(self):
|
||||
allowed = {"summary", "skills"}
|
||||
raw = [str(x).strip().lower() for x in (self.regenerate or [])]
|
||||
out = []
|
||||
seen = set()
|
||||
for lx in raw:
|
||||
if lx in allowed and lx not in seen:
|
||||
out.append(lx)
|
||||
seen.add(lx)
|
||||
if not out:
|
||||
out = ["summary", "skills"]
|
||||
self.regenerate = out
|
||||
return self
|
||||
|
||||
|
||||
class ExerciseVariantCreate(BaseModel):
|
||||
variant_name: str = Field(..., min_length=3, max_length=200)
|
||||
description: Optional[str] = None
|
||||
|
|
@ -1244,7 +1293,7 @@ def assign_exercise_relations(
|
|||
(
|
||||
exercise_id,
|
||||
skill["skill_id"],
|
||||
False,
|
||||
bool(skill.get("is_primary")),
|
||||
normalize_exercise_skill_intensity(skill.get("intensity")),
|
||||
normalize_exercise_skill_level(skill.get("required_level")),
|
||||
normalize_exercise_skill_level(skill.get("target_level")),
|
||||
|
|
@ -2216,6 +2265,98 @@ def list_exercises_like_get(
|
|||
)
|
||||
|
||||
|
||||
def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]:
|
||||
rows: list[tuple[int, bool]] = []
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
fid = int(row.get("focus_area_id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if fid < 1:
|
||||
continue
|
||||
rows.append((fid, bool(row.get("is_primary"))))
|
||||
rows.sort(key=lambda x: (not x[1], x[0]))
|
||||
return rows
|
||||
|
||||
|
||||
def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str:
|
||||
parts: List[str] = []
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if isinstance(row, dict):
|
||||
nm = (row.get("name") or "").strip()
|
||||
if nm:
|
||||
parts.append(nm)
|
||||
txt = ", ".join(parts).strip()
|
||||
if len(txt) > 900:
|
||||
return txt[:899] + "…"
|
||||
return txt
|
||||
|
||||
|
||||
@router.post("/exercises/ai/suggest")
|
||||
def exercise_ai_suggest_endpoint(
|
||||
body: ExerciseAiSuggestBody,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""
|
||||
KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern.
|
||||
OPENROUTER_API_KEY erforderlich.
|
||||
"""
|
||||
_ = tenant.profile_id
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
fctx = None
|
||||
if body.focus_areas_context:
|
||||
fctx = [(x.focus_area_id, bool(x.is_primary)) for x in body.focus_areas_context]
|
||||
|
||||
payload = run_exercise_ai_suggestion(
|
||||
cur,
|
||||
title=(body.title or "").strip(),
|
||||
goal=body.goal,
|
||||
execution=body.execution,
|
||||
focus_area_hint=(body.focus_area_hint or "").strip() or None,
|
||||
focus_areas_context=fctx,
|
||||
want_summary=body.include_summary,
|
||||
want_skills=body.include_skills,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/exercises/{exercise_id}/ai/regenerate")
|
||||
def exercise_ai_regenerate_endpoint(
|
||||
exercise_id: int,
|
||||
body: ExerciseAiRegenerateBody,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
|
||||
want_summary = "summary" in body.regenerate
|
||||
want_skills = "skills" in body.regenerate
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||
|
||||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||
if not exercise:
|
||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||
|
||||
focus = _focus_area_hint_from_detail(exercise)
|
||||
fctx = _focus_areas_ai_ctx_from_detail(exercise)
|
||||
|
||||
payload = run_exercise_ai_suggestion(
|
||||
cur,
|
||||
title=str(exercise.get("title") or "").strip(),
|
||||
goal=exercise.get("goal"),
|
||||
execution=exercise.get("execution"),
|
||||
focus_area_hint=focus or None,
|
||||
focus_areas_context=fctx or None,
|
||||
want_summary=want_summary,
|
||||
want_skills=want_skills,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/exercises/{exercise_id}")
|
||||
def get_exercise(
|
||||
exercise_id: int,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
|||
"admin_users.py",
|
||||
"platform_media_storage.py",
|
||||
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
|
||||
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"catalogs.py",
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.151"
|
||||
BUILD_DATE = "2026-05-20"
|
||||
DB_SCHEMA_VERSION = "20260520066"
|
||||
APP_VERSION = "0.8.157"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260529068"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -18,11 +18,12 @@ MODULE_VERSIONS = {
|
|||
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
|
||||
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
||||
"exercises": "2.30.3", # Frontend KI ohne Modal-Grausperre; Anthropic/OpenRouter verschachtelte Textbloecke; SHINKAN_AI_DEBUG Warn-Logs exercise_ai/OpenRouter
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
@ -37,6 +38,56 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.157",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Übungsformular-KI: Buttons nur noch bei Busy deaktiviert; Vorschau wird vor neuem Aufruf geschlossen (kein Permanent-Grauschalter durch Modal-Zustand)",
|
||||
"OpenRouter: verschachtelte Textbloecke/Content-Huellen fuer Anthropic; refusal optional mitgenommen",
|
||||
"exercise_ai: SHINKAN_AI_DEBUG=1 — detaillierte WARN-Logs (Prompt-Laengen, parse-Fehler, Sanitize-Anzahl)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.156",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"OpenRouter Client: Assistant-Content als Liste/Bloecke (Claude über Bedrock/OpenRouter)",
|
||||
"exercise_ai: leere Modelantwort als 502; Skill-JSON aus Fließtext per balanciertem Array-Zuschnitt robuster parsen",
|
||||
"Admin Retrieval-Profil: Gewichte/Unterkategorien per Formular ohne JSON-Editor",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.155",
|
||||
"date": "2026-05-29",
|
||||
"changes": [
|
||||
"exercise_ai: Fix haengender KI-Endpunkt bei sehr langen Skill-Arrays vom Modell (Cap + frueher Abbruch nach 5 gueltigen Zeilen)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.154",
|
||||
"date": "2026-05-29",
|
||||
"changes": [
|
||||
"Superadmin-Web: KI Skill-Retrieval-Profile unter /admin/ai-skill-retrieval (Liste, JSON config, CRUD gegen /api/admin/ai-skill-retrieval-profiles*)",
|
||||
"Backend: Router ai_skill_retrieval_admin registriert; ACCESS_HINTS EXEMPT dokumentiert",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.153",
|
||||
"date": "2026-05-29",
|
||||
"changes": [
|
||||
"Migration 068: ai_skill_retrieval_profiles — konfigurierbare Gewichte/Quotes fuer Skill-Katalog in exercise_ai",
|
||||
"POST /api/exercises/ai/suggest: optionales Body-Feld focus_areas_context; regenerate nutzt gespeicherte Fokusbereiche",
|
||||
"exercise_ai: kontextbezogene Skill-Auswahl (Score, Kategorie-Caps), Keyword-Patches wie Rollenspiel vs. Haltegriff/Befreiung",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.152",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"KI bei Uebungen: Migration 067 ai_prompts + summary_ai_generated; OpenRouter-Hilfsmodul; POST /api/exercises/ai/suggest und POST /api/exercises/{id}/ai/regenerate",
|
||||
"Uebungsformular: Buttons KI Kurzfassung / Fähigkeiten; exercise_skills is_primary wird aus Payload gespeichert",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.151",
|
||||
"date": "2026-05-20",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ services:
|
|||
DB_PASSWORD: "${DB_PASSWORD:-dev_password}"
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||
OPENROUTER_MODEL: ${OPENROUTER_MODEL}
|
||||
SHINKAN_AI_DEBUG: "${SHINKAN_AI_DEBUG:-}"
|
||||
SMTP_HOST: ${SMTP_HOST}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ services:
|
|||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||
OPENROUTER_MODEL: ${OPENROUTER_MODEL}
|
||||
SHINKAN_AI_DEBUG: "${SHINKAN_AI_DEBUG:-}"
|
||||
SMTP_HOST: ${SMTP_HOST}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,10 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
|
|||
|
||||
### 4.1 Übungen (Kernobjekt)
|
||||
|
||||
- **Anlegen, Bearbeiten, Archivieren/Löschen** je nach Rolle und Sichtbarkeit.
|
||||
- **Mehrdimensionale Einordnung:** Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, **Fähigkeiten mit Stufen**; Suche und Filter über diese Dimensionen.
|
||||
- **Übungsvarianten:** mehrere Ausprägungen einer Übung (z. B. Aufbau, Schwierigkeit, Material), mit Reihenfolge und optionaler **Voraussetzungsvariante**.
|
||||
- **Anlegen, Bearbeiten, Archivieren/Löschen** je nach Rolle und Freigabelevel (siehe §4.7).
|
||||
- **Bearbeitungsformular (Registerkarten):** Stammdaten · Anleitung · Einordnung · (Kombination) · Varianten · Medien & Mehr — reduziert Scroll-Tiefe; farbige Panel-Trenner; Varianten und Medien erst nach erstem Speichern.
|
||||
- **Mehrdimensionale Einordnung:** Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, **Fähigkeiten mit Stufen und Intensität** (`niedrig`/`mittel`/`hoch`); Suche und Filter über diese Dimensionen.
|
||||
- **Übungsvarianten:** mehrere Ausprägungen einer Übung (z. B. Aufbau, Schwierigkeit, Material), mit Reihenfolge und optionaler **Voraussetzungsvariante**; Bearbeitung im Tab **Varianten**; Änderungen werden mit **Speichern** in der Aktionsleiste mitgesichert (oder „Variante anlegen“ für sofortiges Anlegen).
|
||||
- **Progressionsgraph:** gerichtete Beziehungen **von Übung zu Übung** (und Variantenbezug), Pflege in der Übungswelt; unterstützt didaktische „weiter“-Ketten.
|
||||
- **Medien an der Übung:** Upload, Einbettung, Verknüpfung aus dem **Archiv**; Darstellung in Detail- und Bearbeitungsansicht.
|
||||
- **Rich-Text-Felder** (Ablauf, Ziele, Hinweise): **Inline-Verweise auf verknüpfte Medien** über eine einheitliche Platzhalter-/Renderlogik (konsistent mit Archiv-Governance).
|
||||
|
|
@ -100,8 +101,12 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
|
|||
|
||||
### 4.7 Governance von Übungsinhalten
|
||||
|
||||
- **Freigabelevel** (`visibility` in der API): steuert, wer eine Übung **lesen** darf — Werte u. a. **privat**, **Verein** (`club`), **offiziell** (`official`). In der Oberfläche heißt das Feld durchgängig **Freigabelevel** (nicht „Sichtbarkeit“).
|
||||
- **Status:** Entwurf, in Prüfung, freigegeben, archiviert — konkrete Werte siehe Datenmodell.
|
||||
- **Owner:** Der anlegende Nutzer (`created_by`). Varianten haben keinen eigenen Owner; Rechte folgen der Eltern-Übung.
|
||||
- **Bearbeiten** (Stammdaten, Varianten, Medien): Ersteller; Plattform-Admin; bei Vereins-Übungen zusätzlich Planungs-/Inhaltsrollen im Objekt-Verein (Trainer, Content-Editor, Spartenleitung, Vereins-Admin).
|
||||
- **Löschen:** privat — Ersteller (Vereins-Admin im gemeinsamen Verein mit Ersteller); Verein — nur Vereins-Admin; offiziell — nur Plattform-Admin.
|
||||
- **Änderungsanfragen** (Content Change Requests) für vorgeschlagene Änderungen an Inhalten – Einreichung und Bearbeitung über Posteingang/Admin-Prozesse (Detailtiefe siehe Fachdoku).
|
||||
- **Sichtbarkeits- und Statusmodelle** für Übungen (Entwurf, veröffentlicht, archiviert – konkrete Werte siehe Datenmodell).
|
||||
|
||||
### 4.8 Inhaltsmeldungen (P-13, vertrauens- und compliance-orientiert)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-20
|
||||
**App-Version / DB-Schema:** App **`0.8.149`** (Einheiten-Editor Vollseite), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
|
||||
**Stand:** 2026-05-29
|
||||
**App-Version / DB-Schema:** App **`0.8.157`** (KI Übungen: UX-Flow + AI_DEBUG Logs), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
|
||||
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
|
||||
| **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
|
||||
| **Lieferliste inkl. Medien** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12 |
|
||||
| **Lieferliste inkl. Medien & Formular-UX** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §6, §16 |
|
||||
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
||||
|
||||
---
|
||||
|
|
@ -79,6 +79,24 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
- **Frontend:** KPI-Kacheln + Filter-Modal (UX wie Übungsliste) auf **`/planning/framework-programs`** und **`/planning/training-modules`**; Panels in Editoren; Discovery auf Fähigkeiten-Seite; `SkillTreeMultiSelect` mit Portal-Dropdown in Modals
|
||||
- **Offen (Backlog):** Corpus-Caching bei großen Bibliotheken; Tests für Typ-Trennung; Filter-Persistenz; Skill-Filter im Dialog „Rahmen übernehmen“; API-Umbenennung `club_*` → Peer-Namen
|
||||
|
||||
### 2.7 Übungsformular UX, Freigabelevel & Varianten-Speichern (Stand 2026-05-20)
|
||||
|
||||
- **Code:** `frontend/src/components/exercises/ExerciseFormPageRoot.jsx`, `ExerciseFormLayout.jsx`, `ExerciseCatalogAssocEditor.jsx`, `ExerciseSkillsEditor.jsx`, `frontend/src/constants/exerciseGovernanceLabels.js`, `exerciseSkillIntensity.js`
|
||||
- **Tab-Navigation:** Stammdaten | Anleitung | Einordnung | (Kombination) | Varianten | Medien & Mehr — `PageSectionNav` mit farbigen Panels (`.exercise-form-edit` in `app.css`); Varianten/Medien erst nach erstem Speichern aktiv; Kombi-Übungen ohne Varianten-Tab
|
||||
- **Freigabelevel:** UI-Label für `exercises.visibility` in Formular, Liste, Filter, Bulk, Picker — API-Feldname unverändert
|
||||
- **Fähigkeiten:** Intensität `niedrig`/`mittel`/`hoch` (Default `mittel`); kein Primär-Flag in UI; Backend setzt `exercise_skills.is_primary` immer `false`
|
||||
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
||||
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
||||
|
||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.157**)
|
||||
|
||||
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
||||
- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
|
||||
- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff)
|
||||
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas` — **Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`)
|
||||
- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang
|
||||
- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`**
|
||||
|
||||
---
|
||||
|
||||
## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
|
|||
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
||||
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
||||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
||||
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
||||
|
||||
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
||||
|
|
@ -300,6 +301,14 @@ const appRouter = createBrowserRouter([
|
|||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/ai-skill-retrieval',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminAiSkillRetrievalPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -90,9 +90,11 @@ export function buildExerciseApiPayload(formData, extras = {}) {
|
|||
age_groups: [],
|
||||
skills: (formData.skills || []).map((s) => ({
|
||||
skill_id: s.skill_id,
|
||||
is_primary: !!s.is_primary,
|
||||
intensity: normalizeExerciseSkillIntensity(s.intensity),
|
||||
required_level: s.required_level || null,
|
||||
target_level: s.target_level || null,
|
||||
ai_suggested: !!s.ai_suggested,
|
||||
})),
|
||||
visibility: visibilityNorm,
|
||||
status: formData.status || 'draft',
|
||||
|
|
@ -577,17 +579,24 @@ export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
|
|||
})
|
||||
}
|
||||
|
||||
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
|
||||
export async function suggestExerciseAi(payload) {
|
||||
/** KI (OpenRouter): Nur Vorschlaege; Speichern ueber normales exercise PUT/POST. */
|
||||
export async function suggestExerciseAi(payload = {}) {
|
||||
return request('/api/exercises/ai/suggest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
include_summary: true,
|
||||
include_skills: true,
|
||||
...payload,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function regenerateExerciseAi(exerciseId, payload) {
|
||||
export async function regenerateExerciseAi(exerciseId, payload = {}) {
|
||||
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
regenerate: ['summary', 'skills'],
|
||||
...payload,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale } from 'lucide-react'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
||||
|
|
@ -12,6 +12,7 @@ export default function AdminPageNav() {
|
|||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
||||
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
|
||||
{ to: '/admin/ai-skill-retrieval', label: 'KI Retrieval', icon: Brain },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import {
|
|||
buildExerciseMediaDragPayload,
|
||||
} from '../../utils/exerciseInlineMediaRefs'
|
||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import { stripHtmlToText } from '../../utils/htmlUtils'
|
||||
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
||||
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
|
|
@ -43,6 +44,7 @@ import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUn
|
|||
import {
|
||||
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
||||
normalizeExerciseSkillIntensity,
|
||||
formatExerciseSkillIntensityLabel,
|
||||
} from '../../constants/exerciseSkillIntensity'
|
||||
import {
|
||||
EXERCISE_VISIBILITY_CLUB_FIELD_LABEL,
|
||||
|
|
@ -71,6 +73,102 @@ const comboTinyNumberInputSx = {
|
|||
textAlign: 'center',
|
||||
}
|
||||
|
||||
function escapeHtmlText(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.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 cloneExerciseSkillRows(rows) {
|
||||
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
|
||||
}
|
||||
|
||||
function buildNormalizedAiSkillRowFromApi(sug) {
|
||||
const sid = Number(sug.skill_id)
|
||||
if (!Number.isFinite(sid)) return null
|
||||
return {
|
||||
skill_id: sid,
|
||||
intensity: normalizeExerciseSkillIntensity(sug.intensity),
|
||||
required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen',
|
||||
target_level:
|
||||
normalizeSkillLevelSlug(sug.target_level) ||
|
||||
normalizeSkillLevelSlug(sug.required_level) ||
|
||||
'grundlagen',
|
||||
is_primary: !!sug.is_primary,
|
||||
ai_suggested: true,
|
||||
}
|
||||
}
|
||||
|
||||
function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) {
|
||||
const summaryRequested = mode !== 'skills'
|
||||
const skillsRequested = mode !== 'summary'
|
||||
|
||||
let summaryAfterHtml = null
|
||||
let summaryAfterPlain = ''
|
||||
if (summaryRequested && apiRes.summary?.text) {
|
||||
summaryAfterPlain = String(apiRes.summary.text).trim()
|
||||
if (summaryAfterPlain) {
|
||||
summaryAfterHtml = aiPlainSummaryToMinimalHtml(apiRes.summary.text)
|
||||
}
|
||||
}
|
||||
|
||||
const skillChoices = []
|
||||
if (skillsRequested && Array.isArray(apiRes.skills)) {
|
||||
for (const sug of apiRes.skills) {
|
||||
const after = buildNormalizedAiSkillRowFromApi(sug)
|
||||
if (!after) continue
|
||||
const ix = snapshotSkills.findIndex((s) => Number(s.skill_id) === after.skill_id)
|
||||
const before = ix >= 0 ? { ...snapshotSkills[ix] } : null
|
||||
skillChoices.push({
|
||||
key: String(after.skill_id),
|
||||
skill_id: after.skill_id,
|
||||
kind: before ? 'update' : 'add',
|
||||
before,
|
||||
after,
|
||||
include: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
|
||||
const hasSkillChoices = skillChoices.length > 0
|
||||
|
||||
return {
|
||||
mode,
|
||||
applySummary: hasSummaryProposal,
|
||||
summaryBeforePlain: stripHtmlToText(snapshotSummaryHtml || '').trim(),
|
||||
summaryAfterPlain,
|
||||
summaryAfterHtml,
|
||||
skillChoices,
|
||||
hasSummaryProposal,
|
||||
hasSkillChoices,
|
||||
summaryRequested,
|
||||
skillsRequested,
|
||||
}
|
||||
}
|
||||
|
||||
function describeExerciseSkillRowForPreview(row, skillsCatalog) {
|
||||
if (!row) return ''
|
||||
const sk = skillsCatalog.find((x) => Number(x.id) === Number(row.skill_id))
|
||||
const name = sk?.name || `Fähigkeit #${row.skill_id}`
|
||||
const int = formatExerciseSkillIntensityLabel(row.intensity)
|
||||
const from = formatSkillLevelSlug(row.required_level) || '—'
|
||||
const to = formatSkillLevelSlug(row.target_level) || '—'
|
||||
const prim = row.is_primary ? ' · Primär' : ''
|
||||
return `${name}: Intensität ${int}, Niveau ${from} → ${to}${prim}`
|
||||
}
|
||||
|
||||
function emptyComboSlotRow() {
|
||||
return {
|
||||
title: '',
|
||||
|
|
@ -417,6 +515,8 @@ function detailToForm(exercise) {
|
|||
intensity: normalizeExerciseSkillIntensity(s.intensity),
|
||||
required_level: normalizeSkillLevelSlug(s.required_level),
|
||||
target_level: normalizeSkillLevelSlug(s.target_level),
|
||||
is_primary: !!s.is_primary,
|
||||
ai_suggested: !!s.ai_suggested,
|
||||
})) || [],
|
||||
exercise_kind:
|
||||
String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
|
||||
|
|
@ -503,6 +603,8 @@ function ExerciseFormPageRoot() {
|
|||
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
|
||||
const [variantSavingId, setVariantSavingId] = useState(null)
|
||||
const [variantBusy, setVariantBusy] = useState(false)
|
||||
const [aiSuggestBusy, setAiSuggestBusy] = useState(false)
|
||||
const [aiSuggestionPreview, setAiSuggestionPreview] = useState(null)
|
||||
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
||||
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
|
||||
const variantsSavedSnapshotRef = useRef({})
|
||||
|
|
@ -855,6 +957,122 @@ function ExerciseFormPageRoot() {
|
|||
)
|
||||
}
|
||||
|
||||
const runExerciseAiSuggestion = async (mode) => {
|
||||
const gPlain = stripHtmlToText(formData.goal || '').trim()
|
||||
const ePlain = stripHtmlToText(formData.execution || '').trim()
|
||||
if (!gPlain && !ePlain) {
|
||||
toast.error('Ziel oder Durchführung ausfüllen — die KI benötigt Kontext.')
|
||||
return
|
||||
}
|
||||
|
||||
const summaryOn = mode !== 'skills'
|
||||
const skillsOn = mode !== 'summary'
|
||||
|
||||
const focusHint = (formData.focus_areas_multi || [])
|
||||
.map((row) => {
|
||||
const id = row?.focus_area_id
|
||||
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
|
||||
return (fa?.name || '').trim()
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
|
||||
const snapshotSummaryHtml = formData.summary || ''
|
||||
const snapshotSkills = cloneExerciseSkillRows(formData.skills)
|
||||
|
||||
const focusAreasContext = [...(formData.focus_areas_multi || [])]
|
||||
.map((row) => ({
|
||||
focus_area_id: Number(row?.focus_area_id),
|
||||
is_primary: !!row?.is_primary,
|
||||
}))
|
||||
.filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1)
|
||||
.sort((a, b) => {
|
||||
const p = Number(!!b.is_primary) - Number(!!a.is_primary)
|
||||
if (p !== 0) return p
|
||||
return a.focus_area_id - b.focus_area_id
|
||||
})
|
||||
|
||||
/* Vor jedem neuen Aufruf: Vorschau schließen; sonst bleiben die KI-Buttons wegen Modal-Zustand dauerhaft deaktiviert. */
|
||||
setAiSuggestionPreview(null)
|
||||
setAiSuggestBusy(true)
|
||||
try {
|
||||
const res = await api.suggestExerciseAi({
|
||||
title: (formData.title || '').trim(),
|
||||
goal: formData.goal || '',
|
||||
execution: formData.execution || '',
|
||||
focus_area_hint: focusHint || undefined,
|
||||
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
|
||||
include_summary: summaryOn,
|
||||
include_skills: skillsOn,
|
||||
})
|
||||
|
||||
const preview = buildExerciseAiSuggestionPreview({
|
||||
mode,
|
||||
snapshotSummaryHtml,
|
||||
snapshotSkills,
|
||||
apiRes: res,
|
||||
})
|
||||
|
||||
const hasSomething = preview.hasSummaryProposal || preview.hasSkillChoices
|
||||
if (!hasSomething) {
|
||||
toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.')
|
||||
return
|
||||
}
|
||||
|
||||
setAiSuggestionPreview(preview)
|
||||
} catch (err) {
|
||||
toast.error(err?.message || String(err))
|
||||
} finally {
|
||||
setAiSuggestBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyExerciseAiSuggestionPreview = () => {
|
||||
const p = aiSuggestionPreview
|
||||
if (!p) return
|
||||
const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
|
||||
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
|
||||
|
||||
if (!takeSummary && skillsToMerge.length === 0) {
|
||||
toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.')
|
||||
return
|
||||
}
|
||||
|
||||
if (takeSummary) {
|
||||
updateFormField('summary', p.summaryAfterHtml)
|
||||
}
|
||||
if (skillsToMerge.length > 0) {
|
||||
setFormDirty(true)
|
||||
setFormData((prev) => {
|
||||
const next = [...(prev.skills || [])]
|
||||
for (const row of skillsToMerge) {
|
||||
const sid = Number(row.skill_id)
|
||||
const ix = next.findIndex((s) => Number(s.skill_id) === sid)
|
||||
if (ix >= 0) next[ix] = { ...next[ix], ...row }
|
||||
else next.push(row)
|
||||
}
|
||||
return { ...prev, skills: next }
|
||||
})
|
||||
}
|
||||
|
||||
toast.success('Ausgewählte KI-Vorschläge übernommen — bitte prüfen und speichern.')
|
||||
setAiSuggestionPreview(null)
|
||||
}
|
||||
|
||||
const discardExerciseAiSuggestionPreview = () => setAiSuggestionPreview(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiSuggestionPreview) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setAiSuggestionPreview(null)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [aiSuggestionPreview])
|
||||
|
||||
const refreshVariants = useCallback(async () => {
|
||||
if (!exerciseId) return
|
||||
const ex = await api.getExercise(exerciseId)
|
||||
|
|
@ -1309,7 +1527,28 @@ function ExerciseFormPageRoot() {
|
|||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Kurzbeschreibung</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label className="form-label" style={{ marginBottom: 0 }}>
|
||||
Kurzbeschreibung
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
onClick={() => runExerciseAiSuggestion('summary')}
|
||||
>
|
||||
KI: Kurzfassung
|
||||
</button>
|
||||
</div>
|
||||
<RichTextEditor
|
||||
value={formData.summary}
|
||||
onChange={(html) => updateFormField('summary', html)}
|
||||
|
|
@ -1966,6 +2205,42 @@ function ExerciseFormPageRoot() {
|
|||
title="Einordnung"
|
||||
hint="Fokus, Stile, Zielgruppen und Fähigkeiten für Suche, Filter und Skill-Profil."
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
marginBottom: '12px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
onClick={() => runExerciseAiSuggestion('skills')}
|
||||
>
|
||||
KI: Fähigkeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
onClick={() => runExerciseAiSuggestion('both')}
|
||||
>
|
||||
KI: Kurzfassung und Fähigkeiten
|
||||
</button>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||
Benötigt Ziel oder Durchführung sowie optional{' '}
|
||||
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('anleitung')}>
|
||||
Anleitung
|
||||
</button>
|
||||
· Es öffnet ein Dialogfeld mit Vorschau; Übernahme wählweise pro Teil. Speichern nur über die Aktionsleiste.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="exercise-form-meta-panel" aria-label="Klassifikation">
|
||||
<div className="exercise-form-meta-panel__grid">
|
||||
<ExerciseCatalogAssocEditor
|
||||
|
|
@ -2490,6 +2765,259 @@ function ExerciseFormPageRoot() {
|
|||
</form>
|
||||
</div>
|
||||
|
||||
{aiSuggestionPreview &&
|
||||
(() => {
|
||||
const p = aiSuggestionPreview
|
||||
const summaryBoxSx = {
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.45,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
minHeight: '72px',
|
||||
}
|
||||
const canApplySomething =
|
||||
(p.applySummary && p.summaryAfterHtml) || p.skillChoices.some((c) => c.include)
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="KI-Vorschlag prüfen"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 1001,
|
||||
overflow: 'auto',
|
||||
padding: '16px',
|
||||
}}
|
||||
onClick={() => discardExerciseAiSuggestionPreview()}
|
||||
onKeyDown={(e) => e.key === 'Escape' && discardExerciseAiSuggestionPreview()}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
maxWidth: 760,
|
||||
margin: '3vh auto',
|
||||
maxHeight: '92vh',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>KI-Vorschlag übernehmen</h3>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
|
||||
Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.
|
||||
</p>
|
||||
|
||||
{p.hasSummaryProposal ? (
|
||||
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
|
||||
<div
|
||||
id="ai-preview-summary-heading"
|
||||
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
|
||||
>
|
||||
Kurzfassung
|
||||
</div>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={p.applySummary}
|
||||
onChange={(e) =>
|
||||
setAiSuggestionPreview((prev) =>
|
||||
prev ? { ...prev, applySummary: e.target.checked } : prev,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Kurzfassung durch Vorschlag ersetzen (bestehende Kurzbeschreibung wird überschrieben)
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
|
||||
Aktuell (ohne Formatierung)
|
||||
</div>
|
||||
<div style={summaryBoxSx}>{p.summaryBeforePlain || '(leer)'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>KI-Vorschlag</div>
|
||||
<div style={{ ...summaryBoxSx, borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))' }}>
|
||||
{p.summaryAfterPlain || '(leer)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{p.skillsRequested ? (
|
||||
<section aria-labelledby="ai-preview-skills-heading">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
<div id="ai-preview-skills-heading" style={{ fontWeight: 600, fontSize: '0.95rem' }}>
|
||||
Fähigkeiten ({p.skillChoices.length}
|
||||
{p.skillChoices.length === 1 ? ' Vorschlag' : ' Vorschläge'})
|
||||
</div>
|
||||
{p.skillChoices.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() =>
|
||||
setAiSuggestionPreview((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: true })),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
>
|
||||
Alle auswählen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() =>
|
||||
setAiSuggestionPreview((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: false })),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
>
|
||||
Alle abwählen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{p.skillChoices.length === 0 ? (
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)', margin: 0 }}>
|
||||
Keine passenden Fähigkeiten — der Katalog-Vorschlag war leer oder enthielt nur ungültige IDs.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{p.skillChoices.map((c) => (
|
||||
<li
|
||||
key={c.key}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 12px',
|
||||
marginBottom: '10px',
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c.include}
|
||||
onChange={() =>
|
||||
setAiSuggestionPreview((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
skillChoices: prev.skillChoices.map((x) =>
|
||||
x.skill_id === c.skill_id ? { ...x, include: !x.include } : x,
|
||||
),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
style={{ marginTop: '4px' }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '13px', marginBottom: '6px' }}>
|
||||
{c.kind === 'add' ? 'Neu hinzufügen' : 'Bestehende Zeile aktualisieren'}
|
||||
</div>
|
||||
{c.kind === 'update' && c.before ? (
|
||||
<div style={{ fontSize: '12px', lineHeight: 1.5 }}>
|
||||
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Bisher</div>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
{describeExerciseSkillRowForPreview(c.before, skillsCatalog)}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Nach KI-Vorschlag</div>
|
||||
<div>{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '13px', lineHeight: 1.5 }}>
|
||||
{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
paddingTop: '14px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<button type="button" className="btn btn-secondary" onClick={discardExerciseAiSuggestionPreview}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!canApplySomething}
|
||||
onClick={() => applyExerciseAiSuggestionPreview()}
|
||||
>
|
||||
Ausgewähltes übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<ExercisePickerModal
|
||||
open={comboStationPickerIx !== null}
|
||||
onClose={() => setComboStationPickerIx(null)}
|
||||
|
|
@ -2504,11 +3032,9 @@ function ExerciseFormPageRoot() {
|
|||
/>
|
||||
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
||||
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
|
||||
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}
|
||||
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> — z. B.{' '}
|
||||
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
|
||||
<code>api.suggestExerciseAi</code>).
|
||||
<strong>KI-Unterstützung:</strong> OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
|
||||
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
|
||||
wie gewohnt.
|
||||
</p>
|
||||
<UnsavedChangesPrompt
|
||||
blocker={blocker}
|
||||
|
|
|
|||
704
frontend/src/pages/AdminAiSkillRetrievalPage.jsx
Normal file
704
frontend/src/pages/AdminAiSkillRetrievalPage.jsx
Normal file
|
|
@ -0,0 +1,704 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
|
||||
function formatDt(iso) {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return String(iso)
|
||||
return d.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||
} catch {
|
||||
return String(iso)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bestehende Zusatzfelder aus der DB übernehmen (keine eigene Pflegeoberfläche). */
|
||||
function configExtrasFromRow(cfg) {
|
||||
const c = cfg && typeof cfg === 'object' ? cfg : {}
|
||||
return {
|
||||
keyword_overrides: Array.isArray(c.keyword_overrides) ? c.keyword_overrides : [],
|
||||
main_min_share: c.main_min_share && typeof c.main_min_share === 'object' ? { ...c.main_min_share } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function buildRetrievalDraft(cfg, mainList, subList) {
|
||||
const c = cfg && typeof cfg === 'object' ? cfg : {}
|
||||
const mainW = c.main_slug_weights || {}
|
||||
const catW = c.category_slug_weights || {}
|
||||
const catCap = c.category_max_share || {}
|
||||
|
||||
let mains = (mainList || [])
|
||||
.map((m) => {
|
||||
const slug = String(m.slug || '').trim()
|
||||
if (!slug) return null
|
||||
const w = mainW[slug]
|
||||
return {
|
||||
slug,
|
||||
name: String(m.name || '').trim() || slug,
|
||||
weight: w != null && w !== '' ? String(w) : '1',
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (mains.length === 0) {
|
||||
mains = ['karate', 'allgemeine'].map((slug) => ({
|
||||
slug,
|
||||
name: slug,
|
||||
weight: mainW[slug] != null && mainW[slug] !== '' ? String(mainW[slug]) : '1',
|
||||
}))
|
||||
}
|
||||
|
||||
const categories = (subList || [])
|
||||
.map((sc) => {
|
||||
const slug = String(sc.slug || '').trim()
|
||||
if (!slug) return null
|
||||
const capNum = catCap[slug]
|
||||
let maxSharePct = ''
|
||||
if (capNum != null && capNum !== '') {
|
||||
const v = Number(capNum)
|
||||
if (Number.isFinite(v) && v > 0 && v <= 1) {
|
||||
maxSharePct = String(Math.round(v * 1000) / 10)
|
||||
} else if (Number.isFinite(v) && v > 1 && v <= 100) {
|
||||
maxSharePct = String(v)
|
||||
}
|
||||
}
|
||||
const w = catW[slug]
|
||||
return {
|
||||
slug,
|
||||
label: [sc.main_category_name, sc.name].filter(Boolean).join(' · ') || sc.name || slug,
|
||||
weight: w != null && w !== '' ? String(w) : '',
|
||||
maxSharePct,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'de'))
|
||||
|
||||
return {
|
||||
importanceMultiplier: c.importance_multiplier != null ? String(c.importance_multiplier) : '1',
|
||||
textOverlapBonus: c.text_overlap_bonus != null ? String(c.text_overlap_bonus) : '2',
|
||||
descMaxLen: c.description_plain_max_len != null ? String(c.description_plain_max_len) : '160',
|
||||
karateRelMaxLen: c.karate_relevance_max_len != null ? String(c.karate_relevance_max_len) : '72',
|
||||
mains,
|
||||
categories,
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfigPayload(extras, draft) {
|
||||
const next = {
|
||||
version: 1,
|
||||
importance_multiplier: parseFloat(String(draft.importanceMultiplier).replace(',', '.')) || 1,
|
||||
text_overlap_bonus: parseFloat(String(draft.textOverlapBonus).replace(',', '.')) || 2,
|
||||
description_plain_max_len: Math.max(
|
||||
40,
|
||||
Math.min(400, parseInt(String(draft.descMaxLen), 10) || 160),
|
||||
),
|
||||
karate_relevance_max_len: Math.max(
|
||||
0,
|
||||
Math.min(280, parseInt(String(draft.karateRelMaxLen), 10) || 0),
|
||||
),
|
||||
main_slug_weights: {},
|
||||
category_slug_weights: {},
|
||||
category_max_share: {},
|
||||
keyword_overrides: Array.isArray(extras.keyword_overrides) ? extras.keyword_overrides : [],
|
||||
main_min_share:
|
||||
extras.main_min_share && typeof extras.main_min_share === 'object' ? { ...extras.main_min_share } : {},
|
||||
}
|
||||
|
||||
for (const m of draft.mains || []) {
|
||||
if (!m.slug) continue
|
||||
const w = parseFloat(String(m.weight).replace(',', '.'))
|
||||
if (Number.isFinite(w) && w > 0) next.main_slug_weights[m.slug] = w
|
||||
}
|
||||
for (const slug of ['karate', 'allgemeine']) {
|
||||
if (next.main_slug_weights[slug] == null) next.main_slug_weights[slug] = 1
|
||||
}
|
||||
|
||||
for (const row of draft.categories || []) {
|
||||
if (!row.slug) continue
|
||||
const w = parseFloat(String(row.weight).replace(',', '.'))
|
||||
if (Number.isFinite(w) && w > 0 && w !== 1) next.category_slug_weights[row.slug] = w
|
||||
|
||||
const capRaw = String(row.maxSharePct || '').trim().replace(',', '.')
|
||||
if (capRaw) {
|
||||
const v = parseFloat(capRaw)
|
||||
if (Number.isFinite(v) && v > 0) {
|
||||
const frac = v > 1 ? v / 100 : v
|
||||
if (frac > 0 && frac <= 1) next.category_max_share[row.slug] = frac
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Pflege von ai_skill_retrieval_profiles — Gewichte pro Haupt- und Unterkategorie ohne JSON-Bearbeitung.
|
||||
* Nur Superadmin (PlatformAdminRoute).
|
||||
*/
|
||||
export default function AdminAiSkillRetrievalPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const [profiles, setProfiles] = useState([])
|
||||
const [focusAreas, setFocusAreas] = useState([])
|
||||
const [skillMainCatalog, setSkillMainCatalog] = useState([])
|
||||
const [skillSubCatalog, setSkillSubCatalog] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [editor, setEditor] = useState(null)
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
const [p, faRaw, mc, sc] = await Promise.all([
|
||||
api.listAiSkillRetrievalProfiles(),
|
||||
api.listFocusAreas(),
|
||||
api.listSkillMainCategories(),
|
||||
api.listSkillCategories({ status: 'active' }),
|
||||
])
|
||||
setProfiles(Array.isArray(p) ? p : [])
|
||||
const fa = Array.isArray(faRaw) ? faRaw : []
|
||||
setFocusAreas(fa.filter((a) => !a.status || String(a.status).toLowerCase() === 'active'))
|
||||
setSkillMainCatalog(Array.isArray(mc) ? mc : [])
|
||||
setSkillSubCatalog(Array.isArray(sc) ? sc : [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, loadAll])
|
||||
|
||||
const openNew = () => {
|
||||
setEditor({
|
||||
mode: 'new',
|
||||
isDefault: false,
|
||||
focusAreaId: '',
|
||||
name: '',
|
||||
description: '',
|
||||
active: true,
|
||||
configExtras: { keyword_overrides: [], main_min_share: {} },
|
||||
retrieval: buildRetrievalDraft({}, skillMainCatalog, skillSubCatalog),
|
||||
})
|
||||
}
|
||||
|
||||
const openEdit = (row) => {
|
||||
const cfg = row.config && typeof row.config === 'object' ? row.config : {}
|
||||
setEditor({
|
||||
mode: 'edit',
|
||||
id: row.id,
|
||||
originalIsDefault: !!row.is_default,
|
||||
isDefault: !!row.is_default,
|
||||
promoteToDefault: false,
|
||||
focusAreaId: row.focus_area_id != null ? String(row.focus_area_id) : '',
|
||||
name: row.name || '',
|
||||
description: row.description || '',
|
||||
active: !!row.active,
|
||||
configExtras: configExtrasFromRow(cfg),
|
||||
retrieval: buildRetrievalDraft(cfg, skillMainCatalog, skillSubCatalog),
|
||||
})
|
||||
}
|
||||
|
||||
const closeEditor = () => setEditor(null)
|
||||
|
||||
const save = async () => {
|
||||
if (!editor) return
|
||||
const cfg = buildConfigPayload(editor.configExtras, editor.retrieval)
|
||||
|
||||
const name = editor.name.trim()
|
||||
if (name.length < 2) {
|
||||
alert('Name mindestens 2 Zeichen.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (editor.mode === 'new') {
|
||||
const body = {
|
||||
name,
|
||||
description: (editor.description || '').trim(),
|
||||
active: editor.active,
|
||||
is_default: editor.isDefault,
|
||||
config: cfg,
|
||||
}
|
||||
if (!editor.isDefault) {
|
||||
const fid = parseInt(editor.focusAreaId, 10)
|
||||
if (!fid) {
|
||||
alert('Fokusbereich wählen (oder „Standardprofil“ aktivieren).')
|
||||
return
|
||||
}
|
||||
body.focus_area_id = fid
|
||||
}
|
||||
await api.createAiSkillRetrievalProfile(body)
|
||||
} else {
|
||||
const body = {
|
||||
name,
|
||||
description: (editor.description || '').trim(),
|
||||
active: editor.active,
|
||||
config: cfg,
|
||||
}
|
||||
if (editor.originalIsDefault) {
|
||||
await api.updateAiSkillRetrievalProfile(editor.id, body)
|
||||
} else {
|
||||
if (editor.promoteToDefault) {
|
||||
body.is_default = true
|
||||
} else {
|
||||
const fid = parseInt(editor.focusAreaId, 10)
|
||||
if (!fid) {
|
||||
alert('Fokusbereich wählen oder „Als Standardprofil setzen“ aktivieren.')
|
||||
return
|
||||
}
|
||||
body.focus_area_id = fid
|
||||
}
|
||||
await api.updateAiSkillRetrievalProfile(editor.id, body)
|
||||
}
|
||||
}
|
||||
closeEditor()
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (row) => {
|
||||
if (row.is_default) return
|
||||
if (!confirm(`Profil „${row.name}“ wirklich löschen?`)) return
|
||||
try {
|
||||
await api.deleteAiSkillRetrievalProfile(row.id)
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSuperadmin) return <Navigate to="/" replace />
|
||||
|
||||
const retrieval = editor?.retrieval
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<AdminPageNav />
|
||||
|
||||
<h1 style={{ marginTop: 0 }}>KI Skill-Retrieval-Profile</h1>
|
||||
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1rem' }}>
|
||||
Gewichtungen für den Skill-Katalog bei der <strong>Übungs-KI</strong> (OpenRouter).{' '}
|
||||
<strong>Unterkategorien</strong> (z. B. Kondition, Selbstverteidigung): Multiplikatoren optional;{' '}
|
||||
<strong>max. Anteil</strong> begrenzt den Listenanteil dieser Kategorie (1–100 %). Hauptgruppen
|
||||
(Karate / Allgemein) separat einstellbar — siehe auch{' '}
|
||||
<span style={{ fontSize: '0.88em' }}>.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md</span>.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||||
<button type="button" className="btn btn-primary" onClick={openNew}>
|
||||
Neues Profil
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => loadAll().catch((e) => setError(e.message))}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="card" style={{ padding: '12px 16px', marginBottom: '1rem', borderColor: 'var(--danger)' }}>
|
||||
<strong style={{ color: 'var(--danger)' }}>Fehler</strong>
|
||||
<div style={{ marginTop: '0.35rem', whiteSpace: 'pre-wrap' }}>{error}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="spinner" style={{ margin: '2rem auto' }} />
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.92rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '10px 12px' }}>Name</th>
|
||||
<th style={{ padding: '10px 12px' }}>Typ</th>
|
||||
<th style={{ padding: '10px 12px' }}>Fokusbereich</th>
|
||||
<th style={{ padding: '10px 12px' }}>Aktiv</th>
|
||||
<th style={{ padding: '10px 12px' }}>Geändert</th>
|
||||
<th style={{ padding: '10px 12px', width: '1%' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{profiles.map((row) => (
|
||||
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '10px 12px', fontWeight: 600 }}>{row.name}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{row.is_default ? 'Standard' : 'Fokus'}</td>
|
||||
<td style={{ padding: '10px 12px', color: 'var(--text2)' }}>
|
||||
{row.is_default ? '—' : row.focus_area_name || `(ID ${row.focus_area_id})`}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>{row.active ? 'ja' : 'nein'}</td>
|
||||
<td style={{ padding: '10px 12px', color: 'var(--text3)' }}>{formatDt(row.updated_at)}</td>
|
||||
<td style={{ padding: '10px 12px', whiteSpace: 'nowrap' }}>
|
||||
<button type="button" className="btn btn-secondary" style={{ marginRight: 6 }} onClick={() => openEdit(row)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{!row.is_default ? (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => remove(row)}>
|
||||
Löschen
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{profiles.length === 0 ? (
|
||||
<div style={{ padding: '1.25rem', color: 'var(--text3)' }}>Keine Einträge (oder Tabelle/Migration 068 fehlt).</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editor && retrieval && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1200,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
maxWidth: '920px',
|
||||
width: '100%',
|
||||
maxHeight: '92vh',
|
||||
overflow: 'auto',
|
||||
padding: '1.25rem 1.5rem',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0 }}>{editor.mode === 'new' ? 'Neues Profil' : 'Profil bearbeiten'}</h2>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="arp-name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="arp-name"
|
||||
className="form-input"
|
||||
value={editor.name}
|
||||
onChange={(e) => setEditor((ed) => (ed ? { ...ed, name: e.target.value } : ed))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="arp-desc">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="arp-desc"
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={editor.description}
|
||||
onChange={(e) => setEditor((ed) => (ed ? { ...ed, description: e.target.value } : ed))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editor.mode === 'new' ? (
|
||||
<div className="form-row">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editor.isDefault}
|
||||
onChange={(e) =>
|
||||
setEditor((ed) =>
|
||||
ed ? { ...ed, isDefault: e.target.checked, focusAreaId: e.target.checked ? '' : ed.focusAreaId } : ed
|
||||
)
|
||||
}
|
||||
/>
|
||||
Standardprofil (ohne Fokusbereich)
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editor.mode === 'edit' && editor.originalIsDefault ? (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
|
||||
Standardprofil: Fokusbereich ist immer leer; Deaktivieren ist nicht erlaubt.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{editor.mode === 'edit' && !editor.originalIsDefault ? (
|
||||
<div className="form-row">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!editor.promoteToDefault}
|
||||
onChange={(e) =>
|
||||
setEditor((ed) =>
|
||||
ed ? { ...ed, promoteToDefault: e.target.checked, focusAreaId: e.target.checked ? '' : ed.focusAreaId } : ed
|
||||
)
|
||||
}
|
||||
/>
|
||||
Als Standardprofil setzen (andere Standard-Markierung wird entfernt)
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!editor.isDefault && editor.mode === 'new' ? (
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="arp-focus">
|
||||
Fokusbereich
|
||||
</label>
|
||||
<select
|
||||
id="arp-focus"
|
||||
className="form-input"
|
||||
value={editor.focusAreaId}
|
||||
onChange={(e) => setEditor((ed) => (ed ? { ...ed, focusAreaId: e.target.value } : ed))}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{focusAreas.map((fa) => (
|
||||
<option key={fa.id} value={String(fa.id)}>
|
||||
{fa.name || `Fokus ${fa.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editor.mode === 'edit' && !editor.originalIsDefault && !editor.promoteToDefault ? (
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="arp-focus-e">
|
||||
Fokusbereich
|
||||
</label>
|
||||
<select
|
||||
id="arp-focus-e"
|
||||
className="form-input"
|
||||
value={editor.focusAreaId}
|
||||
onChange={(e) => setEditor((ed) => (ed ? { ...ed, focusAreaId: e.target.value } : ed))}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{focusAreas.map((fa) => (
|
||||
<option key={fa.id} value={String(fa.id)}>
|
||||
{fa.name || `Fokus ${fa.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="form-row">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editor.active}
|
||||
disabled={editor.mode === 'edit' && editor.originalIsDefault}
|
||||
onChange={(e) => setEditor((ed) => (ed ? { ...ed, active: e.target.checked } : ed))}
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1.25rem 0' }} />
|
||||
|
||||
<h3 style={{ margin: '0 0 0.75rem', fontSize: '1rem' }}>Globale Ranking-Parameter</h3>
|
||||
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="arp-imp">
|
||||
importance_multiplier
|
||||
</label>
|
||||
<input
|
||||
id="arp-imp"
|
||||
className="form-input"
|
||||
inputMode="decimal"
|
||||
value={retrieval.importanceMultiplier}
|
||||
onChange={(e) =>
|
||||
setEditor((ed) =>
|
||||
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, importanceMultiplier: e.target.value } } : ed
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="arp-tob">
|
||||
text_overlap_bonus
|
||||
</label>
|
||||
<input
|
||||
id="arp-tob"
|
||||
className="form-input"
|
||||
inputMode="decimal"
|
||||
value={retrieval.textOverlapBonus}
|
||||
onChange={(e) =>
|
||||
setEditor((ed) =>
|
||||
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, textOverlapBonus: e.target.value } } : ed
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="arp-dmax">
|
||||
Beschreibung max. Zeichen (je Zeile im Katalog)
|
||||
</label>
|
||||
<input
|
||||
id="arp-dmax"
|
||||
className="form-input"
|
||||
inputMode="numeric"
|
||||
value={retrieval.descMaxLen}
|
||||
onChange={(e) =>
|
||||
setEditor((ed) =>
|
||||
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, descMaxLen: e.target.value } } : ed
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="arp-krm">
|
||||
Karate-Relevanz max. Zeichen (0 = weglassen)
|
||||
</label>
|
||||
<input
|
||||
id="arp-krm"
|
||||
className="form-input"
|
||||
inputMode="numeric"
|
||||
value={retrieval.karateRelMaxLen}
|
||||
onChange={(e) =>
|
||||
setEditor((ed) =>
|
||||
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, karateRelMaxLen: e.target.value } } : ed
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ margin: '1.25rem 0 0.5rem', fontSize: '1rem' }}>Hauptkategorien (Multiplikatoren)</h3>
|
||||
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Bezeichnung</th>
|
||||
<th style={{ padding: '8px 10px', width: '30%' }}>Multiplikator</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{retrieval.mains.map((m) => (
|
||||
<tr key={m.slug} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px' }}>
|
||||
<div style={{ fontWeight: 600 }}>{m.name}</div>
|
||||
<div style={{ color: 'var(--text3)', fontSize: '0.8rem' }}>{m.slug}</div>
|
||||
</td>
|
||||
<td style={{ padding: '8px 10px' }}>
|
||||
<input
|
||||
className="form-input"
|
||||
inputMode="decimal"
|
||||
value={m.weight}
|
||||
aria-label={`Gewicht ${m.slug}`}
|
||||
onChange={(e) =>
|
||||
setEditor((ed) => {
|
||||
if (!ed?.retrieval) return ed
|
||||
const nm = ed.retrieval.mains.map((row) =>
|
||||
row.slug === m.slug ? { ...row, weight: e.target.value } : row
|
||||
)
|
||||
return { ...ed, retrieval: { ...ed.retrieval, mains: nm } }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 style={{ margin: '1.25rem 0 0.5rem', fontSize: '1rem' }}>
|
||||
Unterkategorien (optional: Gewicht · max. Listenanteil)
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 0.5rem', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
Gewicht leer oder 1 = neutral. „Max. Anteil“ in Prozent der Katalogzeilen für diese Unterkategorie
|
||||
(z. B. 25 für 25 %).
|
||||
</p>
|
||||
<div style={{ maxHeight: '280px', overflow: 'auto', border: '1px solid var(--border)', borderRadius: '8px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: 'var(--surface2)', zIndex: 1 }}>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Unterkategorie</th>
|
||||
<th style={{ padding: '8px 10px', width: '110px' }}>Gewicht</th>
|
||||
<th style={{ padding: '8px 10px', width: '120px' }}>Max. %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{retrieval.categories.map((c) => (
|
||||
<tr key={c.slug} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 10px', verticalAlign: 'middle' }}>
|
||||
<span title={c.slug}>{c.label}</span>
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', verticalAlign: 'middle' }}>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="1"
|
||||
inputMode="decimal"
|
||||
value={c.weight}
|
||||
aria-label={`Gewicht ${c.slug}`}
|
||||
onChange={(e) =>
|
||||
setEditor((ed) => {
|
||||
if (!ed?.retrieval) return ed
|
||||
const nc = ed.retrieval.categories.map((row) =>
|
||||
row.slug === c.slug ? { ...row, weight: e.target.value } : row
|
||||
)
|
||||
return { ...ed, retrieval: { ...ed.retrieval, categories: nc } }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', verticalAlign: 'middle' }}>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="—"
|
||||
inputMode="decimal"
|
||||
value={c.maxSharePct}
|
||||
aria-label={`Max. Anteil ${c.slug}`}
|
||||
onChange={(e) =>
|
||||
setEditor((ed) => {
|
||||
if (!ed?.retrieval) return ed
|
||||
const nc = ed.retrieval.categories.map((row) =>
|
||||
row.slug === c.slug ? { ...row, maxSharePct: e.target.value } : row
|
||||
)
|
||||
return { ...ed, retrieval: { ...ed.retrieval, categories: nc } }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: '1rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
Hinweis: <strong>Keyword-Overrides</strong> und etwaige <strong>main_min_share</strong>-Felder aus der Datenbank werden
|
||||
beim Speichern mitgeschrieben ({editor.configExtras?.keyword_overrides?.length || 0} Overrides). Es gibt dafür
|
||||
keine separate Maske.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-primary" style={{ flex: '1 1 120px' }} onClick={save}>
|
||||
Speichern
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={closeEditor}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -361,6 +361,33 @@ export async function getAdminHierarchy() {
|
|||
return request('/api/admin/hierarchy')
|
||||
}
|
||||
|
||||
// Superadmin: KI Skill-Retrieval-Profile (Migration 068, exercise_ai)
|
||||
export async function listAiSkillRetrievalProfiles() {
|
||||
return request('/api/admin/ai-skill-retrieval-profiles')
|
||||
}
|
||||
|
||||
export async function getAiSkillRetrievalProfile(profileId) {
|
||||
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`)
|
||||
}
|
||||
|
||||
export async function createAiSkillRetrievalProfile(data) {
|
||||
return request('/api/admin/ai-skill-retrieval-profiles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateAiSkillRetrievalProfile(profileId, data) {
|
||||
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteAiSkillRetrievalProfile(profileId) {
|
||||
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reifegradmodelle / Fähigkeitsmatrix
|
||||
// ============================================================================
|
||||
|
|
@ -764,6 +791,11 @@ export const api = {
|
|||
updateFocusArea,
|
||||
deleteFocusArea,
|
||||
getAdminHierarchy,
|
||||
listAiSkillRetrievalProfiles,
|
||||
getAiSkillRetrievalProfile,
|
||||
createAiSkillRetrievalProfile,
|
||||
updateAiSkillRetrievalProfile,
|
||||
deleteAiSkillRetrievalProfile,
|
||||
listStyleDirections,
|
||||
listTrainingStyles,
|
||||
createStyleDirection,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user