From e4451e1362dfe68f0cb08f6acebecc589678286a Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 07:52:31 +0200 Subject: [PATCH 01/10] Enhance Exercise Management and AI Integration - Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr. - Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components. - Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes. - Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management. - Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively. --- .claude/docs/PROJECT_STATUS.md | 4 +- .../AI_EXERCISE_ASSISTANT_VISION.md | 100 ++++++ .claude/docs/functional/DOMAIN_MODEL.md | 2 +- .../docs/functional/SHINKAN_REQUIREMENTS.md | 2 + .../library/FEATURES_DELIVERED_2026-Q2.md | 59 +++- .../ACCESS_LAYER_AND_GOVERNANCE_PLAN.md | 20 +- .../docs/technical/AI_PROMPT_SYSTEM_SPEC.md | 3 +- .../technical/AI_TRAINING_PLANNING_CONCEPT.md | 46 ++- .claude/docs/technical/EXERCISES_API_SPEC.md | 58 +++- .../docs/technical/EXERCISES_ARCHITECTURE.md | 17 +- .../technical/EXERCISES_FRONTEND_ROUTING.md | 32 +- .claude/docs/technical/KI_FEATURES_SPEC.md | 9 +- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 5 +- .../AI_EXERCISE_IMPLEMENTATION_PLAN.md | 58 ++++ backend/db.py | 9 +- backend/exercise_ai.py | 320 ++++++++++++++++++ .../067_ai_prompts_exercise_assistant.sql | 141 ++++++++ backend/openrouter_chat.py | 100 ++++++ backend/requirements.txt | 1 + backend/routers/exercises.py | 109 +++++- backend/version.py | 15 +- docs/FACHLICHE_NUTZERFUNKTIONEN.md | 13 +- docs/HANDOVER.md | 11 +- frontend/src/api/exercises.js | 19 +- .../exercises/ExerciseFormPageRoot.jsx | 165 ++++++++- 25 files changed, 1240 insertions(+), 78 deletions(-) create mode 100644 .claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md create mode 100644 .claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md create mode 100644 backend/exercise_ai.py create mode 100644 backend/migrations/067_ai_prompts_exercise_assistant.sql create mode 100644 backend/openrouter_chat.py diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index e4a793f..3ab8915 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -83,7 +83,9 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu - [x] **Varianten** (CRUD, Reorder, Voraussetzung) + Anzeige im Detail - [x] **Progressionsgraph zwischen Übungen** (Bibliotheks-Container, Kanten, Sequenz-Bulk, Varianten-Knoten — Zwischenstand, siehe TRAINING_FRAMEWORK_SPEC §4) - [x] Medien (Upload/Embed, rollenabhängige Größenlimits) -- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen) +- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen; **Freigabelevel** als UI-Begriff für `visibility`) +- [x] **Übungsformular:** Registerkarten (Stammdaten … Medien & Mehr), kompakte Chip-Editoren, Varianten-Speichern über Aktionsleiste +- [x] **Fähigkeiten-Intensität** ohne Primär-Flag (`niedrig`/`mittel`/`hoch`; Backend `is_primary` immer false) - [x] Exercise Blocks (Bausteine) - [x] Saved Searches (wo implementiert) diff --git a/.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md b/.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md new file mode 100644 index 0000000..4b786ba --- /dev/null +++ b/.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md @@ -0,0 +1,100 @@ +# KI-Unterstützung bei Übungen – Produkt-Vision + +**Version:** 0.1 +**Datum:** 2026-05-22 +**Status:** Zielbild / Anforderungsgrundlage (nicht gleich Ist-Spec – technische Schnittstellen: **`technical/KI_FEATURES_SPEC.md`**, **`technical/AI_PROMPT_SYSTEM_SPEC.md`**, **`technical/AI_TRAINING_PLANNING_CONCEPT.md` §1.1**) +**Zielgruppe:** Product, Trainer-UX, später Admin-Werkzeuge + +--- + +## 1. Übergeordnete Prinzipien + +1. **Immer Vorschlag, nie blind überschreiben** + Die KI liefert **Vorschläge** (Änderungen, Ergänzungen, Strukturen). Bestehende Inhalte werden **nicht** still ersetzt. Übernahme erfolgt durch den Nutzer: **teilweise** (Felder/Stellen/Blöcke) oder **komplett** („Vorschlag gesamt akzeptieren“). + +2. **Granulare Anforderung im Editor** + Innerhalb einer Übung soll KI-Unterstützung **feldbasiert oder bereichsbasiert** auslösbar sein (z. B. nur „Anleitung schärfen“, nur „Fähigkeiten“, nur „Variantenrahmen“) **oder** als **Komplettüberarbeitung** mit klarem Warnhinweis (Umfang/transparenter Diff). + +3. **Nachweisliche Herkunft** + Übernommene KI-Inhalte werden technisch dort abgebildet, wo bereits vorgesehen (z. B. **`summary_ai_generated`**, **`exercise_skills.ai_suggested`**) und um analogen Hinweis für weitergehende Textfelder/Varianten **erweitert**, sobald Implementierung konkret wird. + +--- + +## 2. Funktionsbereiche (Vision) + +### 2.1 Von der Idee zur kompletten Übung („Zielausbau“) + +**Einstieg minimal:** Kurzbeschreibung oder Stichwort, **Ziel** („was soll erreicht werden?“), wenige **Rahmenparameter** (z. B. Fokusbereich, Trainingszeit, Teilnehmerzahl, Alter, Platzausstattung, Sicherheitshinweise – konkrete Dropdowns/Freifelder in UX später festlegen). + +**KI-Aufgabe:** aus diesem dünnen Kontext einen **übernehmbaren Entwurf** einer **ganzen Übung** erzeugen: Titel‑Vorschlag, Ziel-/Durchführungstext, Sicherheit/Organisation, ggf. Trainerhinweise – **immer als Vorschlagspaket**, nicht als Speicher ohne Bestätigung. + +**Abgrenzung:** Kombinationsübungen / komplexe Methodenprofile können **phasenweise** später einbezogen werden (Verweis Fachspez Trainingsmodule). + +### 2.2 Anleitung (Durchführung / „Ausführung“) maximal hilfreich + +**Ziel:** Die **Ausführungs-/Anleitungsbereiche** sollen sich **didaktisch klar**, **teilbar** und **wieder verwendbar** lesen – ohne den Trainer zu entmindigen. + +**KI-Aufgabe:** Überarbeitungsvorschlag für Struktur (nummerierte Schritte, Zeiten pro Block, häufige Fehler, Progressionshinweise innerhalb der Übung wo sinnvoll). **Selektiver** Aufruf: nur diese Felder oder nur ein markierter Abschnitt (wenn UX Textauswahl unterstützt). + +### 2.3 Kurzbeschreibung (`summary`) + +**KI-Aufgabe:** Aus den **relevanten Übungstexten** eine **Liste-/Karte-taugliche** Kurzfassung generieren — wie in **`KI_FEATURES_SPEC.md`** beschrieben, mit **Ablehnen / Bearbeiten / Übernehmen**. + +### 2.4 Einordnung – primär **Fähigkeiten** + +**KI-Aufgabe:** automatische Erkennung und **Zuordnung** zum **globale Skills-Katalog** inklusive: + +- **Intensität** (`exercise_skills`) +- **Skill-Level**: `required_level` / `target_level` nach **kanonischen Slugs** (Backend-konform) +- **`is_primary`** / Priorisierung wo fachlich sinnvoll + +**Prompt-Kontext für Qualität:** Stammfelder wie `skills.description`, **`karate_relevance`**, **`relevance_level`**, **`focus_areas`**, optional **`skill_level_definitions`** nur für eine **kurze Kandidatenliste** (zweite Runde möglich) – keine vollständigen Romane für den gesamten Katalog auf einmal. + +### 2.5 Varianten (optional, später prioritär erwägenswert) + +**Vision:** Aus Ziel-/Durchführungstext **mehrere sinnvolle Ausprägungen** als **Übungsvarianten** vorschlagen oder einzelne erzeugen (**progression**, **Schwierigkeit**, andere Paararbeit, Gerätevariation) mit **übernehmbarem** Datenmodell gleich dem bestehenden `exercise_variants`. + +**Randbedingungen:** Validierung gegen Übungstyp (Kombinationsübungen ohne Varianten laut Produktstand), keine Halluzination fremder IDs. + +--- + +## 3. Kontextbezug später: Nachbearbeitung aus der Trainingsplanung + +**Vision:** Hinweise aus der **Nachbearbeitung** einer Trainingseinheit (Ist‑Minuten, Trainer-Notizen, Abweichungen „was lief nicht?“ – je nach Datenmodell) fließen **optional** als Kontext in eine **erneute KI-Überarbeitung der betroffenen Übung** ein („Übung aus den Erfahrungen der Gruppe verbessern“). + +**Konsequenz technisch später:** Zugriffsrechte, Mandant, keine unzulässige Verknüpfung personenbezogener Sportlerdaten; Aggregation auf **Einheit-/Gruppe** und **bereits dokumentierte Trainer-Insights**. + +--- + +## 4. Admin: Massenverarbeitung und Analyse + +**Vision für Plattform-/Vereins-Admins:** + +| Thema | Richtungsziel | +|-------|----------------| +| **Massenverarbeitung** | Batch: z. B. Zusammenfassungen nachziehen, fehlende Skills vorschlagen, einheitlicher Stil bei importiertem Bestand — immer mit **Review-Queue**, nicht ohne menschliche Freigabe skalierungskritisch. | +| **Analyse / Qualität** | Werkzeugkasten oder Berichte: **welche Übungen** sollten überarbeitet werden? z. B. leere/kurze `summary`, fehlende `goal`/`execution`, **fehlende oder widersprüchliche Skill-Zuordnung**, Import-Herkunft ohne Plausibilität, Kombi-Slots unvollständig, sehr alte Imports. | +| **Lückenkarten** | Z. B. Abgleich gegen **Skill-Discovery**/Profil-Analysen („keine Übung deckt Fähigkeit X ab“ auf gewähltem Korpus); Verbindung zu **`skill-discovery`** entscheidend später im Detail (kein automatischer Rewrite ohne Policy). | + +**Governance:** Sichtbarkeit (`official`, Verein), Rechte (**Superadmin** vs. Vereinsinhalt), Audit der KI-Anwendung bei Massenjobs. + +--- + +## 5. Phasierung (überarbeitungsfähig) + +| Phase | Inhalt | +|-------|--------| +| **P0** | KI-Service + Prompts aus DB + **Suggestion-only** UX; Kern: **Summary** + **Skills** (wie Spec-Minimum), **ein Feld / Komplettpaket mit Diff** nach UX. | +| **P1** | **Anleitung überarbeiten** + **„von Idee zur Übung“** (Zielausbau) mit Rahmenparameter-Form | +| **P2** | **Variantenvorschläge** mit strenger Validation | +| **P3** | **Planungs-/Nachbereitungskontext** | +| **P4** | **Admin** Massen-/Analyse (Queue + Reports + Governance) | + +--- + +## 6. Offene Produkt-/Fachfragen + +- Minimaler **Parameterbau** beim Zielausbau (Pflicht vs. optional). +- Umgang mit **Medien**/Inline-Verweisen beim KI-Text – nichts zerstören, Platzhalter erhalten (siehe Medien-Spec §11). +- **Kombinationsübungen:** welche Teilaspekte dürfen KI anfassen? +- Limits: **Tokens**, **Rate-Limits**, Kostenüberwachung pro Verein/global. diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md index d512d08..158d784 100644 --- a/.claude/docs/functional/DOMAIN_MODEL.md +++ b/.claude/docs/functional/DOMAIN_MODEL.md @@ -57,7 +57,7 @@ Haupt-Kategorie (KARATE / ALLGEMEINE) - Selbstverteidigung ✓ - Gewaltschutz ✓ -**Technische Umsetzung:** M:N Beziehungen mit `is_primary` Flag. +**Technische Umsetzung:** M:N-Beziehungen mit optionalem `is_primary`-Flag bei **Fokusbereichen, Stilrichtungen, Trainingsstilen und Zielgruppen** — nicht bei `exercise_skills` (dort nur Intensität `niedrig|mittel|hoch`). ### 3. Hierarchischer Kontext (§8.1) diff --git a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md index 84c44ba..9141e4f 100644 --- a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md +++ b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md @@ -12,6 +12,8 @@ Ausführliche fachliche Inhalte: | [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§ 10.4)**, kanonische Archetyp-IDs **§ 10.2.1**, **Anhang A** Implementierungsabgleich | | [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand | | [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt | +| [**KI-Unterstützung Übungen (Vision)**](./AI_EXERCISE_ASSISTANT_VISION.md) | Zielbild Zielausbau, Vorschlags-UX (teilweise/komplett), Skills/Varianten, später Planungskontext, Admin-Masse/Qualität | +| [**KI Übungen – Umsetzungsplan**](../working/AI_EXERCISE_IMPLEMENTATION_PLAN.md) | Stufen S0–S6, Driftschutz-Regeln, Checkliste gegen Specs | **Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**. diff --git a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md index e6f1af9..df5e5cb 100644 --- a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md +++ b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md @@ -68,7 +68,7 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung. ## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`) - Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht. -- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, Sichtbarkeit, Status). +- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, **Freigabelevel**, Status). - **Filter-Chips** unter der Suchleiste; Klick entfernt einen Filter; Badge am Filter-Button = Anzahl Chips. - **Kein Vollbild-Spinner** bei jeder Suche: nur noch **`listFetching`** — Suchfelder bleiben im DOM (**Fokus/Cursor** bleiben erhalten); Liste zeigt optional „Aktualisiere Treffer…“. - **``** 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 `
`): 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 (`
`), **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 | |--------|----------| diff --git a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md index 2daf6d6..4225c87 100644 --- a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md +++ b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md @@ -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 diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md index b0b53bf..baada4d 100644 --- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md +++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md @@ -174,10 +174,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), diff --git a/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md b/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md index 372f92d..a8a76a0 100644 --- a/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md +++ b/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md @@ -1,12 +1,14 @@ # KI-gestützte Trainingsplanung – Zentrales Konzept -**Version:** 0.1 -**Datum:** 2026-05-16 +**Version:** 0.2 +**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) · `technical/TRAINING_FRAMEWORK_SPEC.md` · **`technical/SKILL_SCORING_SPEC.md`** (Fähigkeits-Profilierung, Discovery) · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/SKILLS_MATRIX_SPEC.md` · `docs/FACHLICHE_NUTZERFUNKTIONEN.md` · `docs/HANDOVER.md` --- @@ -16,13 +18,28 @@ - **Human-in-the-loop:** KI liefert **Vorschläge** (Liste, Reihenfolge, Begründung); schreibende Übernahme in Pläne nur nach **Trainer-Bestätigung** oder expliziter Aktion (analog „Manual First“ in `KI_FEATURES_SPEC.md`). - **Governance-first:** Nur Übungen, die die API bereits für den Mandanten/Kontext **sichtbar** freigibt, dürfen in Kandidatenlisten landen – **vor** Retrieval und **vor** jedem Prompt. +### 1.1 Abgleich: aktueller Code- und Schema-Stand (Stand Review 2026-05-22) + +| Thema | Ist im Repo | Konsequenz für dieses Konzept | +|--------|-------------|-------------------------------| +| **OpenRouter / LLM im Backend** | Produktiver Aufruf für Übungs‑Suggest in `openrouter_chat.py`, `exercise_ai.py`; Endpunkte **`POST …/exercises/ai/suggest`** und **`POST …/{id}/ai/regenerate`**; Migration **067** (`ai_prompts`, `summary_ai_generated`). **`db.py`**-Bootstrap nutzt **`display_name`**. | **Übungs-Assistent (P0)** vorhanden; generalisierter Service + **Planungs-KI** folgen. | +| **Übungs-KI laut Spec** | P0: Kurzfassung + Skill‑Vorschläge (`include_summary` / `include_skills`); **kein** Auto-KI beim Speichern (S5 im Umsetzungsplan). | Feinspez: `summary_ai_generated` bei manueller Kurzfassung zurücksetzen; Rate-Limits; Prompt-Admin-UI. | +| **Fähigkeiten-Stammdaten** | Migration **`065_skills_wiki_karate_relevance`:** `skills.karate_relevance` (Text), `skills.relevance_level` (1–3, optional); dazu weiterhin `description`, `focus_areas`, Kategorien, `skill_level_definitions` (Level 1–5 je Skill). | Diese Felder sind **expliziter Prompt-Kontext** für Skill-Vorschläge (Disambiguierung Karate vs. universal) – siehe §6. | +| **Skill-Scoring & Discovery (ohne LLM)** | Router `skill_profiles.py` + Modul `skill_scoring.py`: u. a. `GET …/skill-profile` für **Rahmenprogramm**, **Trainingsmodul**, **Progressionsgraph**; `POST /skill-profiles/batch-summaries`; **`GET /api/skill-discovery/suggestions`** (Match Bibliotheksartefakte ⇄ `skill_ids`, mit `library_content_visibility_sql`). | Ergänzt §3 **Stufe 3**: deterministische **Skill-Abdeckung / Artefakt-Discovery** ist **bereits vorhanden** und kann später die **Planungs-KI** speisen (Ziel-Skill-Mengen, Vergleich „Profil des Rahmens“) – ersetzt aber **nicht** die Top‑K-Selektion aus dem **Übungskatalog** für eine konkrete Session. | +| **Profil / Planungs-Präferenzen** | `profiles.training_planning_prefs` (JSONB, vgl. `MODULE_VERSIONS` → `profiles`), Planungsmodul mit u. a. **Vorlagen inkl. Split-Sessions** (`planning`), `training_units` mit **Publish in Rahmen-Slot-Blueprint**. | Zukünftige KI-Planung kann **Prefs** und **Vorlagen-Struktur** als weiche Constraints einbeziehen; Rahmen↔Einheit-Fluss ist produktiv erweitert – für KI nur relevant, sobald Planungs-Endpunkte angebunden werden. | +| **Übungsliste API** | Keyset-Pagination u. a. `cursor_updated_at` + Tie-break `id` (`exercises`-Modul laut `MODULE_VERSIONS`). | Retrieval-Pipelines sollten **cursorbasiert** paginieren, nicht „alle IDs auf einmal“ laden. | + +**Nächster produktiver Fokus:** Prompt-/Admin‑UI zur Pflege von `ai_prompts`, **Rate-Limits**, optional **Auto-KI beim Speichern**; danach Übergang zur **Planungs-KI** laut diesem Dokument. + --- ## 2. Kernproblem: Skalierung des Kontextes 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 +86,8 @@ Mindestens **eine** der folgenden Optionen – kombinierbar: 1. **Skill-/Facet-Overlap:** Punktezahl, wenn Übungs-Skills mit Ziel-/Matrix-Schwerpunkten übereinstimmen (bereits Daten in `exercise_skills`). 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 +146,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 +158,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. --- diff --git a/.claude/docs/technical/EXERCISES_API_SPEC.md b/.claude/docs/technical/EXERCISES_API_SPEC.md index c99b779..29091f3 100644 --- a/.claude/docs/technical/EXERCISES_API_SPEC.md +++ b/.claude/docs/technical/EXERCISES_API_SPEC.md @@ -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 diff --git a/.claude/docs/technical/EXERCISES_ARCHITECTURE.md b/.claude/docs/technical/EXERCISES_ARCHITECTURE.md index 8ce3822..1ef2c9b 100644 --- a/.claude/docs/technical/EXERCISES_ARCHITECTURE.md +++ b/.claude/docs/technical/EXERCISES_ARCHITECTURE.md @@ -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` diff --git a/.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md b/.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md index 9ef6f81..3af52ce 100644 --- a/.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md +++ b/.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md @@ -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) diff --git a/.claude/docs/technical/KI_FEATURES_SPEC.md b/.claude/docs/technical/KI_FEATURES_SPEC.md index e3705c1..2b590e7 100644 --- a/.claude/docs/technical/KI_FEATURES_SPEC.md +++ b/.claude/docs/technical/KI_FEATURES_SPEC.md @@ -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 | |---------|------| @@ -182,7 +187,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 +196,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen. "required_level": "einsteiger", "target_level": "grundlagen", "intensity": "mittel", - "is_primary": false, "confidence": 0.74 } ] diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 5bc6f55..3b89aa4 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -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; Sendung an OpenRouter | | exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar | | 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) | @@ -38,12 +39,14 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-13 — `GET /api/dashboard/kpis` (Kurzüberblick-Aggregat). +Letzte Änderung: 2026-05-22 — `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` (Übungs-KI, kein Persist durch Endpunkt). --- ### Changelog (Fortführung) +- **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`. diff --git a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..08d0394 --- /dev/null +++ b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md @@ -0,0 +1,58 @@ +# Umsetzungsplan – KI bei Übungen (stufenweise, Driftschutz) + +**Version:** 0.1 +**Datum:** 2026-05-22 +**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand) + +--- + +## 1. Drift vermeiden – verbindliche Regeln + +1. **Spec vor Code:** Request/Response-Felder und Statuscodes an `KI_FEATURES_SPEC.md` ausrichten; Abweichungen zuerst Spec oder dieses Dokument anpassen. +2. **Prompts in der DB:** Keine produktionskritischen Prompt-Langtexte nur im Code; Defaults per **Migration** in `ai_prompts`, Anpassung durch Admins über vorgesehene Oberfläche (später) oder SQL. +3. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen. +4. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec). +5. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`. +6. **Schema:** Neue DB-Objekte nur nummerierte Migration `backend/migrations/067_*.sql` (oder folgend); `DB_SCHEMA_VERSION` in `backend/version.py` anheben. + +--- + +## 2. Stufen (Releases) + +| Stufe | Inhalt | Exit-Kriterium | +|-------|--------|------------------| +| **S0** | Dieses Dokument + Verweise konsistent | Review abgehakt | +| **S1** | Migration `ai_prompts` + Defaults `exercise_summary`, `exercise_skill_suggestions`; `exercises.summary_ai_generated` | Migrierte DB, App startet | +| **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key | +| **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key | +| **S4** | Frontend: KI-Vorschlag, Teilübernahme Summary + Skills | Manuelle UX-Prüfung | +| **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config | +| **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics | + +**Aktueller Implementierungsstand nach Merge:** S0–S4 anstreben; S5/S6 nicht Teil dieses Laufs. + +--- + +## 3. Implementierungs-Checkliste (Technik) + +- [ ] `OPENROUTER_API_KEY` / `OPENROUTER_MODEL` in `.env.example` dokumentiert (bereits teils vorhanden – prüfen). +- [ ] Fehlerbilder: `400` zu wenig Inhalt, `503` KI nicht konfiguriert, `502` Upstream-Fehler mit kurzer Message. +- [ ] Logging: **keine** vollständigen Prompts mit personenbezogenen Daten in Prod-Logs (optional DEBUG). +- [ ] Optional: Rate-Limit KI-Endpunkte (`slowapi`) – nach Bedarf. +- [ ] `MODULE_VERSIONS["exercises"]` / Changelog bei API-Erweiterung setzen. + +--- + +## 4. Changelog dieses Plans + +- **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad. +- **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen. + +--- + +## 5. Umsetzungsstand (Zwischencheckpoint) + +**Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert). + +**Bewusst noch nicht:** automatische KI beim Speichern (**S5**), Setzen von `summary_ai_generated` bei manuellen UI-Änderungen, Prompt-Admin-UI, Rate-Limits. + diff --git a/backend/db.py b/backend/db.py index 0b07348..76f2101 100644 --- a/backend/db.py +++ b/backend/db.py @@ -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 ) diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py new file mode 100644 index 0000000..55e07ab --- /dev/null +++ b/backend/exercise_ai.py @@ -0,0 +1,320 @@ +""" +KI-Vorschlaege fuer Uebungsformular: Laedt Prompts aus ai_prompts, ruft OpenRouter auf. +Keine persistente Aenderung an exercises — nur Response-DTO fuer das Frontend. +""" +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import HTTPException + +from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion + +_CANONICAL_SKILL_LEVELS = frozenset({"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}) +_LEGACY_SKILL_LEVEL_SLUG = { + "einsteiger": "basis", + "experte": "optimierung", + "1": "basis", + "2": "grundlagen", + "3": "aufbau", + "4": "fortgeschritten", + "5": "optimierung", +} +_ALLOWED_SKILL_INTENSITY = frozenset({"niedrig", "mittel", "hoch"}) + + +def _normalize_exercise_skill_level(value) -> Optional[str]: + if value is None: + return None + s = str(value).strip().lower() + if not s: + return None + if s in _CANONICAL_SKILL_LEVELS: + return s + return _LEGACY_SKILL_LEVEL_SLUG.get(s) + + +def _normalize_exercise_skill_intensity(value) -> str: + if value is None: + return "mittel" + key = str(value).strip().lower() + if key in ("low",): + return "niedrig" + if key in ("medium",): + return "mittel" + if key in ("high",): + return "hoch" + if key in _ALLOWED_SKILL_INTENSITY: + return key + return "mittel" + +_TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE) + +_MAX_PLAIN_FIELD = 28_000 +_MAX_SKILLS_CATALOG_LINES = 240 +_MAX_SUMMARY_CHARS = 220 + + +def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) -> str: + if not html: + return "" + t = _TAG_RE.sub(" ", str(html)) + t = re.sub(r"\s+", " ", t).strip() + if len(t) > max_len: + t = t[: max_len - 1].rstrip() + "…" + return t + + +def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]: + cur.execute( + """ + SELECT slug, display_name, template, output_format, active + FROM ai_prompts + WHERE slug = %s + """, + (slug,), + ) + row = cur.fetchone() + if not row: + return None + d = dict(row) + if not d.get("active", True): + return None + return d + + +def _render_template(template: str, ctx: Dict[str, str]) -> str: + out = template or "" + for key, val in ctx.items(): + placeholder = "{{" + key + "}}" + out = out.replace(placeholder, val if val is not None else "") + return out + + +def _build_skills_catalog_block(cur) -> str: + cur.execute( + """ + SELECT s.id, s.name, s.category, s.description, s.karate_relevance, s.relevance_level, + sc.name AS subcategory_name + FROM skills s + LEFT JOIN skill_categories sc ON s.category_id = sc.id + WHERE (s.status IS NULL OR s.status = 'active') + ORDER BY s.importance DESC NULLS LAST, s.name + LIMIT %s + """, + (_MAX_SKILLS_CATALOG_LINES,), + ) + lines: List[str] = [] + for r in cur.fetchall(): + rid = int(r["id"]) + nm = (r.get("name") or "").strip() or f"Skill #{rid}" + cat = (r.get("category") or "").strip() + sub = (r.get("subcategory_name") or "").strip() + dsc = strip_html_to_plain(r.get("description"), max_len=320) + kr = strip_html_to_plain(r.get("karate_relevance"), max_len=200) + rel = r.get("relevance_level") + rel_s = "" + if rel is not None: + rel_s = str(rel) + + cats = " / ".join(x for x in (cat, sub) if x) + + blob = ( + f"- id={rid} | name={nm} | kategorie={cats or '-'}" + f" | beschreibung={dsc or '-'} | karate_relevanz={kr or '-'}" + f" | relevanz_stufe={rel_s or '-'}" + ) + lines.append(blob) + return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" + + +def _extract_json_array(text: str) -> Any: + s = text.strip() + if s.startswith("```"): + s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) + if s.endswith("```"): + s = s[:-3].strip() + # array whole string + if s.startswith("["): + end = s.rfind("]") + if end > 0: + s = s[: end + 1] + return json.loads(s) + # object wrapping array + if s.startswith("{"): + obj = json.loads(s) + if isinstance(obj, dict): + for k in ("skills", "items", "data"): + v = obj.get(k) + if isinstance(v, list): + return v + raise ValueError("JSON-Objekt ohne Skills-Liste") + return json.loads(s) + + +def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]: + if not isinstance(rows, list): + return [] + out: List[Dict[str, Any]] = [] + for raw in rows: + if not isinstance(raw, dict): + continue + sid = raw.get("skill_id") + try: + skill_id = int(sid) + except (TypeError, ValueError): + continue + cur.execute( + """ + SELECT s.id, s.name, s.category, + sc.name AS subcategory_name + FROM skills s + LEFT JOIN skill_categories sc ON s.category_id = sc.id + WHERE s.id = %s AND (s.status IS NULL OR s.status = 'active') + """, + (skill_id,), + ) + sk = cur.fetchone() + if not sk: + continue + + req = _normalize_exercise_skill_level(raw.get("required_level")) or "grundlagen" + tgt = _normalize_exercise_skill_level(raw.get("target_level")) or req + if req not in _CANONICAL_SKILL_LEVELS: + req = _LEGACY_SKILL_LEVEL_SLUG.get(str(raw.get("required_level") or "").strip().lower(), "grundlagen") + if req not in _CANONICAL_SKILL_LEVELS: + req = "grundlagen" + if tgt not in _CANONICAL_SKILL_LEVELS: + tgt = _LEGACY_SKILL_LEVEL_SLUG.get(str(raw.get("target_level") or "").strip().lower(), req) + if tgt not in _CANONICAL_SKILL_LEVELS: + tgt = req + + inten = _normalize_exercise_skill_intensity(raw.get("intensity")) + + is_primary = bool(raw.get("is_primary")) if raw.get("is_primary") is not None else len(out) == 0 + + cat = (sk.get("category") or "").strip() + sub = (sk.get("subcategory_name") or "").strip() + skill_category = " / ".join(x for x in (cat, sub) if x) or (cat or None) + + conf = raw.get("confidence") + try: + conf_f = float(conf) if conf is not None else None + except (TypeError, ValueError): + conf_f = None + + item: Dict[str, Any] = { + "skill_id": skill_id, + "skill_name": (sk.get("name") or "").strip() or f"Skill #{skill_id}", + "required_level": req, + "target_level": tgt, + "intensity": inten, + "is_primary": is_primary, + } + if skill_category: + item["skill_category"] = skill_category + if conf_f is not None: + item["confidence"] = conf_f + out.append(item) + + # max 5 + return out[:5] + + +def _require_openrouter() -> Tuple[str, str]: + key, model = normalize_openrouter_env() + if not key: + raise HTTPException( + status_code=503, + detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).", + ) + return key, model + + +def run_exercise_ai_suggestion( + cur, + *, + title: Optional[str], + goal: Optional[str], + execution: Optional[str], + focus_area_hint: Optional[str], + want_summary: bool, + want_skills: bool, +) -> Dict[str, Any]: + key, model = _require_openrouter() + + g_plain = strip_html_to_plain(goal) + e_plain = strip_html_to_plain(execution) + if not (g_plain.strip() or e_plain.strip()): + raise HTTPException( + status_code=400, + detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).", + ) + + t_title = (title or "").strip() + focus = (focus_area_hint or "").strip() + + result: Dict[str, Any] = {"model": model} + + if want_summary: + prow = _load_prompt_row(cur, "exercise_summary") + if not prow: + raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.") + ctx = { + "exercise_title": t_title or "-", + "exercise_focus_area": focus or "-", + "exercise_goal": g_plain or "-", + "exercise_execution": e_plain or "-", + } + prompt = _render_template(str(prow["template"]), ctx) + try: + raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt) + except OpenRouterError as e: + raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e + text = (raw or "").strip() + if len(text) > _MAX_SUMMARY_CHARS: + text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…" + result["summary"] = {"text": text, "ai_generated": True, "model": model} + + if want_skills: + srow = _load_prompt_row(cur, "exercise_skill_suggestions") + if not srow: + raise HTTPException( + status_code=503, + detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.", + ) + catalog = _build_skills_catalog_block(cur) + ctx = { + "exercise_title": t_title or "-", + "exercise_focus_area": focus or "-", + "exercise_goal": g_plain or "-", + "exercise_execution": e_plain or "-", + "skills_catalog": catalog, + } + prompt = _render_template(str(srow["template"]), ctx) + sys_hint = ( + "Du antwortest nur mit validem JSON (Array). Keine Kommentare, keine Erklaerungen ausserhalb des JSON." + ) + try: + raw = openrouter_chat_completion( + api_key=key, + model=model, + user_content=prompt, + system_content=sys_hint, + temperature=0.15, + ) + except OpenRouterError as e: + raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e + try: + parsed = _extract_json_array(raw) + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException( + status_code=502, + detail="KI lieferte kein verwertbares JSON fuer Skills.", + ) from e + skills = _sanitize_skill_entries(cur, parsed) + result["skills"] = skills + + return result diff --git a/backend/migrations/067_ai_prompts_exercise_assistant.sql b/backend/migrations/067_ai_prompts_exercise_assistant.sql new file mode 100644 index 0000000..442679c --- /dev/null +++ b/backend/migrations/067_ai_prompts_exercise_assistant.sql @@ -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'); diff --git a/backend/openrouter_chat.py b/backend/openrouter_chat.py new file mode 100644 index 0000000..41c640a --- /dev/null +++ b/backend/openrouter_chat.py @@ -0,0 +1,100 @@ +""" +Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MODEL / OPENROUTER_BASE_URL from env. +""" +from __future__ import annotations + +import json +import os +from typing import Any, Dict, List, Optional + +import httpx + + +class OpenRouterError(Exception): + """Upstream or transport failure.""" + + +def openrouter_chat_completion( + *, + api_key: str, + model: str, + user_content: str, + system_content: Optional[str] = None, + timeout_sec: float = 120.0, + temperature: float = 0.25, + site_url: Optional[str] = None, + app_title: Optional[str] = None, +) -> str: + """ + Returns assistant message content (plain string). Caller validates empty responses. + """ + base = (os.getenv("OPENROUTER_BASE_URL") or "").strip().rstrip("/") or "https://openrouter.ai/api/v1" + url = f"{base}/chat/completions" + + headers: Dict[str, str] = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + referer = (site_url or os.getenv("APP_URL") or "").strip() + if referer: + headers["HTTP-Referer"] = referer + title = (app_title or os.getenv("OPENROUTER_APP_TITLE") or "Shinkan Jinkendo").strip() + if title: + headers["X-Title"] = title + + messages: List[Dict[str, str]] = [] + if system_content and str(system_content).strip(): + messages.append({"role": "system", "content": str(system_content).strip()}) + messages.append({"role": "user", "content": user_content}) + + payload: Dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": temperature, + } + + try: + with httpx.Client(timeout=timeout_sec) as client: + resp = client.post(url, headers=headers, json=payload) + except httpx.RequestError as e: + raise OpenRouterError(str(e)) from e + + if resp.status_code >= 400: + detail = "" + try: + j = resp.json() + detail = ( + str(j.get("error", {}).get("message")) + if isinstance(j.get("error"), dict) + else str(j.get("message") or j) + ) + except Exception: + detail = (resp.text or "")[:600] + raise OpenRouterError(f"HTTP {resp.status_code}: {detail}".strip()) + + try: + data = resp.json() + except json.JSONDecodeError as e: + raise OpenRouterError("Ungueltige JSON-Antwort von OpenRouter") from e + + choices = data.get("choices") if isinstance(data, dict) else None + if not choices or not isinstance(choices, list): + raise OpenRouterError("OpenRouter: keine choices in Antwort") + + msg0 = choices[0] if choices else {} + inner = msg0.get("message") if isinstance(msg0, dict) else None + content = "" + if isinstance(inner, dict): + content = str(inner.get("content") or "") + elif isinstance(inner, str): + content = inner + elif isinstance(msg0.get("content"), str): + content = msg0.get("content") or "" + + return content.strip() + + +def normalize_openrouter_env() -> tuple[str, str]: + key = (os.getenv("OPENROUTER_API_KEY") or "").strip() + model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip() + return key, model diff --git a/backend/requirements.txt b/backend/requirements.txt index e26b469..ebc6416 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,4 @@ +httpx==0.27.2 fastapi==0.111.0 uvicorn[standard]==0.29.0 anthropic==0.26.0 diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 6057637..8b9c5a6 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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,42 @@ class ExerciseMediaFromAsset(BaseModel): media_type: Optional[str] = None +class ExerciseAiSuggestBody(BaseModel): + title: Optional[str] = Field(None, max_length=300) + goal: Optional[str] = Field(None, max_length=64000) + execution: Optional[str] = Field(None, max_length=128000) + focus_area_hint: Optional[str] = Field(None, max_length=1200) + include_summary: bool = True + include_skills: bool = True + + @model_validator(mode="after") + def check_include_any(self): + if not self.include_summary and not self.include_skills: + raise ValueError("Mindestens include_summary oder include_skills aktivieren.") + return self + + +class ExerciseAiRegenerateBody(BaseModel): + """Welche Artefakte neu angefragt werden sollen.""" + + regenerate: list[str] = Field(default_factory=lambda: ["summary", "skills"]) + + @model_validator(mode="after") + def normalize_regs(self): + allowed = {"summary", "skills"} + raw = [str(x).strip().lower() for x in (self.regenerate or [])] + out = [] + seen = set() + for lx in raw: + if lx in allowed and lx not in seen: + out.append(lx) + seen.add(lx) + if not out: + out = ["summary", "skills"] + self.regenerate = out + return self + + class ExerciseVariantCreate(BaseModel): variant_name: str = Field(..., min_length=3, max_length=200) description: Optional[str] = None @@ -1244,7 +1282,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 +2254,75 @@ def list_exercises_like_get( ) +def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str: + parts: List[str] = [] + for row in exercise.get("focus_areas") or []: + if isinstance(row, dict): + nm = (row.get("name") or "").strip() + if nm: + parts.append(nm) + txt = ", ".join(parts).strip() + if len(txt) > 900: + return txt[:899] + "…" + return txt + + +@router.post("/exercises/ai/suggest") +def exercise_ai_suggest_endpoint( + body: ExerciseAiSuggestBody, + tenant: TenantContext = Depends(get_tenant_context), +): + """ + KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern. + OPENROUTER_API_KEY erforderlich. + """ + _ = tenant.profile_id + with get_db() as conn: + cur = get_cursor(conn) + payload = run_exercise_ai_suggestion( + cur, + title=(body.title or "").strip(), + goal=body.goal, + execution=body.execution, + focus_area_hint=(body.focus_area_hint or "").strip() or None, + want_summary=body.include_summary, + want_skills=body.include_skills, + ) + return payload + + +@router.post("/exercises/{exercise_id}/ai/regenerate") +def exercise_ai_regenerate_endpoint( + exercise_id: int, + body: ExerciseAiRegenerateBody, + tenant: TenantContext = Depends(get_tenant_context), +): + """Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT).""" + want_summary = "summary" in body.regenerate + want_skills = "skills" in body.regenerate + + with get_db() as conn: + cur = get_cursor(conn) + _assert_can_edit_exercise(cur, exercise_id, tenant) + + exercise = enrich_exercise_detail(exercise_id, cur) + if not exercise: + raise HTTPException(status_code=404, detail="Übung nicht gefunden") + + focus = _focus_area_hint_from_detail(exercise) + + payload = run_exercise_ai_suggestion( + cur, + title=str(exercise.get("title") or "").strip(), + goal=exercise.get("goal"), + execution=exercise.get("execution"), + focus_area_hint=focus or None, + want_summary=want_summary, + want_skills=want_skills, + ) + return payload + + @router.get("/exercises/{exercise_id}") def get_exercise( exercise_id: int, diff --git a/backend/version.py b/backend/version.py index 2752721..30fabb1 100644 --- a/backend/version.py +++ b/backend/version.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.152" +BUILD_DATE = "2026-05-22" +DB_SCHEMA_VERSION = "20260522067" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -22,7 +22,7 @@ MODULE_VERSIONS = { "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", - "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break + "exercises": "2.29.0", # POST exercises/ai/suggest + …/ai/regenerate (OpenRouter); exercise_ai; is_primary fuer exercise_skills "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -37,6 +37,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.152", + "date": "2026-05-22", + "changes": [ + "KI bei Uebungen: Migration 067 ai_prompts + summary_ai_generated; OpenRouter-Hilfsmodul; POST /api/exercises/ai/suggest und POST /api/exercises/{id}/ai/regenerate", + "Uebungsformular: Buttons KI Kurzfassung / Fähigkeiten; exercise_skills is_primary wird aus Payload gespeichert", + ], { "version": "0.8.151", "date": "2026-05-20", diff --git a/docs/FACHLICHE_NUTZERFUNKTIONEN.md b/docs/FACHLICHE_NUTZERFUNKTIONEN.md index 123a9be..cd03d02 100644 --- a/docs/FACHLICHE_NUTZERFUNKTIONEN.md +++ b/docs/FACHLICHE_NUTZERFUNKTIONEN.md @@ -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) diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 9ff22c1..741eed2 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -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,15 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **Frontend:** KPI-Kacheln + Filter-Modal (UX wie Übungsliste) auf **`/planning/framework-programs`** und **`/planning/training-modules`**; Panels in Editoren; Discovery auf Fähigkeiten-Seite; `SkillTreeMultiSelect` mit Portal-Dropdown in Modals - **Offen (Backlog):** Corpus-Caching bei großen Bibliotheken; Tests für Typ-Trennung; Filter-Persistenz; Skill-Filter im Dialog „Rahmen übernehmen“; API-Umbenennung `club_*` → Peer-Namen +### 2.7 Übungsformular UX, Freigabelevel & Varianten-Speichern (Stand 2026-05-20) + +- **Code:** `frontend/src/components/exercises/ExerciseFormPageRoot.jsx`, `ExerciseFormLayout.jsx`, `ExerciseCatalogAssocEditor.jsx`, `ExerciseSkillsEditor.jsx`, `frontend/src/constants/exerciseGovernanceLabels.js`, `exerciseSkillIntensity.js` +- **Tab-Navigation:** Stammdaten | Anleitung | Einordnung | (Kombination) | Varianten | Medien & Mehr — `PageSectionNav` mit farbigen Panels (`.exercise-form-edit` in `app.css`); Varianten/Medien erst nach erstem Speichern aktiv; Kombi-Übungen ohne Varianten-Tab +- **Freigabelevel:** UI-Label für `exercises.visibility` in Formular, Liste, Filter, Bulk, Picker — API-Feldname unverändert +- **Fähigkeiten:** Intensität `niedrig`/`mittel`/`hoch` (Default `mittel`); kein Primär-Flag in UI; Backend setzt `exercise_skills.is_primary` immer `false` +- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) +- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions + --- ## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz) diff --git a/frontend/src/api/exercises.js b/frontend/src/api/exercises.js index ec16dfd..4eee53d 100644 --- a/frontend/src/api/exercises.js +++ b/frontend/src/api/exercises.js @@ -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, + }), }) } diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 2af4d8f..1e7d74b 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -15,6 +15,7 @@ import { } from '../../utils/exerciseInlineMediaRefs' import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll' import { normalizeSkillLevelSlug } from '../../constants/skillLevels' +import { stripHtmlToText } from '../../utils/htmlUtils' import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor' import ExerciseSkillsEditor from './ExerciseSkillsEditor' import { useAuth } from '../../context/AuthContext' @@ -71,6 +72,23 @@ const comboTinyNumberInputSx = { textAlign: 'center', } +function escapeHtmlText(s) { + return String(s) + .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) => `

${escapeHtmlText(p)}

`).join('') +} + function emptyComboSlotRow() { return { title: '', @@ -417,6 +435,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 +523,7 @@ function ExerciseFormPageRoot() { const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft()) const [variantSavingId, setVariantSavingId] = useState(null) const [variantBusy, setVariantBusy] = useState(false) + const [aiSuggestBusy, setAiSuggestBusy] = useState(false) const [variantEditSelection, setVariantEditSelection] = useState(null) const [activeFormTab, setActiveFormTab] = useState('stammdaten') const variantsSavedSnapshotRef = useRef({}) @@ -855,6 +876,83 @@ function ExerciseFormPageRoot() { ) } + const runExerciseAiSuggestion = async (mode) => { + const gPlain = stripHtmlToText(formData.goal || '').trim() + const ePlain = stripHtmlToText(formData.execution || '').trim() + if (!gPlain && !ePlain) { + toast.error('Ziel oder Durchführung ausfüllen — die KI benötigt Kontext.') + return + } + + const summaryOn = mode !== 'skills' + const skillsOn = mode !== 'summary' + + const focusHint = (formData.focus_areas_multi || []) + .map((row) => { + const id = row?.focus_area_id + const fa = focusAreas.find((x) => Number(x.id) === Number(id)) + return (fa?.name || '').trim() + }) + .filter(Boolean) + .join(', ') + + setAiSuggestBusy(true) + try { + const res = await api.suggestExerciseAi({ + title: (formData.title || '').trim(), + goal: formData.goal || '', + execution: formData.execution || '', + focus_area_hint: focusHint || undefined, + include_summary: summaryOn, + include_skills: skillsOn, + }) + + let applied = false + + if (summaryOn && res.summary?.text) { + updateFormField('summary', aiPlainSummaryToMinimalHtml(res.summary.text)) + applied = true + } + + if (skillsOn && Array.isArray(res.skills) && res.skills.length) { + setFormDirty(true) + setFormData((prev) => { + const next = [...(prev.skills || [])] + for (const sug of res.skills) { + const sid = Number(sug.skill_id) + if (!Number.isFinite(sid)) continue + const row = { + skill_id: sid, + intensity: normalizeExerciseSkillIntensity(sug.intensity), + required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen', + target_level: + normalizeSkillLevelSlug(sug.target_level) || + normalizeSkillLevelSlug(sug.required_level) || + 'grundlagen', + is_primary: !!sug.is_primary, + ai_suggested: true, + } + const ix = next.findIndex((s) => Number(s.skill_id) === sid) + if (ix >= 0) next[ix] = { ...next[ix], ...row } + else next.push(row) + } + return { ...prev, skills: next } + }) + applied = true + } + + if (!applied) { + toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.') + } else { + toast.success('KI-Vorschlag ins Formular übernommen — bitte prüfen und speichern.') + } + } catch (err) { + toast.error(err?.message || String(err)) + } finally { + setAiSuggestBusy(false) + } + } + const refreshVariants = useCallback(async () => { if (!exerciseId) return const ex = await api.getExercise(exerciseId) @@ -1309,7 +1407,28 @@ function ExerciseFormPageRoot() {
- +
+ + +
updateFormField('summary', html)} @@ -1966,6 +2085,42 @@ function ExerciseFormPageRoot() { title="Einordnung" hint="Fokus, Stile, Zielgruppen und Fähigkeiten für Suche, Filter und Skill-Profil." > +
+ + + + Benötigt Ziel oder Durchführung sowie optional{' '} + + · Vorschläge werden ins Formular übernommen und nicht automatisch gespeichert. + +
+

- KI-Ausbaustufe: Backend laut Spec{' '} - POST /api/exercises/ai/suggest und{' '} - POST /api/exercises/{'{id}'}/ai/regenerate — z. B.{' '} - OPENROUTER_API_KEY, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '} - api.suggestExerciseAi). + KI-Unterstützung: OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung + (suggestExerciseAi / regenerateExerciseAi). Übernahme nur im Formular; Speichern + wie gewohnt.

Date: Fri, 22 May 2026 07:56:56 +0200 Subject: [PATCH 02/10] Update AI Training Planning Document and Versioning - Incremented the version number from 0.2 to 0.3 in the AI Training Planning document to reflect the latest changes. - Added a new reference to the `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` document, outlining the architecture preview for the planning AI. - Updated the changelog in `backend/version.py` to include the latest version entry, ensuring accurate tracking of changes. --- .../technical/AI_TRAINING_PLANNING_CONCEPT.md | 6 +- .../AI_PLANNING_KI_MULTISTAGE_FORECAST.md | 112 ++++++++++++++++++ backend/version.py | 1 + 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 .claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md diff --git a/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md b/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md index a8a76a0..c28fdd5 100644 --- a/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md +++ b/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md @@ -1,6 +1,6 @@ # KI-gestützte Trainingsplanung – Zentrales Konzept -**Version:** 0.2 +**Version:** 0.3 **Datum:** 2026-05-22 **Status:** Arbeitsdokument (Verfeinerung durch fachliche Konzept-Agentur vorgesehen) **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. @@ -8,7 +8,7 @@ **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/AI_EXERCISE_ASSISTANT_VISION.md`** (Übungs-KI: Zielbild vor Planungs-KI) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (u. a. CURR-003 zu Progressions-/KI-Automatik) · `technical/TRAINING_FRAMEWORK_SPEC.md` · **`technical/SKILL_SCORING_SPEC.md`** (Fähigkeits-Profilierung, Discovery) · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/SKILLS_MATRIX_SPEC.md` · `docs/FACHLICHE_NUTZERFUNKTIONEN.md` · `docs/HANDOVER.md` +`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` --- @@ -31,6 +31,8 @@ **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 diff --git a/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md b/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md new file mode 100644 index 0000000..a950e32 --- /dev/null +++ b/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md @@ -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. diff --git a/backend/version.py b/backend/version.py index 30fabb1..70d9a29 100644 --- a/backend/version.py +++ b/backend/version.py @@ -44,6 +44,7 @@ CHANGELOG = [ "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", -- 2.43.0 From e5291256d0d153c67b1b09c1ce8705fd94267ce3 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 09:21:44 +0200 Subject: [PATCH 03/10] Enhance AI Exercise Suggestion Functionality and UX - Updated the AI Exercise Implementation Plan to include a detailed description of the new suggestion dialog for AI proposals, allowing users to preview and selectively adopt AI-generated summaries and skills. - Implemented a new preview feature in the ExerciseFormPageRoot component, enabling users to review AI suggestions before applying them to the form. - Enhanced the skill management process by normalizing AI-suggested skills and integrating them into the exercise form, improving user interaction and data handling. --- .../AI_EXERCISE_IMPLEMENTATION_PLAN.md | 7 +- .../exercises/ExerciseFormPageRoot.jsx | 441 ++++++++++++++++-- 2 files changed, 404 insertions(+), 44 deletions(-) diff --git a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md index 08d0394..9c5e042 100644 --- a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md +++ b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md @@ -25,7 +25,7 @@ | **S1** | Migration `ai_prompts` + Defaults `exercise_summary`, `exercise_skill_suggestions`; `exercises.summary_ai_generated` | Migrierte DB, App startet | | **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key | | **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key | -| **S4** | Frontend: KI-Vorschlag, Teilübernahme Summary + Skills | Manuelle UX-Prüfung | +| **S4** | Frontend: KI-Vorschlag, **Änderungsdialog** (Vorschau, Kurzfassung wählbar, Fähigkeiten pro Zeile an-/abwählbar), dann Übernahme ins Formular | Manuelle UX-Prüfung | | **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config | | **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics | @@ -47,12 +47,15 @@ - **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad. - **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen. +- **2026-05-22:** UX: Übernahmedialog für KI-Vorschläge (Vorschau, selektive Übernahme) im Übungsformular (`ExerciseFormPageRoot`). --- ## 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-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert). + +**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen. **Bewusst noch nicht:** automatische KI beim Speichern (**S5**), Setzen von `summary_ai_generated` bei manuellen UI-Änderungen, Prompt-Admin-UI, Rate-Limits. diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 1e7d74b..3dcbfd4 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -14,7 +14,7 @@ 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' @@ -44,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, @@ -89,6 +90,85 @@ function aiPlainSummaryToMinimalHtml(text) { return paras.map((p) => `

${escapeHtmlText(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: '', @@ -524,6 +604,7 @@ function ExerciseFormPageRoot() { 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({}) @@ -896,6 +977,9 @@ function ExerciseFormPageRoot() { .filter(Boolean) .join(', ') + const snapshotSummaryHtml = formData.summary || '' + const snapshotSkills = cloneExerciseSkillRows(formData.skills) + setAiSuggestBusy(true) try { const res = await api.suggestExerciseAi({ @@ -907,45 +991,20 @@ function ExerciseFormPageRoot() { include_skills: skillsOn, }) - let applied = false + const preview = buildExerciseAiSuggestionPreview({ + mode, + snapshotSummaryHtml, + snapshotSkills, + apiRes: res, + }) - if (summaryOn && res.summary?.text) { - updateFormField('summary', aiPlainSummaryToMinimalHtml(res.summary.text)) - applied = true - } - - if (skillsOn && Array.isArray(res.skills) && res.skills.length) { - setFormDirty(true) - setFormData((prev) => { - const next = [...(prev.skills || [])] - for (const sug of res.skills) { - const sid = Number(sug.skill_id) - if (!Number.isFinite(sid)) continue - const row = { - skill_id: sid, - intensity: normalizeExerciseSkillIntensity(sug.intensity), - required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen', - target_level: - normalizeSkillLevelSlug(sug.target_level) || - normalizeSkillLevelSlug(sug.required_level) || - 'grundlagen', - is_primary: !!sug.is_primary, - ai_suggested: true, - } - const ix = next.findIndex((s) => Number(s.skill_id) === sid) - if (ix >= 0) next[ix] = { ...next[ix], ...row } - else next.push(row) - } - return { ...prev, skills: next } - }) - applied = true - } - - if (!applied) { + const hasSomething = preview.hasSummaryProposal || preview.hasSkillChoices + if (!hasSomething) { toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.') - } else { - toast.success('KI-Vorschlag ins Formular übernommen — bitte prüfen und speichern.') + return } + + setAiSuggestionPreview(preview) } catch (err) { toast.error(err?.message || String(err)) } finally { @@ -953,6 +1012,52 @@ function ExerciseFormPageRoot() { } } + 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) @@ -1423,7 +1528,7 @@ function ExerciseFormPageRoot() { type="button" className="btn btn-secondary" style={{ fontSize: '12px' }} - disabled={aiSuggestBusy} + disabled={aiSuggestBusy || !!aiSuggestionPreview} onClick={() => runExerciseAiSuggestion('summary')} > KI: Kurzfassung @@ -2098,7 +2203,7 @@ function ExerciseFormPageRoot() { type="button" className="btn btn-secondary" style={{ fontSize: '12px' }} - disabled={aiSuggestBusy} + disabled={aiSuggestBusy || !!aiSuggestionPreview} onClick={() => runExerciseAiSuggestion('skills')} > KI: Fähigkeiten @@ -2107,7 +2212,7 @@ function ExerciseFormPageRoot() { type="button" className="btn btn-secondary" style={{ fontSize: '12px' }} - disabled={aiSuggestBusy} + disabled={aiSuggestBusy || !!aiSuggestionPreview} onClick={() => runExerciseAiSuggestion('both')} > KI: Kurzfassung und Fähigkeiten @@ -2117,7 +2222,7 @@ function ExerciseFormPageRoot() { - · Vorschläge werden ins Formular übernommen und nicht automatisch gespeichert. + · Es öffnet ein Dialogfeld mit Vorschau; Übernahme wählweise pro Teil. Speichern nur über die Aktionsleiste.
@@ -2615,6 +2720,258 @@ function ExerciseFormPageRoot() {
)} + {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 ( +
discardExerciseAiSuggestionPreview()} + onKeyDown={(e) => e.key === 'Escape' && discardExerciseAiSuggestionPreview()} + > +
e.stopPropagation()} + > +

KI-Vorschlag übernehmen

+

+ Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert. +

+ + {p.hasSummaryProposal ? ( +
+
+ Kurzfassung +
+ +
+
+
+ Aktuell (ohne Formatierung) +
+
{p.summaryBeforePlain || '(leer)'}
+
+
+
KI-Vorschlag
+
+ {p.summaryAfterPlain || '(leer)'} +
+
+
+
+ ) : null} + + {p.skillsRequested ? ( +
+
+
+ Fähigkeiten ({p.skillChoices.length} + {p.skillChoices.length === 1 ? ' Vorschlag' : ' Vorschläge'}) +
+ {p.skillChoices.length > 0 ? ( +
+ + +
+ ) : null} +
+ {p.skillChoices.length === 0 ? ( +

+ Keine passenden Fähigkeiten — der Katalog-Vorschlag war leer oder enthielt nur ungültige IDs. +

+ ) : ( +
    + {p.skillChoices.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+ ) : null} + +
+ + +
+
+
+ ) + })()} {mediaPreview && ( KI-Unterstützung: OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung - (suggestExerciseAi / regenerateExerciseAi). Übernahme nur im Formular; Speichern + (suggestExerciseAi / regenerateExerciseAi). Übernahme im Dialog ins Formular; Speichern wie gewohnt.

Date: Fri, 22 May 2026 09:49:08 +0200 Subject: [PATCH 04/10] Implement AI Skill Retrieval Profiles and Enhance Exercise AI Functionality - Introduced migration 068 for `ai_skill_retrieval_profiles`, enabling configurable weights and quotes for skill catalog prioritization in exercise AI suggestions. - Updated the `POST /api/exercises/ai/suggest` endpoint to include an optional `focus_areas_context` field, allowing for enhanced context in AI-generated suggestions. - Enhanced the `exercise_ai` module to utilize context-based skill selection, incorporating scoring, category caps, and keyword patches for improved AI responses. - Updated the ExerciseFormPageRoot component to pass focus area context to the AI suggestion API, streamlining user interaction with AI-generated content. - Incremented version numbers in `backend/version.py` to reflect the latest changes and ensure accurate tracking in the changelog. --- .../docs/technical/AI_PROMPT_SYSTEM_SPEC.md | 2 + .claude/docs/technical/KI_FEATURES_SPEC.md | 35 +- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 4 +- .../AI_EXERCISE_IMPLEMENTATION_PLAN.md | 28 +- .../AI_SKILL_RETRIEVAL_PROFILES_SPEC.md | 120 +++++ backend/exercise_ai.py | 482 ++++++++++++++++-- .../068_ai_skill_retrieval_profiles.sql | 125 +++++ backend/routers/exercises.py | 34 ++ backend/version.py | 17 +- docs/HANDOVER.md | 12 +- .../exercises/ExerciseFormPageRoot.jsx | 13 + 11 files changed, 802 insertions(+), 70 deletions(-) create mode 100644 .claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md create mode 100644 backend/migrations/068_ai_skill_retrieval_profiles.sql diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md index baada4d..ba8b968 100644 --- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md +++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md @@ -6,6 +6,8 @@ **Autor:** Claude Code **Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System +**Verwandt (Skill-Katalog in Übungs-KI):** `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md` — Tabelle **`ai_skill_retrieval_profiles`** (`config`-JSON ergänzt inhaltliche Prompt-/Katalog-Steuerung neben Platzhaltern). + --- ## 1. Konzept diff --git a/.claude/docs/technical/KI_FEATURES_SPEC.md b/.claude/docs/technical/KI_FEATURES_SPEC.md index 2b590e7..0ca3fb7 100644 --- a/.claude/docs/technical/KI_FEATURES_SPEC.md +++ b/.claude/docs/technical/KI_FEATURES_SPEC.md @@ -160,7 +160,38 @@ KI gibt Vorschläge Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde. Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen. -**Request Body:** +**Required Fields:** mindestens `goal` ODER `execution` + +**Optional – Skill-Katalogpriorisierung (Stand 068):** + +```json +{ + "focus_areas_context": [ + { "focus_area_id": 3, "is_primary": true }, + { "focus_area_id": 1, "is_primary": false } + ], + "focus_area_hint": "Karate, Kumite…" +} +``` + +- `focus_areas_context`: IDs aus Stammdatum **Fokusbereiche**; Primär soll zuerst stehen (`is_primary`). Ohne Feld oder leere Liste gilt das DB-Profil **`is_default`** (`ai_skill_retrieval_profiles`). +- `focus_area_hint`: bleibt lesbarer Text für den Prompt (bestehende Prompts). + + +**Minimal-Beispiel (Mit Fokus für Retrieval):** + +```json +{ + "title": "Maai - Distanzübung", + "goal": "…", + "execution": "…", + "focus_areas_context": [ { "focus_area_id": 1, "is_primary": true } ] +} +``` + + +**Minimal-Beispiel ( ohne Fokus — nur Texts):** + ```json { "title": "Maai - Distanzübung", @@ -169,8 +200,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen. } ``` -**Required Fields:** mindestens `goal` ODER `execution` (je länger, desto besser) - **Response:** `200 OK` ```json { diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 3b89aa4..c592e11 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -13,7 +13,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin | | exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC | | exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) | -| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; Sendung an OpenRouter | +| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; OpenRouter — suggest optional `focus_areas_context` für Retrieval-Profile | | exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar | | training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | | dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) | @@ -39,7 +39,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-22 — `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` (Übungs-KI, kein Persist durch Endpunkt). +Letzte Änderung: 2026-05-29 — gleiche Endpunkte; `POST /api/exercises/ai/suggest` ergänzt um optionales `focus_areas_context` für `ai_skill_retrieval_profiles` (Migration 068). --- diff --git a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md index 9c5e042..1fbc161 100644 --- a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md +++ b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md @@ -1,8 +1,8 @@ # Umsetzungsplan – KI bei Übungen (stufenweise, Driftschutz) -**Version:** 0.1 -**Datum:** 2026-05-22 -**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand) +**Version:** 0.2 +**Datum:** 2026-05-29 +**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · **`working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`** · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand) --- @@ -10,10 +10,11 @@ 1. **Spec vor Code:** Request/Response-Felder und Statuscodes an `KI_FEATURES_SPEC.md` ausrichten; Abweichungen zuerst Spec oder dieses Dokument anpassen. 2. **Prompts in der DB:** Keine produktionskritischen Prompt-Langtexte nur im Code; Defaults per **Migration** in `ai_prompts`, Anpassung durch Admins über vorgesehene Oberfläche (später) oder SQL. -3. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen. -4. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec). -5. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`. -6. **Schema:** Neue DB-Objekte nur nummerierte Migration `backend/migrations/067_*.sql` (oder folgend); `DB_SCHEMA_VERSION` in `backend/version.py` anheben. +3. **Skill-Retrieval-Profile:** Gewichte/Quotes in **`ai_skill_retrieval_profiles.config`** — Spezifikation `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; kein zweites gleichzeitiges Truth-Repo im Sourcecode außer defensiver Fallback `_FALLBACK_RETRIEVAL_CONFIG` in `exercise_ai.py`. +4. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen. +5. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec). +6. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`. +7. **Schema:** Neue DB-Objekte nur nummerierte Migration **`backend/migrations/`** (aktuell bis **068**) und `DB_SCHEMA_VERSION` anheben. --- @@ -26,10 +27,11 @@ | **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key | | **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key | | **S4** | Frontend: KI-Vorschlag, **Änderungsdialog** (Vorschau, Kurzfassung wählbar, Fähigkeiten pro Zeile an-/abwählbar), dann Übernahme ins Formular | Manuelle UX-Prüfung | +| **S4b** | **Skill-Retrieval:** Migration **`ai_skill_retrieval_profiles`**, `focus_areas_context` am **`POST …/ai/suggest`**, `exercise_ai` kontextbezogener Katalog (Gewichte, Caps, Keyword-Patches) | Migration 068 angelegt; Smoke mit Gewaltschutz / ohne Fokus | | **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config | | **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics | -**Aktueller Implementierungsstand nach Merge:** S0–S4 anstreben; S5/S6 nicht Teil dieses Laufs. +**Aktueller Implementierungsstand:** **S4 + S4b** im Code (`exercise_ai` + Formular übermittelt `focus_areas_context`). --- @@ -47,7 +49,7 @@ - **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad. - **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen. -- **2026-05-22:** UX: Übernahmedialog für KI-Vorschläge (Vorschau, selektive Übernahme) im Übungsformular (`ExerciseFormPageRoot`). +- **2026-05-29:** **S4b:** Migration **068**, `ai_skill_retrieval_profiles`; suggest `focus_areas_context`; Frontend sendet gesetzte Fokusbereiche; Spec `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`. --- @@ -55,7 +57,11 @@ **Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert). -**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen. +**Erledigt (2026-05-29):** Migration **`068`** / Profil **`ai_skill_retrieval_profiles`** (Standard + Profil Gewaltschutz wenn `focus_areas.name` vorhanden); **`exercise_ai`** — Score/Kategorie-Zapfen/Text-Overlap/Keyword-Zuschläge; **API:** `ExerciseAiSuggestBody.focus_areas_context`; **Regenerate** nutzt DB-Fokuszeilen. -**Bewusst noch nicht:** automatische KI beim Speichern (**S5**), Setzen von `summary_ai_generated` bei manuellen UI-Änderungen, Prompt-Admin-UI, Rate-Limits. +**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen. + +**Offen nächste Schritte Pflege/Umsetzung:** weitere Retrieval-Profile (z. B. Karate-/Fitness-Schwerpunkt) per SQL später Admin-UI; optionales Feld **`skills.ai_context`** Kurzbeschreibung für KI; automatische KI beim Speichern (**S5**); Prompt-/Profil-Admin-UI ohne SQL; Rate-Limits. + +**Bewusst noch nicht (`summary_ai_generated`):** zurücksetzen bei manueller Kurzfassung im UI; Admin-Pflege `ai_skill_retrieval_profiles`. diff --git a/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md new file mode 100644 index 0000000..b9411ec --- /dev/null +++ b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md @@ -0,0 +1,120 @@ +# KI Skill-Retrieval-Profile (`ai_skill_retrieval_profiles`) + +**Version:** 0.1 +**Datum:** 2026-05-29 +**Status:** Umsetzung gestartet (Migration **068**) +**Ziel:** Für `POST /api/exercises/ai/suggest` (Skill-Katalogauszug) **Gewichte und Quoten** steuerbar machen: + +- gebunden an **Übungs-Fokusbereich** (`focus_areas.id`), +- ein **Standardprofil** ohne Fokus, +- **optional zusammengeführte** Profile bei mehreren Fokusbereichen, +- **optional Keyword-Übersteuerungen** aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung). + +**Technische Basis:** Skills mit `skills.main_category_id` → `skill_main_categories.slug` (`karate` | `allgemeine`) und `skills.category_id` → `skill_categories.slug` (`kondition`, `selbstverteidigung`, …). + +**Bezüge:** `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md` · `backend/exercise_ai.py` + +--- + +## 1. Datenmodell + +### Tabelle `ai_skill_retrieval_profiles` + +| Spalte | Typ | Beschreibung | +|--------|-----|--------------| +| `id` | serial | Primärschlüssel | +| `focus_area_id` | int NULL FK → `focus_areas(id)` ON DELETE SET NULL | **`NULL`** nur für Standardeintrag möglich (siehe `is_default`) | +| `is_default` | boolean | Genau **eine** Zeile mit `true` | +| `name` | varchar | Kurzer Name (Admin später) | +| `description` | text | Hinweise für Pflege | +| `active` | boolean | Nur aktive werden geladen | +| `config` | jsonb | Siehe §2 | + +**Constraints / Indizes** + +- Eindeutig: `(focus_area_id)` WHERE `focus_area_id IS NOT NULL` +- Eindeutig: `(is_default)` WHERE `is_default = true` + +--- + +## 2. JSON-Konfiguration `config.version = 1` + +Alle Schlüssel **optional**; fehlende Werte fallen auf **einprogrammierten Fallback** in `exercise_ai.py` zurück (entspricht bisher grob „neutral“). + +### 2.1 Gewichtungen (Ranking) + +| Schlüssel | Typ | Bedeutung | +|-----------|-----|------------| +| `main_slug_weights` | `object[str, float]` | Multiplikator pro Hauptkategorie-Slug (`karate`, `allgemeine`) | +| `category_slug_weights` | `object[str, float]` | Multiplikator pro `skill_categories.slug` | + +Basis-Score (vereinfacht): +`(importance oder 3) × main_w × cat_w × text_overlap_bonus × importance_multiplier` + +### 2.2 Kapazitätsbegrenzung (Liste) + +`_MAX_SKILLS_CATALOG_LINES` (aktuell **240**) Zeilen Gesamt: + +| Schlüssel | Typ | Bedeutung | +|-----------|-----|------------| +| `category_max_share` | `object[str, float]` | Max. Anteil dieser **Unterkategorie** am Endergebnis (0–1), z. B. `{ "kondition": 0.25 }` | +| `main_min_share` | `object[str, float]` | Mindest-Zielanteil Hauptkategorie beim **Auswahl-Greedy** (weich; Rest nach Score aufgefüllt) | + +### 2.3 Text / Token-Sparen + +| Schlüssel | Typ | Standard | Bedeutung | +|-----------|-----|----------|------------| +| `description_plain_max_len` | int | 160 | Gekürzte Beschreibung pro Zeile | +| `karate_relevance_max_len` | int | **0** oder 80 | **`0`** = Feld `karate_relevance`/`relevance_level` in der Promptzeile **weglassen** | + +### 2.4 Keyword-Overrides (optional) + +Liste `keyword_overrides`: jedes Element: + +```json +{ + "keywords_any": ["befreiung", "haltegriff"], + "case_insensitive": true, + "patch": { + "category_slug_weights": { "selbstverteidigung": 2.5 }, + "category_max_share": { "koordination": 0.1 } + } +} +``` + +Textsuche in verkettetem Korpus **Titel, Ziel, Durchführung, Focus-Hint** (bereits plaintext). Reihenfolge: erst Basis-Profile zusammenmergen, dann **alle treffenden Overrides**‑`patch`‑Objekte **flach zusammenführen** (Gewichte multiplikativ übereinander, Caps den strengsten Wert nehmen – aktuelle Implementierung im Code dokumentiert). + +--- + +## 3. Mehrere Fokusbereiche auf der Übung + +Request-Body: `focus_areas_context: [{ "focus_area_id": n, "is_primary": bool }, …]` + +**Aktuelle Merge-Strategie (v1):** Profile laden → **gleichgewichtete Mittelwert-Bildung** der numerischen Gewichte / Caps (implementiert für `main_slug_weights`, `category_slug_weights`, `category_max_share`, `main_min_share`, `*_max_len`). Anschließend **Keyword-Overrides** anwenden. + +**Primär-Fokus:** Im Frontend soll die **primäre** Zeile aus `focus_areas_multi` **zuerst** in der Liste stehen; die Merge-Strategie kann später zu „Primär dominate“ erweitert werden. + +Ohne Kontext oder ohne Treffer auf aktive Profile: **nur Standardprofil** (`is_default`). + +--- + +## 4. Seed-Daten (Migration) + +- **`is_default=true`:** ausgewogene Standard-Gewichte, moderate Caps auf `kondition`/`koordination`, Karate-Relevanz gekürzt. +- **`Gewaltschutz`:** `focus_area_id` per `(SELECT id FROM focus_areas WHERE name = 'Gewaltschutz' LIMIT 1)` — höhere Gewichte für `kognition`, `psychische_faehigkeiten`, `soziale_faehigkeiten`, `selbstverteidigung`; gedrosseltes `kondition`/`koordination`; `karate_relevance_max_len`: 0; Keyword-Patches wie oben können nachgeschärft werden. + +Weitere Profile (Karate-Schwerpunkt etc.) später per Admin-SQL oder UI. + +--- + +## 5. API + +`ExerciseAiSuggestBody` erweitert um **`focus_areas_context`** (Liste). Feld **`focus_area_hint`** bleibt für den **Prompt-Kontext** (bestehende Prompts). + +`POST …/ai/regenerate` nutzt später dieselbe Retrieval-Logik aus den Detail-Daten der Übung (**To-do:** dort `focus_areas_context` aus `exercise_focus_areas` ableiten). + +--- + +## 6. Changelog + +- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration. diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py index 55e07ab..255ad4c 100644 --- a/backend/exercise_ai.py +++ b/backend/exercise_ai.py @@ -1,12 +1,16 @@ """ KI-Vorschlaege fuer Uebungsformular: Laedt Prompts aus ai_prompts, ruft OpenRouter auf. Keine persistente Aenderung an exercises — nur Response-DTO fuer das Frontend. + +Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, Fallback-Heuristik). """ from __future__ import annotations +import copy import json +import math import re -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple from fastapi import HTTPException @@ -24,6 +28,26 @@ _LEGACY_SKILL_LEVEL_SLUG = { } _ALLOWED_SKILL_INTENSITY = frozenset({"niedrig", "mittel", "hoch"}) +_TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE) +_TOKEN_FIND = re.compile(r"[a-zäöüß0-9]+", re.IGNORECASE) + +_MAX_PLAIN_FIELD = 28_000 +_MAX_SKILLS_CATALOG_LINES = 240 +_MAX_SUMMARY_CHARS = 220 + +_FALLBACK_RETRIEVAL_CONFIG: Dict[str, Any] = { + "version": 1, + "importance_multiplier": 1.0, + "text_overlap_bonus": 2.0, + "main_slug_weights": {"karate": 1.0, "allgemeine": 1.0}, + "category_slug_weights": {}, + "category_max_share": {"kondition": 0.38, "koordination": 0.35}, + "main_min_share": {}, + "description_plain_max_len": 160, + "karate_relevance_max_len": 72, + "keyword_overrides": [], +} + def _normalize_exercise_skill_level(value) -> Optional[str]: if value is None: @@ -50,12 +74,6 @@ def _normalize_exercise_skill_intensity(value) -> str: return key return "mittel" -_TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE) - -_MAX_PLAIN_FIELD = 28_000 -_MAX_SKILLS_CATALOG_LINES = 240 -_MAX_SUMMARY_CHARS = 220 - def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) -> str: if not html: @@ -67,6 +85,399 @@ def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) return t +def _corpus_tokens(*parts: str) -> frozenset: + hay = " ".join(p.strip() for p in parts if p and p.strip()) + ws = {_m.group(0).lower() for _m in _TOKEN_FIND.finditer(hay)} + return frozenset(w for w in ws if len(w) > 1) + + +def _ai_profiles_table_ready(cur) -> bool: + cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_skill_retrieval_profiles",)) + row = cur.fetchone() + if row is None: + return False + val = row["t"] if isinstance(row, dict) else row[0] + return val is not None and str(val).strip() != "" + + +def _average_float_dict(dicts: Sequence[Mapping[str, Any]], *, fallback: float) -> Dict[str, float]: + keys: set = set() + for d in dicts: + keys |= set(d.keys()) + out: Dict[str, float] = {} + for k in keys: + vals = [] + for d in dicts: + if k not in d or d[k] is None: + continue + try: + vals.append(float(d[k])) + except (TypeError, ValueError): + continue + out[k] = (sum(vals) / len(vals)) if vals else fallback + return out + + +def _merge_retrieval_configs(configs: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + base = copy.deepcopy(_FALLBACK_RETRIEVAL_CONFIG) + if not configs: + return base + + base["main_slug_weights"] = _average_float_dict( + [c.get("main_slug_weights") or {} for c in configs], + fallback=1.0, + ) + for slug in ("karate", "allgemeine"): + base["main_slug_weights"].setdefault(slug, 1.0) + + base["category_slug_weights"] = _average_float_dict( + [c.get("category_slug_weights") or {} for c in configs], + fallback=1.0, + ) + base["category_max_share"] = _average_float_dict( + [c.get("category_max_share") or {} for c in configs], + fallback=1.0, + ) + base["main_min_share"] = _average_float_dict( + [c.get("main_min_share") or {} for c in configs], + fallback=0.0, + ) + + ims = [] + tbs = [] + dmx = [] + krm = [] + for c in configs: + try: + if c.get("importance_multiplier") is not None: + ims.append(float(c["importance_multiplier"])) + except (TypeError, ValueError): + continue + try: + if c.get("text_overlap_bonus") is not None: + tbs.append(float(c["text_overlap_bonus"])) + except (TypeError, ValueError): + continue + try: + if c.get("description_plain_max_len") is not None: + dmx.append(int(c["description_plain_max_len"])) + except (TypeError, ValueError): + continue + try: + if c.get("karate_relevance_max_len") is not None: + krm.append(int(c["karate_relevance_max_len"])) + except (TypeError, ValueError): + continue + if ims: + base["importance_multiplier"] = sum(ims) / len(ims) + if tbs: + base["text_overlap_bonus"] = sum(tbs) / len(tbs) + if dmx: + base["description_plain_max_len"] = int(round(sum(dmx) / len(dmx))) + if krm: + base["karate_relevance_max_len"] = int(round(sum(krm) / len(krm))) + + overrides: List[Any] = [] + for c in configs: + overrides.extend(c.get("keyword_overrides") or []) + base["keyword_overrides"] = overrides + return base + + +def _mul_weight_dict(target: MutableMapping[str, float], patch: Mapping[str, Any]) -> None: + for k, v in patch.items(): + try: + mul = float(v) + except (TypeError, ValueError): + continue + target[k] = float(target.get(k, 1.0)) * mul + + +def _apply_keyword_overrides(cfg: Dict[str, Any], corpus_lower: str) -> None: + caps = cfg.setdefault("category_max_share", {}) + for ov in cfg.get("keyword_overrides") or []: + keys_any = ov.get("keywords_any") or [] + if not keys_any or not corpus_lower.strip(): + continue + hay = corpus_lower.lower() if corpus_lower else "" + hit = False + for kw in keys_any: + ks = str(kw or "").strip() + if not ks: + continue + ks_l = ks.lower() + hit = ks_l in hay + if hit: + break + if not hit: + continue + patch = ov.get("patch") or {} + _mul_weight_dict(cfg.setdefault("category_slug_weights", {}), patch.get("category_slug_weights") or {}) + _mul_weight_dict(cfg.setdefault("main_slug_weights", {}), patch.get("main_slug_weights") or {}) + for slug, mx in (patch.get("category_max_share") or {}).items(): + try: + mx_f = float(mx) + except (TypeError, ValueError): + continue + cur = float(caps.get(slug, 1.0)) + caps[slug] = min(cur, mx_f) + + +def _ordered_focus_ids(focus_ctx: Optional[Sequence[Tuple[int, bool]]]) -> List[int]: + """Primär zuerst, dann stabil nach ID.""" + if not focus_ctx: + return [] + seen = set() + ordered: List[Tuple[int, bool]] = [] + for fid, isp in sorted(focus_ctx, key=lambda x: (not x[1], x[0])): + try: + i = int(fid) + except (TypeError, ValueError): + continue + if i < 1 or i in seen: + continue + seen.add(i) + ordered.append((i, bool(isp))) + return [fid for fid, _ in ordered] + + +def _load_merged_retrieval_config( + cur, focus_ctx: Optional[Sequence[Tuple[int, bool]]] +) -> Dict[str, Any]: + if not _ai_profiles_table_ready(cur): + return copy.deepcopy(_FALLBACK_RETRIEVAL_CONFIG) + + loaded: List[Dict[str, Any]] = [] + for fid in _ordered_focus_ids(focus_ctx): + cur.execute( + """ + SELECT config + FROM ai_skill_retrieval_profiles + WHERE active = true AND focus_area_id = %s + LIMIT 1 + """, + (fid,), + ) + rw = cur.fetchone() + if not rw: + continue + raw = rw["config"] if isinstance(rw, dict) else rw[0] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + continue + if isinstance(raw, dict): + loaded.append(raw) + + if not loaded: + cur.execute( + """ + SELECT config + FROM ai_skill_retrieval_profiles + WHERE active = true AND is_default = true + LIMIT 1 + """ + ) + rw = cur.fetchone() + if rw: + raw = rw["config"] if isinstance(rw, dict) else rw[0] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + raw = None + if isinstance(raw, dict): + loaded.append(raw) + + return _merge_retrieval_configs(loaded) + + +def _fetch_all_active_skills_for_catalog(cur) -> List[Dict[str, Any]]: + cur.execute( + """ + SELECT s.id, + s.name, + s.category, + s.description, + s.karate_relevance, + s.relevance_level, + s.importance, + COALESCE(m.slug, '') AS main_slug, + COALESCE(c.slug, '') AS category_slug, + c.name AS subcategory_name + FROM skills s + LEFT JOIN skill_main_categories m ON m.id = s.main_category_id + LEFT JOIN skill_categories c ON c.id = s.category_id + WHERE (s.status IS NULL OR s.status = 'active') + """ + ) + return [dict(r) for r in cur.fetchall()] + + +def _score_skill_row( + row: Mapping[str, Any], + cfg: Mapping[str, Any], + corpus_tokens: frozenset, +) -> float: + main_slug = str(row.get("main_slug") or "").strip().lower() + cat_slug = str(row.get("category_slug") or "").strip().lower() + main_w = float((cfg.get("main_slug_weights") or {}).get(main_slug, 1.0)) + cat_w = float((cfg.get("category_slug_weights") or {}).get(cat_slug, 1.0)) + try: + imp = int(row["importance"]) if row.get("importance") is not None else 3 + except (TypeError, ValueError): + imp = 3 + imp = max(1, min(5, imp)) + imp_mult = float(cfg.get("importance_multiplier") or 1.0) + base = float(imp) * imp_mult * max(main_w, 0.05) * max(cat_w, 0.05) + + name = strip_html_to_plain(row.get("name"), max_len=400) + dsc = strip_html_to_plain(row.get("description"), max_len=520) + search_blob = " ".join( + [ + name, + dsc, + cat_slug.replace("_", " "), + str(row.get("category") or ""), + str(row.get("subcategory_name") or ""), + ] + ).lower() + + overlaps = sum(1 for t in corpus_tokens if t and t in search_blob) + tob = float(cfg.get("text_overlap_bonus") or 0.0) + + return base + overlaps * tob + + +def _category_cap_limits(cfg: Mapping[str, Any], n_max: int) -> Dict[str, int]: + out: Dict[str, int] = {} + mx = cfg.get("category_max_share") or {} + if not isinstance(mx, dict): + return out + for slug, raw in mx.items(): + ks = str(slug or "").strip() + if not ks: + continue + try: + sh = float(raw) + except (TypeError, ValueError): + continue + if 0 < sh < 1.0: + out[ks] = max(1, int(math.floor(sh * n_max))) + elif sh >= 1.0: + out[ks] = n_max + 99999 + else: + continue + return out + + +def _pick_catalog_rows(rows_scored: List[Tuple[float, Dict[str, Any]]], cfg: Mapping[str, Any]) -> List[Dict[str, Any]]: + """rows_scored: (score, row_dict) ohne Sortierung-Anforderung.""" + cap_limits = _category_cap_limits(cfg, _MAX_SKILLS_CATALOG_LINES) + ordered = sorted(rows_scored, key=lambda x: (-x[0], str(x[1].get("name") or ""))) + picked: List[Dict[str, Any]] = [] + picked_ids: set = set() + cat_counts: Dict[str, int] = {} + + def under_cap(cat_slug: str) -> bool: + if not cat_slug or cat_slug not in cap_limits: + return True + return cat_counts.get(cat_slug, 0) < cap_limits[cat_slug] + + # Pass 1: Cap respektieren + for _sc, rw in ordered: + if len(picked) >= _MAX_SKILLS_CATALOG_LINES: + break + sid = rw["id"] + if sid in picked_ids: + continue + cslug = str(rw.get("category_slug") or "").strip().lower() + if cslug and not under_cap(cslug): + continue + picked.append(rw) + picked_ids.add(sid) + if cslug: + cat_counts[cslug] = cat_counts.get(cslug, 0) + 1 + + # Pass 2: auffüllen + if len(picked) < _MAX_SKILLS_CATALOG_LINES: + for _sc, rw in ordered: + if len(picked) >= _MAX_SKILLS_CATALOG_LINES: + break + sid = rw["id"] + if sid in picked_ids: + continue + picked.append(rw) + picked_ids.add(sid) + + return picked[:_MAX_SKILLS_CATALOG_LINES] + + +def _format_skill_catalog_line(row: Mapping[str, Any], cfg: Mapping[str, Any]) -> str: + rid = int(row["id"]) + nm = (row.get("name") or "").strip() or f"Skill #{rid}" + cat_legacy = str(row.get("category") or "").strip() + sub = str(row.get("subcategory_name") or "").strip() + main_slug = str(row.get("main_slug") or "").strip() + cats = " / ".join(x for x in (main_slug.upper() if main_slug else "", cat_legacy, sub) if x) + + dmax = int(cfg.get("description_plain_max_len") or 160) + dsc = strip_html_to_plain(row.get("description"), max_len=max(40, min(400, dmax))) + + krmax = int(cfg.get("karate_relevance_max_len") or 0) + kr = strip_html_to_plain(row.get("karate_relevance"), max_len=min(280, krmax)) if krmax > 0 else "" + rel = row.get("relevance_level") + rel_s = str(rel).strip() if rel is not None else "" + + parts = [ + f"- id={rid} | name={nm}", + f" | kategorie={cats or '-'}", + f" | beschreibung={dsc or '-'}", + ] + if krmax > 0 and (kr.strip() or rel_s): + parts.append(f" | karate_relevanz={kr or '-'} | relevanz_stufe={rel_s or '-'}") + return "".join(parts) + + +def _safe_int_importance(value: Any) -> int: + try: + iv = int(value) + except (TypeError, ValueError): + return 0 + return max(1, min(5, iv)) if iv else 0 + + +def build_contextual_skills_catalog_block( + cur, + *, + title: Optional[str], + goal_plain: str, + execution_plain: str, + focus_hint: Optional[str], + focus_ctx: Optional[Sequence[Tuple[int, bool]]], +) -> str: + cfg = _load_merged_retrieval_config(cur, focus_ctx) + corpus_lower = " ".join([title or "", goal_plain or "", execution_plain or "", focus_hint or ""]).lower() + _apply_keyword_overrides(cfg, corpus_lower) + + tok = _corpus_tokens(title or "", goal_plain, execution_plain, focus_hint or "") + skill_rows = _fetch_all_active_skills_for_catalog(cur) + scored: List[Tuple[float, Dict[str, Any]]] = [] + for r in skill_rows: + scored.append((_score_skill_row(r, cfg, tok), r)) + picked = _pick_catalog_rows(scored, cfg) + picked.sort( + key=lambda r: ( + -_safe_int_importance(r.get("importance")), + str(r.get("name") or "").lower(), + ) + ) + + lines = [_format_skill_catalog_line(row, cfg) for row in picked] + return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" + + def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]: cur.execute( """ @@ -93,56 +504,17 @@ def _render_template(template: str, ctx: Dict[str, str]) -> str: return out -def _build_skills_catalog_block(cur) -> str: - cur.execute( - """ - SELECT s.id, s.name, s.category, s.description, s.karate_relevance, s.relevance_level, - sc.name AS subcategory_name - FROM skills s - LEFT JOIN skill_categories sc ON s.category_id = sc.id - WHERE (s.status IS NULL OR s.status = 'active') - ORDER BY s.importance DESC NULLS LAST, s.name - LIMIT %s - """, - (_MAX_SKILLS_CATALOG_LINES,), - ) - lines: List[str] = [] - for r in cur.fetchall(): - rid = int(r["id"]) - nm = (r.get("name") or "").strip() or f"Skill #{rid}" - cat = (r.get("category") or "").strip() - sub = (r.get("subcategory_name") or "").strip() - dsc = strip_html_to_plain(r.get("description"), max_len=320) - kr = strip_html_to_plain(r.get("karate_relevance"), max_len=200) - rel = r.get("relevance_level") - rel_s = "" - if rel is not None: - rel_s = str(rel) - - cats = " / ".join(x for x in (cat, sub) if x) - - blob = ( - f"- id={rid} | name={nm} | kategorie={cats or '-'}" - f" | beschreibung={dsc or '-'} | karate_relevanz={kr or '-'}" - f" | relevanz_stufe={rel_s or '-'}" - ) - lines.append(blob) - return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" - - def _extract_json_array(text: str) -> Any: s = text.strip() if s.startswith("```"): s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) if s.endswith("```"): s = s[:-3].strip() - # array whole string if s.startswith("["): end = s.rfind("]") if end > 0: s = s[: end + 1] return json.loads(s) - # object wrapping array if s.startswith("{"): obj = json.loads(s) if isinstance(obj, dict): @@ -219,7 +591,6 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]: item["confidence"] = conf_f out.append(item) - # max 5 return out[:5] @@ -240,6 +611,7 @@ def run_exercise_ai_suggestion( goal: Optional[str], execution: Optional[str], focus_area_hint: Optional[str], + focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None, want_summary: bool, want_skills: bool, ) -> Dict[str, Any]: @@ -285,7 +657,14 @@ def run_exercise_ai_suggestion( status_code=503, detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.", ) - catalog = _build_skills_catalog_block(cur) + catalog = build_contextual_skills_catalog_block( + cur, + title=t_title, + goal_plain=g_plain, + execution_plain=e_plain, + focus_hint=focus or None, + focus_ctx=focus_areas_context, + ) ctx = { "exercise_title": t_title or "-", "exercise_focus_area": focus or "-", @@ -318,3 +697,10 @@ def run_exercise_ai_suggestion( result["skills"] = skills return result + + +__all__ = [ + "build_contextual_skills_catalog_block", + "run_exercise_ai_suggestion", + "strip_html_to_plain", +] diff --git a/backend/migrations/068_ai_skill_retrieval_profiles.sql b/backend/migrations/068_ai_skill_retrieval_profiles.sql new file mode 100644 index 0000000..51fef7c --- /dev/null +++ b/backend/migrations/068_ai_skill_retrieval_profiles.sql @@ -0,0 +1,125 @@ +-- Migration 068: KI Skill-Retrieval-Profile pro Fokusbereich (+ Standardprofil) +-- Purpose: Gewichtungen/Quota fuer exercise_ai Skill-Katalog (OpenRouter Kontext) + +CREATE TABLE IF NOT EXISTS ai_skill_retrieval_profiles ( + id SERIAL PRIMARY KEY, + focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + name VARCHAR(200) NOT NULL, + description TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_focus_area + ON ai_skill_retrieval_profiles (focus_area_id) + WHERE focus_area_id IS NOT NULL AND active = TRUE; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_default_only + ON ai_skill_retrieval_profiles (is_default) + WHERE is_default IS TRUE AND active = TRUE; + +COMMENT ON TABLE ai_skill_retrieval_profiles IS + 'Gewichte/Quota fuer Skill-Katalog in exercise_ai; optional gebunden an focus_areas, genau eine is_default=TRUE'; + +INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config) +VALUES ( + NULL, + TRUE, + 'Standard', + 'Kein/Undefinierter Fokusbereich: neutrale Gewichte mit sanften Caps auf sehr breite Unterkategorien.', + TRUE, + '{ + "version": 1, + "importance_multiplier": 1, + "text_overlap_bonus": 2, + "main_slug_weights": { "karate": 1, "allgemeine": 1 }, + "category_slug_weights": {}, + "category_max_share": { + "kondition": 0.38, + "koordination": 0.35 + }, + "main_min_share": {}, + "description_plain_max_len": 160, + "karate_relevance_max_len": 72, + "keyword_overrides": [ + { + "keywords_any": ["rollenspiel", "szenario", "deesk", "diskussion"], + "case_insensitive": true, + "patch": { + "category_slug_weights": { + "psychische_faehigkeiten": 1.65, + "soziale_faehigkeiten": 1.65, + "kognition": 1.4 + }, + "category_max_share": { + "kondition": 0.08, + "koordination": 0.1 + } + } + }, + { + "keywords_any": ["befreiung", "haltegriff", "greifer", "umklammer"], + "case_insensitive": true, + "patch": { + "category_slug_weights": { + "selbstverteidigung": 2.2, + "koordination": 0.9 + }, + "main_slug_weights": { "karate": 1.35 } + } + } + ] + }'::jsonb +); + +INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config) +SELECT + fa.id, + FALSE, + 'Gewaltschutz', + 'Kaum klassische Sportfaehigkeit; Gewicht auf Deeskalation, Kognition/Soziales; SV-Schwerpunkt per Keywords verstaerken.', + TRUE, + '{ + "version": 1, + "importance_multiplier": 1, + "text_overlap_bonus": 2.25, + "main_slug_weights": { "karate": 1.08, "allgemeine": 1.06 }, + "category_slug_weights": { + "kognition": 1.72, + "psychische_faehigkeiten": 1.78, + "soziale_faehigkeiten": 1.78, + "selbstverteidigung": 1.82, + "kondition": 0.32, + "koordination": 0.4 + }, + "category_max_share": { + "kondition": 0.12, + "koordination": 0.16 + }, + "main_min_share": {}, + "description_plain_max_len": 150, + "karate_relevance_max_len": 0, + "keyword_overrides": [ + { + "keywords_any": ["befreiung", "haltegriff", "greifer"], + "case_insensitive": true, + "patch": { + "category_slug_weights": { + "selbstverteidigung": 3.25, + "koordination": 1.08 + }, + "main_slug_weights": { "karate": 1.5 } + } + } + ] + }'::jsonb +FROM focus_areas fa +WHERE fa.name = 'Gewaltschutz' + AND (fa.status IS NULL OR fa.status = 'active') + AND NOT EXISTS ( + SELECT 1 FROM ai_skill_retrieval_profiles p + WHERE p.focus_area_id = fa.id AND p.active = TRUE + ) +LIMIT 1; diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 8b9c5a6..d79b0b9 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -358,11 +358,22 @@ class ExerciseMediaFromAsset(BaseModel): media_type: Optional[str] = None +class ExerciseAiFocusCtx(BaseModel): + """Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles).""" + + focus_area_id: int = Field(..., ge=1) + is_primary: Optional[bool] = False + + class ExerciseAiSuggestBody(BaseModel): title: Optional[str] = Field(None, max_length=300) goal: Optional[str] = Field(None, max_length=64000) execution: Optional[str] = Field(None, max_length=128000) focus_area_hint: Optional[str] = Field(None, max_length=1200) + focus_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field( + None, + description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung", + ) include_summary: bool = True include_skills: bool = True @@ -2254,6 +2265,22 @@ def list_exercises_like_get( ) +def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]: + rows: list[tuple[int, bool]] = [] + for row in exercise.get("focus_areas") or []: + if not isinstance(row, dict): + continue + try: + fid = int(row.get("focus_area_id")) + except (TypeError, ValueError): + continue + if fid < 1: + continue + rows.append((fid, bool(row.get("is_primary")))) + rows.sort(key=lambda x: (not x[1], x[0])) + return rows + + def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str: parts: List[str] = [] for row in exercise.get("focus_areas") or []: @@ -2279,12 +2306,17 @@ def exercise_ai_suggest_endpoint( _ = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) + fctx = None + if body.focus_areas_context: + fctx = [(x.focus_area_id, bool(x.is_primary)) for x in body.focus_areas_context] + payload = run_exercise_ai_suggestion( cur, title=(body.title or "").strip(), goal=body.goal, execution=body.execution, focus_area_hint=(body.focus_area_hint or "").strip() or None, + focus_areas_context=fctx, want_summary=body.include_summary, want_skills=body.include_skills, ) @@ -2310,6 +2342,7 @@ def exercise_ai_regenerate_endpoint( raise HTTPException(status_code=404, detail="Übung nicht gefunden") focus = _focus_area_hint_from_detail(exercise) + fctx = _focus_areas_ai_ctx_from_detail(exercise) payload = run_exercise_ai_suggestion( cur, @@ -2317,6 +2350,7 @@ def exercise_ai_regenerate_endpoint( goal=exercise.get("goal"), execution=exercise.get("execution"), focus_area_hint=focus or None, + focus_areas_context=fctx or None, want_summary=want_summary, want_skills=want_skills, ) diff --git a/backend/version.py b/backend/version.py index 70d9a29..c0896ac 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.152" -BUILD_DATE = "2026-05-22" -DB_SCHEMA_VERSION = "20260522067" +APP_VERSION = "0.8.153" +BUILD_DATE = "2026-05-29" +DB_SCHEMA_VERSION = "20260529068" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -22,7 +22,7 @@ MODULE_VERSIONS = { "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", - "exercises": "2.29.0", # POST exercises/ai/suggest + …/ai/regenerate (OpenRouter); exercise_ai; is_primary fuer exercise_skills + "exercises": "2.30.0", # Migration 068 ai_skill_retrieval_profiles; suggest focus_areas_context; exercise_ai Kontext-Katalog + Gewichtungen "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -37,6 +37,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.153", + "date": "2026-05-29", + "changes": [ + "Migration 068: ai_skill_retrieval_profiles — konfigurierbare Gewichte/Quotes fuer Skill-Katalog in exercise_ai", + "POST /api/exercises/ai/suggest: optionales Body-Feld focus_areas_context; regenerate nutzt gespeicherte Fokusbereiche", + "exercise_ai: kontextbezogene Skill-Auswahl (Score, Kategorie-Caps), Keyword-Patches wie Rollenspiel vs. Haltegriff/Befreiung", + ], + }, { "version": "0.8.152", "date": "2026-05-22", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 741eed2..fba1dad 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-05-20 -**App-Version / DB-Schema:** App **`0.8.149`** (Einheiten-Editor Vollseite), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION` +**Stand:** 2026-05-29 +**App-Version / DB-Schema:** App **`0.8.153`** (KI Skill-Retrieval-Profile), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION` Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. @@ -88,6 +88,14 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions +### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.153**) + +- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; API-Felder **`KI_FEATURES_SPEC.md`** §5.2 +- **DB:** Migration **`068`** – Tabelle **`ai_skill_retrieval_profiles`** (Konfig **`config`**) mit Seed „Standard“ + „Gewaltschutz“ (wenn Focus `Gewaltschutz` in `focus_areas` existiert) +- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff) +- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** verwendet gespeicherte `exercise_focus_areas` automatisch für dieselbe Retrieval-Logik +- **Frontend:** `ExerciseFormPageRoot.jsx` übergibt `focus_areas_context` aus Einordnung; KI-Übernahmedialog nach API-Antwort + --- ## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz) diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 3dcbfd4..13f9329 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -980,6 +980,18 @@ function ExerciseFormPageRoot() { const snapshotSummaryHtml = formData.summary || '' const snapshotSkills = cloneExerciseSkillRows(formData.skills) + const focusAreasContext = [...(formData.focus_areas_multi || [])] + .map((row) => ({ + focus_area_id: Number(row?.focus_area_id), + is_primary: !!row?.is_primary, + })) + .filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1) + .sort((a, b) => { + const p = Number(!!b.is_primary) - Number(!!a.is_primary) + if (p !== 0) return p + return a.focus_area_id - b.focus_area_id + }) + setAiSuggestBusy(true) try { const res = await api.suggestExerciseAi({ @@ -987,6 +999,7 @@ function ExerciseFormPageRoot() { goal: formData.goal || '', execution: formData.execution || '', focus_area_hint: focusHint || undefined, + focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined, include_summary: summaryOn, include_skills: skillsOn, }) -- 2.43.0 From 286c36e9d7b32ffa8f0440b4baa8c88604fe239f Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 09:57:39 +0200 Subject: [PATCH 05/10] Document Superadmin API for AI Skill Retrieval Profiles and Update Access Layer - Added documentation for the new Superadmin CRUD endpoints for managing AI Skill Retrieval Profiles (`/api/admin/ai-skill-retrieval-profiles*`). - Updated the ACCESS_LAYER_ENDPOINT_AUDIT.md to include the new Superadmin API and its exempt status. - Registered the ai_skill_retrieval_admin router in the backend and updated versioning to reflect the changes. - Enhanced the frontend with a new Admin page for AI Skill Retrieval, including navigation and API integration for profile management. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 4 +- .../AI_SKILL_RETRIEVAL_PROFILES_SPEC.md | 5 +- backend/main.py | 3 +- backend/routers/ai_skill_retrieval_admin.py | 370 +++++++++++++++ backend/scripts/check_access_layer_hints.py | 1 + backend/version.py | 11 +- docs/HANDOVER.md | 8 +- frontend/src/App.jsx | 9 + frontend/src/components/AdminPageNav.jsx | 3 +- .../src/pages/AdminAiSkillRetrievalPage.jsx | 435 ++++++++++++++++++ frontend/src/utils/api.js | 32 ++ 11 files changed, 871 insertions(+), 10 deletions(-) create mode 100644 backend/routers/ai_skill_retrieval_admin.py create mode 100644 frontend/src/pages/AdminAiSkillRetrievalPage.jsx diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index c592e11..1937e1d 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -34,17 +34,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT | | matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT | | import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT | +| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug | **Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-29 — gleiche Endpunkte; `POST /api/exercises/ai/suggest` ergänzt um optionales `focus_areas_context` für `ai_skill_retrieval_profiles` (Migration 068). +Letzte Änderung: 2026-05-29 — Superadmin-CRUD `/api/admin/ai-skill-retrieval-profiles*` dokumentiert; `POST /api/exercises/ai/suggest` mit optionalem `focus_areas_context` (Migration 068). --- ### Changelog (Fortführung) +- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert. - **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert. - **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert. diff --git a/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md index b9411ec..bbe719a 100644 --- a/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md +++ b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md @@ -111,10 +111,11 @@ Weitere Profile (Karate-Schwerpunkt etc.) später per Admin-SQL oder UI. `ExerciseAiSuggestBody` erweitert um **`focus_areas_context`** (Liste). Feld **`focus_area_hint`** bleibt für den **Prompt-Kontext** (bestehende Prompts). -`POST …/ai/regenerate` nutzt später dieselbe Retrieval-Logik aus den Detail-Daten der Übung (**To-do:** dort `focus_areas_context` aus `exercise_focus_areas` ableiten). +`POST …/ai/regenerate` nutzt gespeicherte `exercise_focus_areas` zur gleichen Retrieval-Logik wie Suggest. ---- +**Pflege der Profile:** Superadmin ohne Mandantenwahl — **`GET|POST /api/admin/ai-skill-retrieval-profiles`**, **`GET|PUT|DELETE /api/admin/ai-skill-retrieval-profiles/{id}`** (`routers/ai_skill_retrieval_admin.py`); Web-UI Superadmin unter **`/admin/ai-skill-retrieval`**. ## 6. Changelog +- **2026-05-29:** Superadmin-Pflege-Endpoints + UI‑Route dokumentiert (`/admin/ai-skill-retrieval`). - **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration. diff --git a/backend/main.py b/backend/main.py index d15db0b..32e5519 100644 --- a/backend/main.py +++ b/backend/main.py @@ -193,7 +193,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_skill_retrieval_admin app.include_router(auth.router) app.include_router(profiles.router) @@ -220,6 +220,7 @@ app.include_router(import_wiki.router) app.include_router(import_wiki_admin.router) app.include_router(legal_documents.router) app.include_router(content_reports.router) +app.include_router(ai_skill_retrieval_admin.router) # Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad # GET /api/exercises/{id}/media/{mid}/file (?ssetoken für /