Compare commits
287 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea7de64061 | |||
| 7265cd5a01 | |||
| 5e5f4ca8d4 | |||
| f0e581a9f5 | |||
| cd457e3ea0 | |||
| e9bf5bd1a5 | |||
| 3468b2066e | |||
| a1e4ad66df | |||
| 85fccdd093 | |||
| 19bbcdaf50 | |||
| cec96ae473 | |||
| 53f1c7161f | |||
| 89c6780294 | |||
| 3f130aa8ad | |||
| 69ce3f6975 | |||
| dccb065181 | |||
| e828a5da32 | |||
| 5bca5ef9eb | |||
| 5ed06002d9 | |||
| b8f65e04c5 | |||
| f3710ac0a1 | |||
| dbc2dfacb9 | |||
| 6ab2f20f08 | |||
| a4e73c830f | |||
| 63c99b0ec5 | |||
| d448c3191f | |||
| 8a4be795f4 | |||
| a49987408b | |||
| f36a747efa | |||
| de9fdf3ac0 | |||
| 9b4d091637 | |||
| df93da9a03 | |||
| de939481ba | |||
| 6d130a7e09 | |||
| b2fbf6b4af | |||
| ca2adbd55e | |||
| ad051c015f | |||
| b464047c3a | |||
| 7203c871fc | |||
| 480890d0c6 | |||
| 8f1dad53ab | |||
| 044ce2ee60 | |||
| f63b09fc9c | |||
| 713a344d17 | |||
| 1d94c2ebf1 | |||
| a152218c45 | |||
| 4ef3f00e6b | |||
| 3c12363b8f | |||
| 07e147bc76 | |||
| 18547613ea | |||
| 48d51c07c5 | |||
| 3b483346de | |||
| e0ddfa6ce5 | |||
| ee22b22970 | |||
| c1bf9279ad | |||
| 97efe66306 | |||
| 8d5f0b533c | |||
| 800189ff8f | |||
| 3be7606d90 | |||
| ca3a9c6fa4 | |||
| 5692931d07 | |||
| 98b279fa89 | |||
| 1e7941f57b | |||
| 0adf20c9e1 | |||
| 4724da28b1 | |||
| d4b1780193 | |||
| f2650dac57 | |||
| fad1058d54 | |||
| 9dd44ce3ca | |||
| 87f258be38 | |||
| 779e2477ba | |||
| f074a8bef0 | |||
| 0677663268 | |||
| d4e9bded23 | |||
| 7411543a97 | |||
| dd0fae4bf5 | |||
| a9a6153ed5 | |||
| 4130a63dfe | |||
| 9d52aeab67 | |||
| b68185842e | |||
| 40641594ac | |||
| e4cb491d46 | |||
| 8404a42b6c | |||
| fa10450315 | |||
| 37785135b1 | |||
| 8ee8f52e0f | |||
| 8718cf5c70 | |||
| 91dae7b614 | |||
| 20927a5969 | |||
| 7db77f4738 | |||
| 3e87f7515a | |||
| a2f60d3f46 | |||
| 30dc30c7aa | |||
| 7cfbca40bb | |||
| c294c27de8 | |||
| 50c9beb4b3 | |||
| bd5a409fa7 | |||
| 3450a9296a | |||
| 29a5db63e0 | |||
| 8d1dd59c3c | |||
| 5b73d1a1f5 | |||
| c2c736dafc | |||
| c6b8c396ad | |||
| a19ed02300 | |||
| 6db31e7312 | |||
| a34e748be5 | |||
| 16187fbbd0 | |||
| b2157d8a40 | |||
| 50aff849d8 | |||
| a0a891e550 | |||
| 9ba35dc022 | |||
| 46fae3da33 | |||
| f4196c3580 | |||
| d1d8539b42 | |||
| a8633235f2 | |||
| 5c882985e0 | |||
| 04cc77d501 | |||
| 8e68261bc1 | |||
| b0611b9f7f | |||
| 614c2dcfaa | |||
| f5c886fc13 | |||
| d019c20338 | |||
| 905bce198f | |||
| 45e3b5f4f6 | |||
| 207817376d | |||
| 128a9d752e | |||
| d7d45a8927 | |||
| fc5748bef1 | |||
| 9d880e2346 | |||
| c816e50c68 | |||
| 294740b780 | |||
| 675cfa85f0 | |||
| 4725eaa90b | |||
| 9f4678f418 | |||
| 5331eab39c | |||
| 93b8d09d05 | |||
| 0551bb3d80 | |||
| 3bf012a8f4 | |||
| e22266a18c | |||
| d58db3d5dd | |||
| cdeddc7cec | |||
| 2148d0aa7f | |||
| 69f238d9b8 | |||
| f9e295bce0 | |||
| 888d0bd009 | |||
| 1942585546 | |||
| a28a9d399a | |||
| 9be69ace5c | |||
| 286c36e9d7 | |||
| 294b09a5d9 | |||
| e5291256d0 | |||
| 4d36bbf634 | |||
| e4451e1362 | |||
| a1b85cd865 | |||
| 7245bbb1da | |||
| 5f67c01cef | |||
| 4720d70af0 | |||
| 13a1d3a060 | |||
| 7f62b6ceee | |||
| 9b3f594007 | |||
| 5d308b20ba | |||
| 1d698e4b0a | |||
| 57c464c9f6 | |||
| a7a428745f | |||
| 2d187447bb | |||
| 2de4c0b7c9 | |||
| 34966b9e84 | |||
| 9a0cf7f823 | |||
| 78c6c51520 | |||
| 5200895a73 | |||
| 8f8bdf6d8b | |||
| f67bf280c3 | |||
| 732b322c52 | |||
| d42eb3ac52 | |||
| e382b6ed35 | |||
| a4548f5587 | |||
| 9d122d4808 | |||
| 9c3494a7ea | |||
| 9353909fda | |||
| 5a8a212f40 | |||
| f9df2d31db | |||
| ab612a5335 | |||
| b2f77ca627 | |||
| 39b1fd04f0 | |||
| 9020e5eb16 | |||
| 46feb4c867 | |||
| 3067b2e6a8 | |||
| 1e2fdeeb0f | |||
| 728b37ad5f | |||
| 8afdd811db | |||
| 4588ef4c7e | |||
| 6e6270b717 | |||
| 14b005e9b8 | |||
| ef4dd93324 | |||
| 7450c269a5 | |||
| e50c18f92e | |||
| d19a1061d8 | |||
| 99a5fccaa5 | |||
| cb868373f4 | |||
| 472cf1afdb | |||
| 0cb0e81d27 | |||
| 6a9351874f | |||
| 734d943d73 | |||
| 16eaf839e7 | |||
| 295c7e7efc | |||
| c9175bd2fd | |||
| f15aa7c415 | |||
| 1684892bcb | |||
| 4fee5a2b47 | |||
| 82705f0c3e | |||
| a51f794945 | |||
| 7693139242 | |||
| 55d87d8d17 | |||
| 623af621b4 | |||
| 949a77fe38 | |||
| 0275f76432 | |||
| bc1790bd82 | |||
| b35a5ae216 | |||
| 8c07cf36ee | |||
| 7d2661a8e8 | |||
| 0fdee610ed | |||
| f1c470a8a3 | |||
| 736656bde8 | |||
| e441f59bff | |||
| c3eb5a62c4 | |||
| 79e748b470 | |||
| 88c4201f80 | |||
| 6e1cc62065 | |||
| 76cc81a385 | |||
| bd9cfaa6e4 | |||
| a4f11a8225 | |||
| 5e5350d5ac | |||
| 73ac2218c7 | |||
| 352237bbb9 | |||
| 4cf7133bce | |||
| c182ced7cd | |||
| 5338871f36 | |||
| 3005f1cb3e | |||
| 72e8f31cff | |||
| 73975d3402 | |||
| 4902771772 | |||
| c2efbee4ee | |||
| 514b64682c | |||
| a0a0be8bef | |||
| 613fedfaff | |||
| 2e761161ef | |||
| 0a203aaf75 | |||
| f50e9db523 | |||
| 749c185e3d | |||
| 214f90d39b | |||
| 0d34c0a73d | |||
| ae51d201bc | |||
| 220a16429c | |||
| e759076a6c | |||
| 8175e239b4 | |||
| 8f5af49a6f | |||
| e7dc6a6cd3 | |||
| e09a2284e9 | |||
| b0faa4bfab | |||
| a1a3f2e0a1 | |||
| 45bc049c0d | |||
| e4e362b0a9 | |||
| 300d916fad | |||
| 1631bd2e02 | |||
| 639392e133 | |||
| 2e105a99b8 | |||
| 4235246cd7 | |||
| 57a8957c93 | |||
| d153a22545 | |||
| 930a786315 | |||
| 9da29a2231 | |||
| b06d026dd0 | |||
| 32ba008660 | |||
| 657fcc241a | |||
| c69edc6952 | |||
| 789b640ad0 | |||
| 14cf8a1a53 | |||
| ea4c1f87f6 | |||
| 2fa1db55fd | |||
| 75ddd06d6a | |||
| 597486bef1 | |||
| ebad8025f4 | |||
| c7650cac2f | |||
| 4b2848c7c3 | |||
| 255fa45e90 | |||
| 7043addd15 | |||
| 1c268555f6 |
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo - Projekt-Status
|
# Shinkan Jinkendo - Projekt-Status
|
||||||
|
|
||||||
**Stand:** 2026-05-12
|
**Stand:** 2026-05-14
|
||||||
**Version (Code):** 0.8.110 (`backend/version.py`, APP_VERSION)
|
**Version (Code):** 0.8.140 (`backend/version.py`, APP_VERSION)
|
||||||
**DB-Schema-Version:** `20260512057` (`backend/version.py`, DB_SCHEMA_VERSION)
|
**DB-Schema-Version:** `20260515063` (`backend/version.py`, DB_SCHEMA_VERSION)
|
||||||
**Branch:** develop
|
**Branch:** develop
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
**Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
|
**Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
|
||||||
|
|
||||||
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
|
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8.
|
||||||
|
|
||||||
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
|
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
|
||||||
|
|
||||||
|
|
@ -36,7 +36,8 @@
|
||||||
1. Kalender‑UI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk.
|
1. Kalender‑UI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk.
|
||||||
2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API).
|
2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API).
|
||||||
3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung (**§4**).
|
3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung (**§4**).
|
||||||
4. **Kombinationsübungen / Coach (Fachspez § 10.6):** Coach **Stufe B/C** (archetypgesteuerte Durchführung); **Archetyp-Verwaltung** jenseits Code-Konstanten; **Massen-Vorbelegung** aller Slot-Zeit/Anzahl-Felder; **serverseitige** Validierung Profil ↔ Archetyp — siehe `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Pakete **4e–4g**) und `COMBINATION_TIMING_PROFILE_PLAN.md`.
|
4. **Breakout / Coaching (Arbeitspaket):** Backend-Konsistenz `phases`↔`sections`, Run-UI vs. Spec (Stream-Tabs), Vorlagen phasenfähig, E2E-Smoke — siehe **`docs/HANDOVER.md`** (Tabelle „Coaching & Breakout“).
|
||||||
|
5. **Kombinationsübungen / Coach (Fachspez § 10.6):** Coach **Stufe B/C** (archetypgesteuerte Durchführung); **Archetyp-Verwaltung** jenseits Code-Konstanten; **Massen-Vorbelegung** aller Slot-Zeit/Anzahl-Felder; **serverseitige** Validierung Profil ↔ Archetyp — siehe `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Pakete **4e–4g**) und `COMBINATION_TIMING_PROFILE_PLAN.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -82,7 +83,9 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
||||||
- [x] **Varianten** (CRUD, Reorder, Voraussetzung) + Anzeige im Detail
|
- [x] **Varianten** (CRUD, Reorder, Voraussetzung) + Anzeige im Detail
|
||||||
- [x] **Progressionsgraph zwischen Übungen** (Bibliotheks-Container, Kanten, Sequenz-Bulk, Varianten-Knoten — Zwischenstand, siehe TRAINING_FRAMEWORK_SPEC §4)
|
- [x] **Progressionsgraph zwischen Übungen** (Bibliotheks-Container, Kanten, Sequenz-Bulk, Varianten-Knoten — Zwischenstand, siehe TRAINING_FRAMEWORK_SPEC §4)
|
||||||
- [x] Medien (Upload/Embed, rollenabhängige Größenlimits)
|
- [x] Medien (Upload/Embed, rollenabhängige Größenlimits)
|
||||||
- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen)
|
- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen; **Freigabelevel** als UI-Begriff für `visibility`)
|
||||||
|
- [x] **Übungsformular:** Registerkarten (Stammdaten … Medien & Mehr), kompakte Chip-Editoren, Varianten-Speichern über Aktionsleiste
|
||||||
|
- [x] **Fähigkeiten-Intensität** ohne Primär-Flag (`niedrig`/`mittel`/`hoch`; Backend `is_primary` immer false)
|
||||||
- [x] Exercise Blocks (Bausteine)
|
- [x] Exercise Blocks (Bausteine)
|
||||||
- [x] Saved Searches (wo implementiert)
|
- [x] Saved Searches (wo implementiert)
|
||||||
|
|
||||||
|
|
@ -92,6 +95,8 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
||||||
- [x] **Optionale Zuordnung einer Übungsvariante** pro Eintrag (`exercise_variant_id`)
|
- [x] **Optionale Zuordnung einer Übungsvariante** pro Eintrag (`exercise_variant_id`)
|
||||||
- [x] **Trainingsrahmenprogramm Bibliothek** (Ziele, Slots, Kontext) + **Slot‑Blueprints** in `training_units` (036–037)
|
- [x] **Trainingsrahmenprogramm Bibliothek** (Ziele, Slots, Kontext) + **Slot‑Blueprints** in `training_units` (036–037)
|
||||||
- [x] **Materialisierung** aus Rahmen‑Slot (`POST …/training-units/from-framework-slot`; UI‑Anbindung optional)
|
- [x] **Materialisierung** aus Rahmen‑Slot (`POST …/training-units/from-framework-slot`; UI‑Anbindung optional)
|
||||||
|
- [x] **Phasenmodell & parallele Streams** pro Einheit (Migration **063**): `training_unit_phases`, `training_unit_parallel_streams`; GET mit **`phases`** + flachen **`sections`**; PUT mit **`phases`** (App **0.8.137–0.8.140**)
|
||||||
|
- [x] **Coaching-Modus** für Breakout: Timeline mit Split-Wahl, Rejoin vor Ganzgruppe/nächstem Split, Nachbereitung speichern → Plan & Ablauf (`TrainingCoachPage`, `trainingPlanUtils.js`)
|
||||||
- [ ] Kalender-View / erweiterte Roadmap (Backlog)
|
- [ ] Kalender-View / erweiterte Roadmap (Backlog)
|
||||||
|
|
||||||
**MediaWiki Import:**
|
**MediaWiki Import:**
|
||||||
|
|
@ -101,6 +106,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
||||||
**Skills-System:**
|
**Skills-System:**
|
||||||
|
|
||||||
- [x] Hierarchisches Schema, Fokusbereich-Zuordnung, Exercise-Skill mit Levels
|
- [x] Hierarchisches Schema, Fokusbereich-Zuordnung, Exercise-Skill mit Levels
|
||||||
|
- [x] **Gewichtetes Fähigkeiten-Profil (Phase 3):** Module, Rahmenprogramme, Regressionspfade; Peer-Kontext getrennt; Listen-Filter + Discovery — **`technical/SKILL_SCORING_SPEC.md`**
|
||||||
|
|
||||||
**Admin-UI:**
|
**Admin-UI:**
|
||||||
|
|
||||||
|
|
@ -155,18 +161,19 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
||||||
|
|
||||||
| Dokument | Pfad | Stand | Status |
|
| Dokument | Pfad | Stand | Status |
|
||||||
|----------|------|-------|--------|
|
|----------|------|-------|--------|
|
||||||
| Fachliche Nutzerfunktionen (Design/Product) | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | 2026-05-12 | neu, Ist-Überblick |
|
| Fachliche Nutzerfunktionen (Design/Product) | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | 2026-05-14 | Phasen/Coach/Rejoin |
|
||||||
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-12 | Verweis Version siehe `version.py` |
|
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-14 | §11a Breakout |
|
||||||
| Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint |
|
| Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint |
|
||||||
| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-05-12 | Verweis Nutzerüberblick |
|
| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-05-12 | Verweis Nutzerüberblick |
|
||||||
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-07 | ✅ Hinweis 040–046 Medien (Kurz) |
|
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-07 | ✅ Hinweis 040–046 Medien (Kurz) |
|
||||||
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-12 | Version 0.4.5, Verweis Nutzerüberblick |
|
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-14 | Parallele Streams Ist 063 |
|
||||||
| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-05-08 | ✅ Medien/Inline-Workflow ergänzt |
|
| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-05-08 | ✅ Medien/Inline-Workflow ergänzt |
|
||||||
| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise |
|
| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise |
|
||||||
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
|
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
|
||||||
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-05-07 | ✅ Verweis Archiv/Inline |
|
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-05-07 | ✅ Verweis Archiv/Inline |
|
||||||
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-08 | ✅ Ist-Changelog + §11 Inline erweitert |
|
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-08 | ✅ Ist-Changelog + §11 Inline erweitert |
|
||||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-12 | auf 0.8.96 + P-13/P-01 + Nutzerüberblick |
|
| Parallele Streams (Fach/Technik) | `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | 2026-05-14 | Ist-Stand P1 teils |
|
||||||
|
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-14 | Keyset, KPIs, Breakout/Coach Kurzverweis |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -177,4 +184,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2026-05-12 (Version 0.8.96, Executive Summary P-13/P-01, `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
|
**Letzte Aktualisierung:** 2026-05-14 (Version 0.8.140, DB 063, Handover Coaching/Breakout)
|
||||||
|
|
|
||||||
100
.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md
Normal file
100
.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# KI-Unterstützung bei Übungen – Produkt-Vision
|
||||||
|
|
||||||
|
**Version:** 0.1
|
||||||
|
**Datum:** 2026-05-22
|
||||||
|
**Status:** Zielbild / Anforderungsgrundlage (nicht gleich Ist-Spec – technische Schnittstellen: **`technical/KI_FEATURES_SPEC.md`**, **`technical/AI_PROMPT_SYSTEM_SPEC.md`**, **`technical/AI_TRAINING_PLANNING_CONCEPT.md` §1.1**)
|
||||||
|
**Zielgruppe:** Product, Trainer-UX, später Admin-Werkzeuge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Übergeordnete Prinzipien
|
||||||
|
|
||||||
|
1. **Immer Vorschlag, nie blind überschreiben**
|
||||||
|
Die KI liefert **Vorschläge** (Änderungen, Ergänzungen, Strukturen). Bestehende Inhalte werden **nicht** still ersetzt. Übernahme erfolgt durch den Nutzer: **teilweise** (Felder/Stellen/Blöcke) oder **komplett** („Vorschlag gesamt akzeptieren“).
|
||||||
|
|
||||||
|
2. **Granulare Anforderung im Editor**
|
||||||
|
Innerhalb einer Übung soll KI-Unterstützung **feldbasiert oder bereichsbasiert** auslösbar sein (z. B. nur „Anleitung schärfen“, nur „Fähigkeiten“, nur „Variantenrahmen“) **oder** als **Komplettüberarbeitung** mit klarem Warnhinweis (Umfang/transparenter Diff).
|
||||||
|
|
||||||
|
3. **Nachweisliche Herkunft**
|
||||||
|
Übernommene KI-Inhalte werden technisch dort abgebildet, wo bereits vorgesehen (z. B. **`summary_ai_generated`**, **`exercise_skills.ai_suggested`**) und um analogen Hinweis für weitergehende Textfelder/Varianten **erweitert**, sobald Implementierung konkret wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Funktionsbereiche (Vision)
|
||||||
|
|
||||||
|
### 2.1 Von der Idee zur kompletten Übung („Zielausbau“)
|
||||||
|
|
||||||
|
**Einstieg minimal:** Kurzbeschreibung oder Stichwort, **Ziel** („was soll erreicht werden?“), wenige **Rahmenparameter** (z. B. Fokusbereich, Trainingszeit, Teilnehmerzahl, Alter, Platzausstattung, Sicherheitshinweise – konkrete Dropdowns/Freifelder in UX später festlegen).
|
||||||
|
|
||||||
|
**KI-Aufgabe:** aus diesem dünnen Kontext einen **übernehmbaren Entwurf** einer **ganzen Übung** erzeugen: Titel‑Vorschlag, Ziel-/Durchführungstext, Sicherheit/Organisation, ggf. Trainerhinweise – **immer als Vorschlagspaket**, nicht als Speicher ohne Bestätigung.
|
||||||
|
|
||||||
|
**Abgrenzung:** Kombinationsübungen / komplexe Methodenprofile können **phasenweise** später einbezogen werden (Verweis Fachspez Trainingsmodule).
|
||||||
|
|
||||||
|
### 2.2 Anleitung (Durchführung / „Ausführung“) maximal hilfreich
|
||||||
|
|
||||||
|
**Ziel:** Die **Ausführungs-/Anleitungsbereiche** sollen sich **didaktisch klar**, **teilbar** und **wieder verwendbar** lesen – ohne den Trainer zu entmindigen.
|
||||||
|
|
||||||
|
**KI-Aufgabe:** Überarbeitungsvorschlag für Struktur (nummerierte Schritte, Zeiten pro Block, häufige Fehler, Progressionshinweise innerhalb der Übung wo sinnvoll). **Selektiver** Aufruf: nur diese Felder oder nur ein markierter Abschnitt (wenn UX Textauswahl unterstützt).
|
||||||
|
|
||||||
|
### 2.3 Kurzbeschreibung (`summary`)
|
||||||
|
|
||||||
|
**KI-Aufgabe:** Aus den **relevanten Übungstexten** eine **Liste-/Karte-taugliche** Kurzfassung generieren — wie in **`KI_FEATURES_SPEC.md`** beschrieben, mit **Ablehnen / Bearbeiten / Übernehmen**.
|
||||||
|
|
||||||
|
### 2.4 Einordnung – primär **Fähigkeiten**
|
||||||
|
|
||||||
|
**KI-Aufgabe:** automatische Erkennung und **Zuordnung** zum **globale Skills-Katalog** inklusive:
|
||||||
|
|
||||||
|
- **Intensität** (`exercise_skills`)
|
||||||
|
- **Skill-Level**: `required_level` / `target_level` nach **kanonischen Slugs** (Backend-konform)
|
||||||
|
- **`is_primary`** / Priorisierung wo fachlich sinnvoll
|
||||||
|
|
||||||
|
**Prompt-Kontext für Qualität:** Stammfelder wie `skills.description`, **`karate_relevance`**, **`relevance_level`**, **`focus_areas`**, optional **`skill_level_definitions`** nur für eine **kurze Kandidatenliste** (zweite Runde möglich) – keine vollständigen Romane für den gesamten Katalog auf einmal.
|
||||||
|
|
||||||
|
### 2.5 Varianten (optional, später prioritär erwägenswert)
|
||||||
|
|
||||||
|
**Vision:** Aus Ziel-/Durchführungstext **mehrere sinnvolle Ausprägungen** als **Übungsvarianten** vorschlagen oder einzelne erzeugen (**progression**, **Schwierigkeit**, andere Paararbeit, Gerätevariation) mit **übernehmbarem** Datenmodell gleich dem bestehenden `exercise_variants`.
|
||||||
|
|
||||||
|
**Randbedingungen:** Validierung gegen Übungstyp (Kombinationsübungen ohne Varianten laut Produktstand), keine Halluzination fremder IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Kontextbezug später: Nachbearbeitung aus der Trainingsplanung
|
||||||
|
|
||||||
|
**Vision:** Hinweise aus der **Nachbearbeitung** einer Trainingseinheit (Ist‑Minuten, Trainer-Notizen, Abweichungen „was lief nicht?“ – je nach Datenmodell) fließen **optional** als Kontext in eine **erneute KI-Überarbeitung der betroffenen Übung** ein („Übung aus den Erfahrungen der Gruppe verbessern“).
|
||||||
|
|
||||||
|
**Konsequenz technisch später:** Zugriffsrechte, Mandant, keine unzulässige Verknüpfung personenbezogener Sportlerdaten; Aggregation auf **Einheit-/Gruppe** und **bereits dokumentierte Trainer-Insights**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Admin: Massenverarbeitung und Analyse
|
||||||
|
|
||||||
|
**Vision für Plattform-/Vereins-Admins:**
|
||||||
|
|
||||||
|
| Thema | Richtungsziel |
|
||||||
|
|-------|----------------|
|
||||||
|
| **Massenverarbeitung** | Batch: z. B. Zusammenfassungen nachziehen, fehlende Skills vorschlagen, einheitlicher Stil bei importiertem Bestand — immer mit **Review-Queue**, nicht ohne menschliche Freigabe skalierungskritisch. |
|
||||||
|
| **Analyse / Qualität** | Werkzeugkasten oder Berichte: **welche Übungen** sollten überarbeitet werden? z. B. leere/kurze `summary`, fehlende `goal`/`execution`, **fehlende oder widersprüchliche Skill-Zuordnung**, Import-Herkunft ohne Plausibilität, Kombi-Slots unvollständig, sehr alte Imports. |
|
||||||
|
| **Lückenkarten** | Z. B. Abgleich gegen **Skill-Discovery**/Profil-Analysen („keine Übung deckt Fähigkeit X ab“ auf gewähltem Korpus); Verbindung zu **`skill-discovery`** entscheidend später im Detail (kein automatischer Rewrite ohne Policy). |
|
||||||
|
|
||||||
|
**Governance:** Sichtbarkeit (`official`, Verein), Rechte (**Superadmin** vs. Vereinsinhalt), Audit der KI-Anwendung bei Massenjobs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phasierung (überarbeitungsfähig)
|
||||||
|
|
||||||
|
| Phase | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| **P0** | KI-Service + Prompts aus DB + **Suggestion-only** UX; Kern: **Summary** + **Skills** (wie Spec-Minimum), **ein Feld / Komplettpaket mit Diff** nach UX. |
|
||||||
|
| **P1** | **Anleitung überarbeiten** + **„von Idee zur Übung“** (Zielausbau) mit Rahmenparameter-Form |
|
||||||
|
| **P2** | **Variantenvorschläge** mit strenger Validation |
|
||||||
|
| **P3** | **Planungs-/Nachbereitungskontext** |
|
||||||
|
| **P4** | **Admin** Massen-/Analyse (Queue + Reports + Governance) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Offene Produkt-/Fachfragen
|
||||||
|
|
||||||
|
- Minimaler **Parameterbau** beim Zielausbau (Pflicht vs. optional).
|
||||||
|
- Umgang mit **Medien**/Inline-Verweisen beim KI-Text – nichts zerstören, Platzhalter erhalten (siehe Medien-Spec §11).
|
||||||
|
- **Kombinationsübungen:** welche Teilaspekte dürfen KI anfassen?
|
||||||
|
- Limits: **Tokens**, **Rate-Limits**, Kostenüberwachung pro Verein/global.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo - Fachliches Domänenmodell
|
# Shinkan Jinkendo - Fachliches Domänenmodell
|
||||||
|
|
||||||
**Version:** 0.4.5
|
**Version:** 0.4.6
|
||||||
**Stand:** 2026-05-12 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
|
**Stand:** 2026-05-14 (Fachlicher Nutzerüberblick: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`)
|
||||||
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
|
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -57,7 +57,7 @@ Haupt-Kategorie (KARATE / ALLGEMEINE)
|
||||||
- Selbstverteidigung ✓
|
- Selbstverteidigung ✓
|
||||||
- Gewaltschutz ✓
|
- Gewaltschutz ✓
|
||||||
|
|
||||||
**Technische Umsetzung:** M:N Beziehungen mit `is_primary` Flag.
|
**Technische Umsetzung:** M:N-Beziehungen mit optionalem `is_primary`-Flag bei **Fokusbereichen, Stilrichtungen, Trainingsstilen und Zielgruppen** — nicht bei `exercise_skills` (dort nur Intensität `niedrig|mittel|hoch`).
|
||||||
|
|
||||||
### 3. Hierarchischer Kontext (§8.1)
|
### 3. Hierarchischer Kontext (§8.1)
|
||||||
|
|
||||||
|
|
@ -407,10 +407,9 @@ skill_level_definitions (
|
||||||
- Reaktion (Koordination, target_level: 2, intensity: mittel)
|
- Reaktion (Koordination, target_level: 2, intensity: mittel)
|
||||||
|
|
||||||
**Attribute pro Fähigkeitsbezug:**
|
**Attribute pro Fähigkeitsbezug:**
|
||||||
- is_primary (Haupt- oder Nebenfähigkeit)
|
- `intensity` — Nutzeneinschätzung: **niedrig | mittel | hoch** (Standard **mittel**)
|
||||||
- intensity (niedrig/mittel/hoch)
|
- `required_level` / `target_level` — Stufen-Spanne (kanonische Slugs basis … optimierung)
|
||||||
- required_level (Voraussetzung, 1-5)
|
- `is_primary` — Legacy-Feld; **nicht mehr in der UI**, beim Speichern immer false; Scoring ignoriert es
|
||||||
- target_level (Ziel-Level, 1-5)
|
|
||||||
|
|
||||||
**🆕 Fokusbereich-Filterung:**
|
**🆕 Fokusbereich-Filterung:**
|
||||||
- Bei Übungen mit Fokusbereich "Karate" sollten primär KARATE-Fähigkeiten zugeordnet werden
|
- Bei Übungen mit Fokusbereich "Karate" sollten primär KARATE-Fähigkeiten zugeordnet werden
|
||||||
|
|
@ -466,6 +465,8 @@ skill_level_definitions (
|
||||||
|
|
||||||
**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4.
|
**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4.
|
||||||
|
|
||||||
|
**KI-Planung (Workbench, Stand 0.8.233):** Am Graph können Trainer neben Kanten ein **`planning_roadmap`**-Artefakt (Curriculum-Stufen) und **`planning_catalog_context`** (Primärfokus, Stilrichtung, Trainingsstil, Zielgruppe aus den Katalog-Dimensionen §1) pflegen. Die Roadmap-first-Pipeline matcht Übungen pro Stufe; Didaktik und Reihenfolge kommen aus Roadmap + QS, nicht aus Technik-Hardcoding. **Geplant (H1):** Katalog-Dimensionen zusätzlich als **Prompt-Snippets** in LLM-Aufrufen (Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`**. Technische Details: **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**. Für **Trainingsplanung** (Einheit, Abschnitt, Rahmen-Slot) gelten dieselben Katalog- und Retrieval-Bausteine mit anderen Scopes — Phase G, siehe Roadmap **`PLANNING_KI_ROADMAP.md`**.
|
||||||
|
|
||||||
### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009)
|
### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009)
|
||||||
|
|
||||||
**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR‑010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR‑013**).
|
**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR‑010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR‑013**).
|
||||||
|
|
@ -474,7 +475,43 @@ skill_level_definitions (
|
||||||
|
|
||||||
**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**).
|
**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**).
|
||||||
|
|
||||||
---
|
### Trainingsmodul (Bibliothek)
|
||||||
|
|
||||||
|
**Abgrenzung:** Wiederverwendbare **Übungsfolge** (`training_modules` + `training_module_items`) — kein Kalendertermin, kein Rahmen-Slot. Übernahme in geplante Einheiten über Planung (`apply-training-module`).
|
||||||
|
|
||||||
|
**Governance:** wie andere Bibliotheksartefakte (`visibility`, `club_id`, `library_content_visibility_sql`).
|
||||||
|
|
||||||
|
### Gewichtetes Fähigkeiten-Profil (Planungs-Bausteine, Phase 3)
|
||||||
|
|
||||||
|
**Zweck:** Aus den verknüpften Übungen eines Planungsartefakts wird ein **Fähigkeiten-Profil** berechnet (Trainingsgewicht je Fähigkeit). Trainer vergleichen Bausteine **innerhalb desselben Typs**, um z. B. das passendste Modul für eine Ziel-Fähigkeit zu finden.
|
||||||
|
|
||||||
|
**Artefakttypen (getrennte Peer-Kontexte):**
|
||||||
|
|
||||||
|
| Typ | Vergleich |
|
||||||
|
|-----|-----------|
|
||||||
|
| `training_module` | nur sichtbare **Module** |
|
||||||
|
| `framework_program` | nur sichtbare **Rahmenprogramme** |
|
||||||
|
| `progression_graph` | nur sichtbare **Regressionspfade** |
|
||||||
|
|
||||||
|
**Metriken (Nutzer):**
|
||||||
|
|
||||||
|
- **Score / Gewicht** — absolut (Dauer × Häufigkeit × Intensität × Stufen-Spanne)
|
||||||
|
- **Prozent** — Anteil am stärksten sichtbaren Peer **desselben Typs** für diese Fähigkeit (max. 100 %)
|
||||||
|
- **★** — stärkster Peer in diesem Kontext
|
||||||
|
|
||||||
|
**UI:** Profile in Editoren; KPI-Kacheln und Filter in Listen (`/planning/framework-programs`, `/planning/training-modules`); Discovery auf der Fähigkeiten-Seite.
|
||||||
|
|
||||||
|
**Technik:** `backend/skill_scoring.py`, `routers/skill_profiles.py` — Spec **`technical/SKILL_SCORING_SPEC.md`**.
|
||||||
|
|
||||||
|
### Parallele Trainingsstreams (Breakout)
|
||||||
|
|
||||||
|
**Fachlich:** Eine Kalender‑**Einheit** kann aus **Phasen** bestehen — z. B. gemeinsamer Block, dann **beliebig viele parallele** „Teilstrecken“ (**Streams**) mit je eigenem Miniplan (Abschnitte/Übungen), erneut gemeinsamer Block. Das ist **nicht** dasselbe wie ein **Rahmenprogramm‑Slot** (Serien‑Session über Wochen): Slots strukturieren **mehrere Einheiten** in einem Programm; **Streams** strukturieren **gleichzeitige** Abläufe **innerhalb einer** Einheit.
|
||||||
|
|
||||||
|
**Sonderfall Stationen:** Rotation kann **innerhalb** einer Stream‑Planung über **Kombinationsübungen** (Methodenprofil/Archetyp) abgebildet werden; hallenweit **synchron** getaktete Rotation ist eine **erweiterte** Ausbaustufe (siehe Fachkonzept).
|
||||||
|
|
||||||
|
**Umsetzung (2026-05, Migration 063, App 0.8.137 ff.):** Tabellen **`training_unit_phases`** und **`training_unit_parallel_streams`**; **`training_unit_sections`** mit **`phase_id`** und **`parallel_stream_id`** (exakt eine Zuordnung pro Sektion). **`GET /api/training-units/:id`** liefert **`phases`** (verschachtelt) und flache **`sections`**. **Coaching** und **Durchführung** nutzen dieselbe Phasenlogik im Frontend (`trainingPlanUtils.js`).
|
||||||
|
|
||||||
|
**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
||||||
|
|
||||||
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
|
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
|
||||||
|
|
||||||
|
|
@ -482,7 +519,7 @@ skill_level_definitions (
|
||||||
- **`exercise_media`:** Verknüpfung **Übung ↔ Asset** (`media_asset_id`) oder **Embed** ohne Asset; Felder wie `context` (`ablauf` \| `detail` \| `trainer_hint`), Sortierung, Primär-Medium.
|
- **`exercise_media`:** Verknüpfung **Übung ↔ Asset** (`media_asset_id`) oder **Embed** ohne Asset; Felder wie `context` (`ablauf` \| `detail` \| `trainer_hint`), Sortierung, Primär-Medium.
|
||||||
- **`platform_media_storage`:** Konfiguration effektiver Medienwurzel (Superadmin, relativ zu `MEDIA_ROOT`).
|
- **`platform_media_storage`:** Konfiguration effektiver Medienwurzel (Superadmin, relativ zu `MEDIA_ROOT`).
|
||||||
- **Produkt:** Medienbibliothek **`/media`**; in der Übungsbearbeitung Upload, Entfernen der Verknüpfung, **Aus Archiv verknüpfen**; Governance **`official`** und Copyright-Regeln wie in der Norm beschrieben.
|
- **Produkt:** Medienbibliothek **`/media`**; in der Übungsbearbeitung Upload, Entfernen der Verknüpfung, **Aus Archiv verknüpfen**; Governance **`official`** und Copyright-Regeln wie in der Norm beschrieben.
|
||||||
- **Geplant:** **Inline-Verweise** in Fließtextfeldern auf dieselbe Verknüpfung (`exercise_media.id`) — **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**, **`docs/HANDOVER.md`** §5.
|
- **Inline-Verweise** in Fließtextfeldern: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**, **`docs/HANDOVER.md`** §5.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -640,12 +677,13 @@ skill_level_definitions (
|
||||||
- [ ] Level-Definitionen aus Fähigkeitsmatrix extrahieren (optional)
|
- [ ] Level-Definitionen aus Fähigkeitsmatrix extrahieren (optional)
|
||||||
- [ ] Skills-Beschreibungen aus Wiki importieren (Migration 024)
|
- [ ] Skills-Beschreibungen aus Wiki importieren (Migration 024)
|
||||||
- [ ] Admin-UI für Fähigkeiten-Kategorien (CRUD)
|
- [ ] Admin-UI für Fähigkeiten-Kategorien (CRUD)
|
||||||
- [ ] Skill-Filter in Übungssuche integrieren
|
- [x] Skill-Filter in Übungssuche (SkillTreeMultiSelect + Stufen)
|
||||||
|
- [x] Gewichtetes Fähigkeiten-Profil für Planungs-Bausteine (Module, Rahmen, Pfade) — siehe `technical/SKILL_SCORING_SPEC.md`
|
||||||
- [ ] Reifegradmodelle definieren (Kombination Fokusbereich + Stil + Zielgruppe)
|
- [ ] Reifegradmodelle definieren (Kombination Fokusbereich + Stil + Zielgruppe)
|
||||||
- [ ] KI-Unterstützung für Trainingsplanung (basierend auf Fähigkeiten-Level)
|
- [ ] KI-Unterstützung für Trainingsplanung (basierend auf Fähigkeiten-Level)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2026-04-27
|
**Letzte Aktualisierung:** 2026-05-20
|
||||||
**Verantwortlich:** Claude Code
|
**Verantwortlich:** Claude Code
|
||||||
**Review:** Pending
|
**Review:** Pending
|
||||||
|
|
|
||||||
114
.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md
Normal file
114
.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# Parallele Trainingsstreams (Breakout) — Fachkonzept
|
||||||
|
|
||||||
|
**Status:** MVP-Umsetzung **teilweise** (Code) · **Stand:** 2026-05-14
|
||||||
|
**Ziel:** Planung und Durchführung von Training mit **phasenweise gemeinsamem** Ablauf und **beliebig vielen parallelen Teilstrecken** (Breakout-Sessions), inkl. Sonderfall **rotierende Stationen**.
|
||||||
|
|
||||||
|
**Technische Ausarbeitung:** `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
||||||
|
**Domänenbegriffe (Überblick):** `.claude/docs/functional/DOMAIN_MODEL.md` (Abschnitt Parallele Streams)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ausgangslage und Problem
|
||||||
|
|
||||||
|
In Kinder- und Breitensport-Training ist ein typischer Ablauf:
|
||||||
|
|
||||||
|
1. **Gemeinsam:** Aufwärmen, Koordination, Ansagen.
|
||||||
|
2. **Getrennt:** Kinder in mehrere Gruppen teilen; **Co-Trainer** leiten jeweils eigene Inhalte **gleichzeitig**.
|
||||||
|
3. **Gemeinsam:** Abschluss, gemeinsame Übungen, Verabschiedung.
|
||||||
|
|
||||||
|
Die aktuelle Shinkan-Planung modelliert pro Termin **eine lineare Folge von Abschnitten und Übungen** pro Einheit. Das genügt nicht, wenn **mehrere gleichzeitige „Unter-Sessions“** mit unterschiedlichen Plänen dokumentiert und auf der Matte geführt werden sollen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ziele (fachlich)
|
||||||
|
|
||||||
|
| ID | Ziel |
|
||||||
|
|----|------|
|
||||||
|
| PT‑01 | Eine **Kalender-Einheit** bleibt **ein** Termin (eine Halle, eine Gruppe, ein Datum) — kein Splitten in künstlich mehrere Kalendereinträge nur für Parallelität. |
|
||||||
|
| PT‑02 | **Unbegrenzte** Anzahl paralleler **Streams** (Teilstrecken) in einer oder mehreren **Parallelphasen**. |
|
||||||
|
| PT‑03 | **Phasenmodell:** klar erkennbar **Gemeinsam** vs. **Parallel** vs. wieder **Gemeinsam** (auch mehrfach hintereinander möglich). |
|
||||||
|
| PT‑04 | **Rollen:** Leitung (Haupttrainer) und Co-Trainer; Zuordnung der Co-Trainer **soll** an konkrete Streams anschließbar sein (heute: nur flache Liste pro Einheit — siehe technische Spec). |
|
||||||
|
| PT‑05 | **Sonderfall Stationen:** rotierender Ablauf (z. B. Wechsel alle 20 Min.) **inhaltlich** unterscheiden zwischen (a) Rotation **innerhalb** einer Teilstrecke und (b) **synchron** getakteter Hallen-Rotation — siehe §5. |
|
||||||
|
| PT‑06 | **Durchführung:** Trainer können „ihre“ Spur auf dem Gerät abarbeiten; Fortschritt pro Spur nachvollziehbar. |
|
||||||
|
|
||||||
|
**Nicht-Ziel (frühe Stufen):** Echtzeit-Synchronisation mehrerer Geräte; individuelles Athleten-Tracking; automatische Raumbelegung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Begriffe
|
||||||
|
|
||||||
|
| Begriff | Definition |
|
||||||
|
|---------|------------|
|
||||||
|
| **Einheit / Termin** | Geplante `training_unit` für Gruppe und Datum — übergeordneter Rahmen des Abends. |
|
||||||
|
| **Phase** | Organisatorischer Block innerhalb der Einheit: entweder **ganze Gruppe** oder **parallel**. |
|
||||||
|
| **Stream / Teilstrecke** | Innerhalb einer Parallelphase: eine von N **gleichzeitig** stattfindenden Unter-Abläufen mit **eigenem** Miniplan (Abschnitte, Übungen, Notizen — analog heutiger Planung). |
|
||||||
|
| **Synchronisationspunkt** | Fachlich: alle treffen sich wieder (Beginn einer **Gemeinschaftsphase** nach Parallelität). |
|
||||||
|
| **Station (Rotation)** | Inhaltlicher Fokus oder Platz, den Teilnehmer **wechselnd** anlaufen; kann als Kombinations-/Zirkellogik oder als koordinierter Hallenrhythmus modelliert werden (§5). |
|
||||||
|
|
||||||
|
**Abgrenzung „Rahmenprogramm-Slot“:** Ein Slot im **Rahmenprogramm** ist eine **Session in einer Serie** (z. B. Woche 1 vs. Woche 2), **nicht** „Teilgruppe A gleichzeitig mit Teilgruppe B in derselben Stunde“. Parallele Streams sind **innerhalb einer Einheit**, orthogonal zum Rahmen-Slot.
|
||||||
|
|
||||||
|
**Abgrenzung **Kombinationsübung**:** Eine Kombi-Übung bündelt **mehrere Einzelübungen** mit Methodenprofil (Archetyp, ggf. Rotation) **in einem Plan-Item**. Sie ersetzt **nicht** mehrere Trainer mit **jeweils eigenem Gesamtablauf**, kann aber **pro Stream** für Stationslogik genutzt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Szenarien
|
||||||
|
|
||||||
|
### 4.1 Klassischer Breakout
|
||||||
|
|
||||||
|
30 Min. gemeinsam → 25 Min. drei parallele Streams (Gruppe an Matte / an Schlagsack / Fußarbeit) → 15 Min. gemeinsam.
|
||||||
|
|
||||||
|
### 4.2 Viele Kinder, mehrere Co-Trainer
|
||||||
|
|
||||||
|
Haupttrainer plant die Gesamtstruktur; jeder Co-Trainer sieht in der Durchführung primär die zugewiesene Teilstrecke.
|
||||||
|
|
||||||
|
### 4.3 Rollierendes Stationssystem
|
||||||
|
|
||||||
|
Alle Gruppen arbeiten an **verschiedenen Schwerpunkten** und **wechseln** nach festem Intervall die Station — entweder **nur innerhalb einer Spur** oder **hallenweit synchron** (offene fachliche Präzisierung in MVP vs. später, §5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sonderfall: Stationen und Kombinationsübungen
|
||||||
|
|
||||||
|
### 5.1 Variante A — Rotation innerhalb einer Teilstrecke
|
||||||
|
|
||||||
|
Eine Teilgruppe rotiert durch mehrere Übungen (Zeit oder Runden). Das liegt nah an einer **Kombinationsübung** mit Archetyp z. B. „Zirkel / zeitgesteuerte Rotation“ und Parametern (Wechselintervall). **Empfehlung:** Diese Variante über **bestehendes** Kombinationsübungs-Konzept in der jeweiligen **Stream-Planung** abbilden (`planning_method_profile`).
|
||||||
|
|
||||||
|
### 5.2 Variante B — Synchron getaktete Hallen-Rotation
|
||||||
|
|
||||||
|
Alle Streams (oder alle Kinder insgesamt) **wechseln gleichzeitig** zur nächsten Station; Startstation kann pro Teilgruppe **versetzt** sein. Das ist **organisatorisch** schwerer: es braucht entweder **Phasen-Metadaten** (globaler Takt) oder eine explizite **Rot/Matrix**. **Empfehlung:** In einer **zweiten Ausbaustufe** abbilden; MVP kann bei Variante A starten, sofern fachlich ausreichend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rollen und Verantwortlichkeiten
|
||||||
|
|
||||||
|
- **Leitungstrainer:** Hält den Faden, startet Gemeinschaftsphasen, koordiniert Parallelbeginn/-ende (fachlich; ggf. später UI-Hinweise).
|
||||||
|
- **Co-Trainer:** Verantwortlich für **zugeteilte** Streams; Zuordnung soll **pro Stream** möglich werden (Erweiterung gegenüber reiner Einheits-Co-Trainer-Liste).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Offene fachliche Entscheidungen
|
||||||
|
|
||||||
|
1. **MVP Umfang:** Reicht **freie Parallelität** ohne **synchronen** Hallenwechsel (Variante B)?
|
||||||
|
2. **Dauer:** Sollen Phasen oder Streams **Soll-Minuten** tragen (nur Anzeige vs. später Timer)?
|
||||||
|
3. **Vorlagen:** Müssen `training_plan_templates` parallel-fähig werden **vor** oder **mit** der ersten Implementierung?
|
||||||
|
4. **Sichtbarkeit:** Dürfen alle Co-Trainer alle Streams sehen, oder „nur meine Spur“?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Umsetzungsstand (kurz, 2026-05-14)
|
||||||
|
|
||||||
|
- **Erreicht:** Datenmodell Phasen/Streams (**063**), API **GET/PUT** mit **`phases`**, Planungs-Breakout-UI, Durchführung und Coach nutzen dieselbe Phasen-/Stream-Logik im Frontend (`trainingPlanUtils.js`). **Synchronisationspunkt** fachlich umgesetzt: vor nächster Ganzgruppenphase oder nächstem Split erscheint im Coach die **Rejoin-Karte** (mehrere Streams), sofern nicht am absoluten Planende.
|
||||||
|
- **Noch offen:** vollständige **Persistenz-Konsistenz** bei nachträglich geänderten Sektionen, **Vorlagen** mit Phasen, **Trainer pro Stream** in der UI, ggf. **Stream-Tabs** in der Durchführungsansicht wie in §5.2 skizziert — siehe **`docs/HANDOVER.md`** (Arbeitspaket-Tabelle).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Verwandte Dokumente
|
||||||
|
|
||||||
|
| Dokument | Bezug |
|
||||||
|
|----------|--------|
|
||||||
|
| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slots = Serien-Sessions, **nicht** Intra-Einheit-Parallelität |
|
||||||
|
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombinationsübungen, Archetypen, Stationslogik **im Item** |
|
||||||
|
| `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` | Fachliche Tiefe Kombi |
|
||||||
|
| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick |
|
||||||
|
| `docs/HANDOVER.md` | Ist-Stand Coach, offene Breakout-Punkte |
|
||||||
|
| `technical/DATABASE_SCHEMA.md` | Aktueller Stand Tabellen |
|
||||||
|
|
@ -12,6 +12,8 @@ Ausführliche fachliche Inhalte:
|
||||||
| [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§ 10.4)**, kanonische Archetyp-IDs **§ 10.2.1**, **Anhang A** Implementierungsabgleich |
|
| [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§ 10.4)**, kanonische Archetyp-IDs **§ 10.2.1**, **Anhang A** Implementierungsabgleich |
|
||||||
| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand |
|
| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand |
|
||||||
| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
|
| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
|
||||||
|
| [**KI-Unterstützung Übungen (Vision)**](./AI_EXERCISE_ASSISTANT_VISION.md) | Zielbild Zielausbau, Vorschlags-UX (teilweise/komplett), Skills/Varianten, später Planungskontext, Admin-Masse/Qualität |
|
||||||
|
| [**KI Übungen – Umsetzungsplan**](../working/AI_EXERCISE_IMPLEMENTATION_PLAN.md) | Stufen S0–S6, Driftschutz-Regeln, Checkliste gegen Specs |
|
||||||
|
|
||||||
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
|
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Gelieferte Features & technische Basis (Q2 2026)
|
# Gelieferte Features & technische Basis (Q2 2026)
|
||||||
|
|
||||||
**Stand:** 2026-05-12
|
**Stand:** 2026-05-20
|
||||||
**Referenz:** `backend/version.py` — aktuelle **APP_VERSION** / **DB_SCHEMA_VERSION** (Stand Code u. a. **0.8.96**)
|
**Referenz:** `backend/version.py` — aktuelle **APP_VERSION** / **DB_SCHEMA_VERSION** (Stand Code u. a. **0.8.96**)
|
||||||
|
|
||||||
Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. Trainingsrahmen‑Bibliothek + Slot‑Blueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§3–4**. **Medien-Archiv & Bibliothek:** Abschnitt **12** unten + **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`.
|
Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. Trainingsrahmen‑Bibliothek + Slot‑Blueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§3–4**. **Medien-Archiv & Bibliothek:** Abschnitt **12** unten + **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`.
|
||||||
|
|
@ -68,7 +68,7 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
||||||
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
||||||
|
|
||||||
- Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht.
|
- Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht.
|
||||||
- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, Sichtbarkeit, Status).
|
- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, **Freigabelevel**, Status).
|
||||||
- **Filter-Chips** unter der Suchleiste; Klick entfernt einen Filter; Badge am Filter-Button = Anzahl Chips.
|
- **Filter-Chips** unter der Suchleiste; Klick entfernt einen Filter; Badge am Filter-Button = Anzahl Chips.
|
||||||
- **Kein Vollbild-Spinner** bei jeder Suche: nur noch **`listFetching`** — Suchfelder bleiben im DOM (**Fokus/Cursor** bleiben erhalten); Liste zeigt optional „Aktualisiere Treffer…“.
|
- **Kein Vollbild-Spinner** bei jeder Suche: nur noch **`listFetching`** — Suchfelder bleiben im DOM (**Fokus/Cursor** bleiben erhalten); Liste zeigt optional „Aktualisiere Treffer…“.
|
||||||
- **`<datalist>`** mit Titeln der aktuellen Treffer; **`autoComplete="on"`** für Browser-Vorschläge.
|
- **`<datalist>`** mit Titeln der aktuellen Treffer; **`autoComplete="on"`** für Browser-Vorschläge.
|
||||||
|
|
@ -76,14 +76,47 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Frontend – Übung bearbeiten (`ExerciseFormPage.jsx`)
|
## 6. Frontend – Übung bearbeiten (`ExerciseFormPageRoot.jsx`)
|
||||||
|
|
||||||
|
**Routing:** `/exercises/new`, `/exercises/:id/edit` — keine separaten Varianten-Routen.
|
||||||
|
|
||||||
|
### 6.1 Tab-Navigation (Registerkarten)
|
||||||
|
|
||||||
|
Horizontale **`PageSectionNav`** über **`ExerciseFormTabBar`** / **`ExerciseFormPanel`** (`ExerciseFormLayout.jsx`); farbige linke Panel-Ränder (CSS `.exercise-form-edit`, `.exercise-form-panel--*`).
|
||||||
|
|
||||||
|
| Tab | Inhalt |
|
||||||
|
|-----|--------|
|
||||||
|
| **Stammdaten** | Titel, Kurztext, Dauer/Gruppe, Equipment, **Freigabelevel** (`visibility`), Status, Verein |
|
||||||
|
| **Anleitung** | Ziel, Durchführung, Vorbereitung, Trainerhinweise (Rich-Text inkl. Inline-Medien) |
|
||||||
|
| **Einordnung** | Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, Altersgruppen, **Fähigkeiten** (kompakte Chip-Editoren) |
|
||||||
|
| **Kombination** | nur bei `exercise_kind=combination`: Slots, Archetyp, `method_profile` |
|
||||||
|
| **Varianten** | nur nach erstem Speichern; **nicht** bei Kombinationsübungen |
|
||||||
|
| **Medien & Mehr** | Medien, Progressionsgraph, KI-Hilfen, Löschen — nach erstem Speichern |
|
||||||
|
|
||||||
|
Neue Übungen: Tabs **Varianten** und **Medien & Mehr** deaktiviert bis zur ersten Speicherung.
|
||||||
|
|
||||||
|
### 6.2 Freigabelevel (UI-Begriff)
|
||||||
|
|
||||||
|
Feld **`exercises.visibility`** heißt in der UI durchgängig **Freigabelevel** (`frontend/src/constants/exerciseGovernanceLabels.js`) — Liste, Filter, Bulk, Picker, Formular. API/DB-Feldname **`visibility`** unverändert.
|
||||||
|
|
||||||
|
### 6.3 Fähigkeiten am Übungsobjekt
|
||||||
|
|
||||||
|
- Intensität je Fähigkeit: **`niedrig` \| `mittel` \| `hoch`**, Standard **`mittel`** (`exerciseSkillIntensity.js`).
|
||||||
|
- Kein „Primär“-Schalter mehr in der UI; **`is_primary`** bei `exercise_skills` ist Legacy — Backend speichert immer **`false`**, Scoring ignoriert das Feld.
|
||||||
|
- Kompakte **Chip-Editoren** für Katalog-Zuordnungen und Fähigkeiten (`ExerciseCatalogAssocEditor`, `ExerciseSkillsEditor`).
|
||||||
|
|
||||||
|
### 6.4 Varianten-Editor
|
||||||
|
|
||||||
|
- Tab **Varianten**: **eine Variante zur Zeit** (Dropdown oder „Erste Variante anlegen“); Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Löschen pro Variante.
|
||||||
|
- **Speichern über Aktionsleiste:** `performSaveAttempt` ruft zuerst **`persistPendingVariantChanges()`** auf (geänderte Varianten per PUT, danach optional Entwurf **`createVariantFromDraft()`**).
|
||||||
|
- Button **„Variante anlegen“** (`type="button"`, kein verschachteltes `<form>`): legt Entwurf sofort per API an; alternativ mitgesichert über **Speichern** in der Aktionsleiste.
|
||||||
|
- Snapshot **`variantsSavedSnapshotRef`** für Dirty-Erkennung; Hinweis im Panel: Änderungen werden mit Speichern in der Aktionsleiste mitgesichert.
|
||||||
|
|
||||||
|
### 6.5 Medien & Progressionsgraph
|
||||||
|
|
||||||
- **Varianten-Editor**: eingeklappter Bereich (`<details>`), **eine Variante zur Zeit** über Dropdown oder „Neue Variante“; Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Speichern/Löschen pro Variante.
|
|
||||||
- **Medien:** Upload/Embed, **Archiv verknüpfen** (`from-asset`), Medienliste mit Vorschau, Reaktivierung bei Archiv-Konflikt — Details **§12**.
|
- **Medien:** Upload/Embed, **Archiv verknüpfen** (`from-asset`), Medienliste mit Vorschau, Reaktivierung bei Archiv-Konflikt — Details **§12**.
|
||||||
- Block **Progressionsgraph** (Edit): Kanten mit Bezug zur aktuellen Übung.
|
- Block **Progressionsgraph** (Edit): Kanten mit Bezug zur aktuellen Übung.
|
||||||
|
|
||||||
Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Bearbeitung erfolgt unter **`/exercises/:id/edit`** (Routing-Doku ggf. anpassen).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Frontend – Übung Detail (`ExerciseDetailPage.jsx`)
|
## 7. Frontend – Übung Detail (`ExerciseDetailPage.jsx`)
|
||||||
|
|
@ -123,7 +156,16 @@ Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Be
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Medien-Archiv & Medienbibliothek (Migration **045** ff., App ca. **0.8.41–0.8.64**)
|
## 12. Trainingsplan: Phasen & parallele Streams (DB **063**, App **0.8.137–0.8.140**)
|
||||||
|
|
||||||
|
- **063:** `training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` / `parallel_stream_id`; Default-Ganzgruppenphase für Bestand.
|
||||||
|
- **API:** `GET /api/training-units/:id` mit **`phases`** + **`sections`**; `PUT`/`POST` mit **`phases`** für Breakout-Einheiten (**0.8.138**); Rahmen-Slot-Materialisierung kopiert Phasen (**0.8.138**).
|
||||||
|
- **Frontend:** Planung Breakout-Panel (**0.8.139–0.8.140**); **`trainingPlanUtils.js`** — `sectionsWithPlanLocForDisplay`, `flattenPlanTimeline`, `buildCoachSavePlanPayload`, Split-Rejoin (`coachShouldPromptSplitRejoinTransition`); **`TrainingCoachPage`**, **`TrainingUnitRunPage`**.
|
||||||
|
- **Doku:** `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`, `docs/HANDOVER.md` §3, Arbeitspaket „offen“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Medien-Archiv & Medienbibliothek (Migration **045** ff., App ca. **0.8.41–0.8.64**)
|
||||||
|
|
||||||
Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick geliefert:
|
Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick geliefert:
|
||||||
|
|
||||||
|
|
@ -150,7 +192,7 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Nächste sinnvolle Schritte (nicht Lieferstand)
|
## 14. Nächste sinnvolle Schritte (nicht Lieferstand)
|
||||||
|
|
||||||
- Trainingsplanung: Kalender‑UI‑Anbindung **„aus Rahmen übernehmen“**; Visibility/Policies für geteilte Rahmen (**CURR‑004** später).
|
- Trainingsplanung: Kalender‑UI‑Anbindung **„aus Rahmen übernehmen“**; Visibility/Policies für geteilte Rahmen (**CURR‑004** später).
|
||||||
- Progressions-Serien als **Blöcke** (angekündigt; Voraussetzung: `prerequisite_variant_id` / `progression_level` vorhanden).
|
- Progressions-Serien als **Blöcke** (angekündigt; Voraussetzung: `prerequisite_variant_id` / `progression_level` vorhanden).
|
||||||
|
|
@ -160,15 +202,55 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Verweise
|
## 15. Gewichtetes Fähigkeiten-Scoring (Phase 3, Stand 2026-05-20)
|
||||||
|
|
||||||
|
Norm: **`technical/SKILL_SCORING_SPEC.md`**.
|
||||||
|
|
||||||
|
### 15.1 Backend
|
||||||
|
|
||||||
|
- **`skill_scoring.py`:** Gewichtung (Dauer × Vorkommen × Intensität × Stufen); `compute_planning_corpus_by_type()` mit getrennten Corpora; `universal_percent` capped auf 100 %
|
||||||
|
- **`routers/skill_profiles.py`:** Profile-GET pro Artefakt; `POST /api/skill-profiles/batch-summaries`; `GET /api/skill-discovery/suggestions`
|
||||||
|
- Sichtbarkeit: **`library_content_visibility_sql`** (Planungs-Bibliothek, nicht „nur Verein club“)
|
||||||
|
|
||||||
|
### 15.2 Frontend
|
||||||
|
|
||||||
|
- **Listen:** Rahmenprogramme + Trainingsmodule — Filter-Modal (wie Übungen), Chips, `SkillTreeMultiSelect` (Portal-Dropdown)
|
||||||
|
- **KPI:** `SkillProfileCompact` — Top je Unterkategorie, Score + Peer-%
|
||||||
|
- **Editoren + Modal:** `SkillProfilePanel`, `SkillProfileFullModal`
|
||||||
|
- **Discovery:** `SkillDiscoveryPanel` auf Fähigkeiten-Seite
|
||||||
|
|
||||||
|
### 15.3 Offen
|
||||||
|
|
||||||
|
- Corpus-Caching; pytest für Typ-Trennung; Filter-Persistenz; Skill-Filter Import-Dialog „Rahmen übernehmen“
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Übungen – Governance & Berechtigungen (Ist, Stand 2026-05-20)
|
||||||
|
|
||||||
|
**Owner:** `exercises.created_by` (Ersteller). **Varianten** haben kein eigenes `created_by` — Rechte leiten sich von der Eltern-Übung ab.
|
||||||
|
|
||||||
|
| Aktion | `private` | `club` | `official` |
|
||||||
|
|--------|-----------|--------|------------|
|
||||||
|
| **Lesen** | Ersteller; Plattform-Admin | Aktive Vereinsmitglieder des Objekt-`club_id`; Plattform-Admin ohne Mitgliedschaft (Audit) | Plattform-weit |
|
||||||
|
| **Bearbeiten** (Übung inkl. Varianten/Medien) | Ersteller; Plattform-Admin | Ersteller; Plattform-Admin; **`can_plan_in_club`** im Objekt-Verein (`trainer`, `content_editor`, `division_lead`, `club_admin`) | Plattform-Admin |
|
||||||
|
| **Löschen** | Ersteller; Vereins-Admin gemeinsamer Vereine mit Ersteller | Nur **`club_admin`** im Objekt-Verein | Nur Plattform-Admin |
|
||||||
|
|
||||||
|
**Code:** `backend/club_tenancy.py` (`exercise_visible_to_profile`, `can_plan_in_club`), `backend/routers/exercises.py` (`_assert_can_edit_exercise`, `_assert_can_delete_exercise`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Verweise
|
||||||
|
|
||||||
| Thema | Dokument |
|
| Thema | Dokument |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
| Rahmenprogramm / Progressionsgraph | `technical/TRAINING_FRAMEWORK_SPEC.md` |
|
| Rahmenprogramm / Progressionsgraph | `technical/TRAINING_FRAMEWORK_SPEC.md` |
|
||||||
|
| Fähigkeiten-Scoring Planung | `technical/SKILL_SCORING_SPEC.md` |
|
||||||
| API Übungen | `technical/EXERCISES_API_SPEC.md` |
|
| API Übungen | `technical/EXERCISES_API_SPEC.md` |
|
||||||
| Domänenmodell | `functional/DOMAIN_MODEL.md` |
|
| Domänenmodell | `functional/DOMAIN_MODEL.md` |
|
||||||
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |
|
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |
|
||||||
| Medien Upload (Limits, MIME) | `technical/MEDIA_UPLOAD_SPEC.md` |
|
| Medien Upload (Limits, MIME) | `technical/MEDIA_UPLOAD_SPEC.md` |
|
||||||
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
||||||
|
| Parallele Phasen/Streams | `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` |
|
||||||
|
| Coaching/Breakout-Handover | `docs/HANDOVER.md` |
|
||||||
| Fachlicher Nutzerüberblick | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` (Repo-Root) |
|
| Fachlicher Nutzerüberblick | `docs/FACHLICHE_NUTZERFUNKTIONEN.md` (Repo-Root) |
|
||||||
| Projektstatus-Kachel | `../PROJECT_STATUS.md` |
|
| Projektstatus-Kachel | `../PROJECT_STATUS.md` |
|
||||||
|
|
|
||||||
|
|
@ -79,16 +79,18 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
||||||
|
|
||||||
### Stufe E – Capabilities dokumentieren (ohne UI für Custom Roles)
|
### Stufe E – Capabilities dokumentieren (ohne UI für Custom Roles)
|
||||||
|
|
||||||
- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `content.share_club`, `planning.edit_unit`, `org.manage_members`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen.
|
- **Verbindliche Spez v1:** `CAPABILITY_CATALOG.v1.md` — Capability-IDs, Account-Lifecycle, Rollen-Matrix, Endpoint-Mapping.
|
||||||
|
- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `exercises.ai.suggest`, `org.members.manage`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen (siehe Katalog §5–6).
|
||||||
- Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen – keine zweite Philosophie.
|
- Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen – keine zweite Philosophie.
|
||||||
|
|
||||||
### Stufe F – Community (eigenes Epic)
|
### Stufe F – Community (eigenes Epic)
|
||||||
|
|
||||||
- Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen.
|
- Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen.
|
||||||
|
|
||||||
### Zurückgestellt – Vereinsabo / Limits
|
### Zurückgestellt – Vereinsabo / Limits (Konzept liegt vor)
|
||||||
|
|
||||||
- Wiederöffnen wenn ACCESS_LAYER Stufe C/D stabil; dann Enforcement vor ausgewählten Writes an einen Billing-Stripe binden.
|
- **Spez v1:** `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` — Feature-Registry (Mitai-v9c-Pattern), `club_plans`/`club_subscriptions`, Kontingente an `club_id`.
|
||||||
|
- Implementierung/Billing (Stripe) weiter zurückgestellt; Schema- und Enforcement-Hooks gemäß 4-Phasen-Rollout (Mitai-Vorbild) vorbereiten, sobald Stufe C/D stabil.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -117,10 +119,28 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
||||||
|
|
||||||
## 7. Referenzen
|
## 7. Referenzen
|
||||||
|
|
||||||
|
- **`CAPABILITY_CATALOG.v1.md`** – Rollen, Capabilities, CRUD-Mapping, `GET /api/me/entitlements`.
|
||||||
|
- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** – Vereinsabo, Feature-Limits, Mitai-Mapping, Ziel-Schema.
|
||||||
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe.
|
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe.
|
||||||
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
|
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
|
||||||
- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
|
- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, `can_plan_in_club`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2026-05-07
|
## 8. Anhang – Übungen (Ist-Implementierung, Referenz)
|
||||||
|
|
||||||
|
**Stand:** 2026-05-20 · **Detail:** `EXERCISES_API_SPEC.md` Permissions, `FEATURES_DELIVERED_2026-Q2.md` §16
|
||||||
|
|
||||||
|
| Feld / Konzept | Semantik |
|
||||||
|
|----------------|----------|
|
||||||
|
| `created_by` | Owner der Übung; Varianten erben Rechte |
|
||||||
|
| `visibility` | UI: **Freigabelevel** — `private` \| `club` \| `official` |
|
||||||
|
| Lesen | `exercise_visible_to_profile` — `official` global; `private` Ersteller + Plattform-Admin; `club` aktive Mitglieder (+ Plattform-Admin Audit) |
|
||||||
|
| Bearbeiten | Ersteller; Plattform-Admin; bei `club` zusätzlich `can_plan_in_club` (Trainer, Content-Editor, Spartenleitung, Vereins-Admin) |
|
||||||
|
| Löschen | `official` → Plattform-Admin; `club` → `club_admin` im Objekt-Verein; `private` → Ersteller oder Vereins-Admin mit gemeinsamem Verein |
|
||||||
|
|
||||||
|
**Hinweis:** Dieser Anhang dokumentiert den **produktiven Code-Pfad** in `exercises.py`; die Roadmap in §4 bleibt für die langfristige Vereinheitlichung aller Bibliotheksartefakte maßgeblich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Letzte Aktualisierung:** 2026-05-20
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
# KI-Prompt-System – Universelle Admin-Konfiguration
|
# KI-Prompt-System – Universelle Admin-Konfiguration
|
||||||
|
|
||||||
**Version:** 1.0
|
**Version:** 1.1
|
||||||
**Datum:** 2026-04-24
|
**Datum:** 2026-05-30
|
||||||
**Status:** DRAFT
|
**Status:** Kern umgesetzt (`ai_prompts`, `prompt_resolver`, Superadmin-HTTP-API); Kaskaden geplant (Abschnitt 8)
|
||||||
|
|
||||||
|
**Zielbild (Roadmap):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Planung/Rahmen, Phasenplan.
|
||||||
|
|
||||||
|
**Ist-Stand API (Superadmin):**
|
||||||
|
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
|
||||||
|
- Spalte **`openrouter_model`** (Migration **070**): Optional pro Prompt-Zeile; OpenRouter **`model`**-Parameter; **`NULL`/leer ⇒ `OPENROUTER_MODEL`** aus der Umgebung.
|
||||||
|
|
||||||
**Autor:** Claude Code
|
**Autor:** Claude Code
|
||||||
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
**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
|
## 1. Konzept
|
||||||
|
|
@ -28,6 +37,7 @@ steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.
|
||||||
|-------------|-----------|
|
|-------------|-----------|
|
||||||
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
|
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
|
||||||
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
|
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
|
||||||
|
| `exercise_instruction_rewrite` | Überarbeitet Anleitung: goal, execution, preparation, trainer_notes (JSON, prägnantes HTML) |
|
||||||
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
|
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
|
||||||
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
|
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
|
||||||
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
|
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
|
||||||
|
|
@ -174,10 +184,9 @@ Wähle maximal 5 passende Fähigkeiten. Für jede gib an:
|
||||||
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
|
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
|
||||||
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
|
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
|
||||||
- intensity: Trainingsintensität (niedrig|mittel|hoch)
|
- intensity: Trainingsintensität (niedrig|mittel|hoch)
|
||||||
- is_primary: true wenn Hauptfähigkeit
|
|
||||||
|
|
||||||
Antworte NUR als JSON-Array:
|
Antworte NUR als JSON-Array:
|
||||||
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch"}]
|
||||||
|
|
||||||
Wenn keine Fähigkeit passt, antworte mit [].$$,
|
Wenn keine Fähigkeit passt, antworte mit [].$$,
|
||||||
'exercise', 'json', true, NULL, 2),
|
'exercise', 'json', true, NULL, 2),
|
||||||
|
|
@ -597,6 +606,19 @@ AI_PROMPT_SYSTEM_SPEC: ai_service.run_ai_prompt("exercise_summary", ...)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version:** 1.0
|
## 8. Prompt-Kaskaden (geplant — nicht implementiert)
|
||||||
**Datum:** 2026-04-24
|
|
||||||
**Status:** DRAFT
|
**Ziel:** Vorlagen, die andere Prompts einbinden oder in feste Stufen (System → Fach → Ausgabeformat) zerlegt werden — ohne die DB-Templates mit duplizierten Fliesstexten zu zersplittern.
|
||||||
|
|
||||||
|
**Konzeptskizze:**
|
||||||
|
- Optional neues Feld `base_slug` oder eigene Tabelle `ai_prompt_composition` (Reihenfolge, Rolle: `system|user|prepend`).
|
||||||
|
- Platzhaltersyntax z. B. `{{include_prompt:slug}}` mit **maximaler Verschachtelungstiefe** und Zykluserkennung.
|
||||||
|
- Auflösungsreihenfolge: (1) eingebundene Slugs expandieren, (2) Kontext-Variablen wie heute ersetzen.
|
||||||
|
|
||||||
|
Bis zur Umsetzung bleiben zusammengesetzte Anweisungen im **einen** Template pro Slug (wie `exercise_skill_suggestions` mit `{{skills_catalog}}`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.1
|
||||||
|
**Datum:** 2026-05-30
|
||||||
|
**Status:** Teile umgesetzt (DB 067/069, Resolver, Superadmin-API + UI); Kaskaden offen
|
||||||
|
|
|
||||||
166
.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
Normal file
166
.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# KI-Prompt-System — Zielarchitektur (Shinkan Jinkendo)
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Datum:** 2026-05-30
|
||||||
|
**Status:** VERBINDLICHE ZIELRICHTUNG (Roadmap — nicht alles bereits umgesetzt)
|
||||||
|
**Ergänzt:** `AI_PROMPT_SYSTEM_SPEC.md` (aktueller Ist-Stand APIs/DB/UI), Mitai-Anleihen aus gleichnamigen Konzepten (Admin-Prompts, Platzhalter)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zweck
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt das **Zielbild**, damit spätere Arbeiten (**Trainingsplanung**, **mehrstufige Rahmenprogramme**, **Phasen/Streams**, weitere KI-Artefakte) **nicht** zu wiederholten Refaktoren von Übungs-KI oder OpenRouter-Anbindung zwingen.
|
||||||
|
|
||||||
|
**Leitkriterien:** wenige stabile Schnittflächen, Kontext pro Domäne, komponierbare Prompts, gültige Ausgaben, Betrieb ohne Code-Deploy für kleine Tweaks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Leitprinzipien
|
||||||
|
|
||||||
|
### 2.1 Eine stabile Ausführungsschicht
|
||||||
|
|
||||||
|
Alle produktiven KI-Aufrufe sollten mittelfristig über eine **einheitliche Fassade** laufen:
|
||||||
|
|
||||||
|
- **Eingabe:** `slug` (+ optional Kontext-Arten-Enum), **serialisierter Domän-Kontext** (Pydantic pro Kind), Konfiguration (Modell, Temperatur, … aus Env/DB).
|
||||||
|
- **Ausgabe:** Text oder validiertes JSON, Metadaten (`model`, `slug`, ggf. `prompt_version`/Hash), strukturierte Fehler.
|
||||||
|
|
||||||
|
Router und Frontend rufen diese Schicht oder schmale Orchestratoren — **nicht** direkt `httpx`/OpenRouter an jeder Ecke verteilt.
|
||||||
|
|
||||||
|
**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` (`load_ai_prompt_row`, `load_and_render_ai_prompt`, Kontext-Arten) sowie `backend/ai_prompt_job.py` (Pydantic `ExerciseFormAiPromptContext` fuer Uebungs-Prompts — Admin-Vorschau + erweiterbare Router-Nutzung); `exercise_ai` orchestriert OpenRouter nach dem Rendern.
|
||||||
|
|
||||||
|
### 2.2 Trennung: Semantik vs. Transport
|
||||||
|
|
||||||
|
- **Semantik:** Was soll das Modell liefern? Das hängt an **Prompt-Definition**, **Ausgabeformat** (`text`/`json`) und nachvollziehbarer Validierung — nicht am HTTP-Client.
|
||||||
|
- **Transport:** OpenRouter, Modellwahl, Retry, Timeouts bleiben in einem oder wenigen Hilfsmodulen.
|
||||||
|
|
||||||
|
### 2.3 Kontext-Namespaces für Platzhalter
|
||||||
|
|
||||||
|
Platzhalter und erlaubte Keys sind **pro logischer Kontext-Art** definiert, z. B.:
|
||||||
|
|
||||||
|
- `exercise_form_ai` — heute: Übungsformular-Vorschläge.
|
||||||
|
- später: `training_unit`, `framework_program_slot`, `import_wiki`, …
|
||||||
|
|
||||||
|
Damit kann der Katalog wachsen, ohne dass alle Keys in einen globalen Soup-Namespace müssen (`exercise_*` vs. `framework_*` ohne Kollisionen). Optional später **präfixierte** Keys (`exercise.title`, `slot.index`).
|
||||||
|
|
||||||
|
### 2.4 Komposition / Kaskade explizit
|
||||||
|
|
||||||
|
**Ziel:** Mehrteilige Prompts („System“–„Nutzer“–Anhänge) und **Einbindung anderer Vorlagen** als **Daten** (Kompositionsmodell), nicht nur als unbearbeiteter Freitext mit `{{include}}`.
|
||||||
|
|
||||||
|
Skizzen (noch nicht vollständig umgesetzt):
|
||||||
|
|
||||||
|
- Tabelle oder JSON-Spalte `composition`/`ai_prompt_segments`: geordnete Segmente mit `role` (`system` \| `user` \| äquivalent zum jeweiligen API-Shape), Quelle (`inline`, `ref_slug`), optional `ref_slug`, Schema-Version.
|
||||||
|
- Einbindungen mit **Maximaltiefe** und **Zykluserkennung** — keine unbegrenzten Makro-Ketten.
|
||||||
|
|
||||||
|
Bis dahin bleiben zusammenhängende Anweisungen in **einem** DB-Template pro Slug tragbar (`exercise_skill_suggestions` + `{{skills_catalog}}` bleiben gültig).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Zieldatenmodell (Schichten)
|
||||||
|
|
||||||
|
### 3.1 Definition (`ai_prompts` — bereits vorhanden, evolviert)
|
||||||
|
|
||||||
|
| Konzept | Bedeutung |
|
||||||
|
|--------|-----------|
|
||||||
|
| `slug`, `category`, `output_format`, `active` | Adressierung & Schalter |
|
||||||
|
| `template` | aktueller Inhalt |
|
||||||
|
| `default_template` | Referenz zum Zurücksetzen (Migration **069**) |
|
||||||
|
| `output_schema` (JSONB) | optional: JSON-Outputs validieren |
|
||||||
|
|
||||||
|
**Ausbaustufen:**
|
||||||
|
|
||||||
|
1. Nur `template`-Text (**heute**, plus Mustache über `prompt_resolver`).
|
||||||
|
2. Zusätzlich **Versionierung**: Historie oder `template_version`/Audit (wer hat wann geändert).
|
||||||
|
3. **Segmentierte Composition** wie in Abschnitt 2.4.
|
||||||
|
|
||||||
|
### 3.2 Kontext-Builder pro Domäne
|
||||||
|
|
||||||
|
Pro **Kontext-Art** eine klar genannte Routine (Pattern: registrierbare Builder):
|
||||||
|
|
||||||
|
| Kontext-Art | Beispiel-Input aus der App | Beispiel-Platzhalter / Daten |
|
||||||
|
|-------------|----------------------------|------------------------------|
|
||||||
|
| `exercise_form_ai` | Titel, Ziel/Durchführung (HTML→Plain), Fokuskontext, Retrieval-Profil-Influenza | `exercise_*`, `skills_catalog` |
|
||||||
|
| `training_unit` (geplant) | Sektionen, Zeiten, Phasen/Streams, verknüpfte Übungs-IDs | `unit_*`, `sections_summary_*` |
|
||||||
|
| `framework_program` (geplant) | Ziele pro Woche/Schicht, Slots, bereits geplante Einheiten, Skill-Scores | `framework_*`, `slot_*`, aggregierte KPIs |
|
||||||
|
|
||||||
|
**Regel:** Planungs-UI baut keine Prompt-Strings; sie liefert **Domän-DTOs** → Builder erzeugen **Platzhalter-Map + ggf. Anhänge**.
|
||||||
|
|
||||||
|
### 3.3 Skill-Retrieval und Prompts
|
||||||
|
|
||||||
|
`ai_skill_retrieval_profiles` steuert **Katalog‑Zusammenstellung** vor dem Platzhalter `{{skills_catalog}}` — das bleibt **orthogonal** zur Prompt-Verwaltung: Prompt ändert *Anweisung*, Profil ändert *welche Skills im Kontextfenster sind*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Trainingsplanung & Rahmen — erwartete Komplexität
|
||||||
|
|
||||||
|
Risiken: sehr große Kontexte (viele Slots, Streams, Bibliotheken), wiederholte KI-Anfragen, Token-Limits.
|
||||||
|
|
||||||
|
**Vorbereitende Strategien:**
|
||||||
|
|
||||||
|
1. **Gestufte Kontexte:** Rohdaten → interne Kurzfassungen (optional zweiter Prompt oder heuristisch) → finale Generator-Prompt nur mit komprimierten Summaries.
|
||||||
|
2. **Slug-Pro-Use-Case:** z. B. `training_unit_trainer_notes`, `framework_slot_coach_hint` — jeweils schmaler Vertrag statt „ein Prompt für alles“.
|
||||||
|
3. **Output-Verträge:** JSON-Schema + Server-Validierung vor UI; Fehlermeldungen mit Referenz auf Slug/Version.
|
||||||
|
4. **Feature-Flags / Modell-Overrides** pro Slug (optional in DB oder Env) für Dev/Prod ohne große Codepfade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Mitai (Jinkendo)
|
||||||
|
|
||||||
|
Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter, Preview), **andere** Kontext-Builder und ggf. andere Mandanten/Overlays. Eine gemeinsame **Resolver-/Mustache-Ebene** ist wünschenswert; **Shinkan-spezifische** Planungs- und Rahmenkontexte bleiben in Shinkan gekapselt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Betrieb, Sicherheit, Observability
|
||||||
|
|
||||||
|
- **Audit:** `updated_by` / Änderungshistorie für Templates (Backlog), heute: Timestamps.
|
||||||
|
- **Prompt-Injection:** System-/User-Segmente trennen; sensible Regeln in `system`/`developer`-äquivalenten Blöcken (wenn API das hergibt).
|
||||||
|
- **Logging:** weiter `SHINKAN_AI_DEBUG`; langfristig Hash/Länge des **aufgelösten** Prompts pro Request (ohne Secrets).
|
||||||
|
- **Kosten/Latenz:** Timeouts, max. Token-Hinweise pro Slug-Konfiguration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phasenplan (empfohlen, ohne Big-Bang)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph laufzeit
|
||||||
|
A[ai_prompts DB]
|
||||||
|
B[prompt_resolver Mustache]
|
||||||
|
C[ai_prompt_runtime]
|
||||||
|
J[ai_prompt_job Pydantic]
|
||||||
|
D[exercise_ai OpenRouter]
|
||||||
|
end
|
||||||
|
A --> C
|
||||||
|
C --> B
|
||||||
|
J --> D
|
||||||
|
C --> D
|
||||||
|
B --> D
|
||||||
|
```
|
||||||
|
|
||||||
|
| Phase | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. |
|
||||||
|
| **P1** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **`ExerciseFormAiPromptContext`** in `ai_prompt_context.py`; **`run_exercise_form_ai_suggestion`**; Übungs-API und Admin-Vorschau nutzen denselben Kontext. |
|
||||||
|
| **P2** | Versionierung oder Audit-Spalten; **teilweise:** optionales OpenRouter-Modell pro Zeile (`openrouter_model`, Migration 070, Fallback `OPENROUTER_MODEL`); weitere Overrides (Temperatur) offen. |
|
||||||
|
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
|
||||||
|
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Was bewusst vermieden werden soll
|
||||||
|
|
||||||
|
- Vollständige „Workflow-Engine“ mit beliebigen Graphen, bevor 2–3 konkrete Planungs-Anwendungsfälle live sind.
|
||||||
|
- Pro-Verein-Prompt-Kopien vor klar definierter Produkt-Anforderung (sonst Daten- und Pflege-Spirale).
|
||||||
|
- Unbegrenzte `include`-rekursive Textmakros ohne Tiefenschutz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Querverweise
|
||||||
|
|
||||||
|
- Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
|
||||||
|
- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||||
|
- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`
|
||||||
|
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_context.py`, `backend/ai_prompt_job.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.0 · **Datum:** 2026-05-30
|
||||||
202
.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md
Normal file
202
.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
# KI-gestützte Trainingsplanung – Zentrales Konzept
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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) · **`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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Produktliche Leitlinien
|
||||||
|
|
||||||
|
- **Nutzer:** Trainer/Vereinskontext, **Gruppenplanung** – keine Pflicht zur individuellen Sportler-Verfolgung; Kontext soll primär aus **Gruppe**, **bereits geplanten/durchgeführten Einheiten**, **Rahmen-/Zielen** und **berechtigtem Übungskorpus** bestehen.
|
||||||
|
- **Human-in-the-loop:** KI liefert **Vorschläge** (Liste, Reihenfolge, Begründung); schreibende Übernahme in Pläne nur nach **Trainer-Bestätigung** oder expliziter Aktion (analog „Manual First“ in `KI_FEATURES_SPEC.md`).
|
||||||
|
- **Governance-first:** Nur Übungen, die die API bereits für den Mandanten/Kontext **sichtbar** freigibt, dürfen in Kandidatenlisten landen – **vor** Retrieval und **vor** jedem Prompt.
|
||||||
|
|
||||||
|
### 1.1 Abgleich: aktueller Code- und Schema-Stand (Stand Review 2026-05-22)
|
||||||
|
|
||||||
|
| Thema | Ist im Repo | Konsequenz für dieses Konzept |
|
||||||
|
|--------|-------------|-------------------------------|
|
||||||
|
| **OpenRouter / LLM im Backend** | Produktiver Aufruf für Übungs‑Suggest in `openrouter_chat.py`, `exercise_ai.py`; Endpunkte **`POST …/exercises/ai/suggest`** und **`POST …/{id}/ai/regenerate`**; Migration **067** (`ai_prompts`, `summary_ai_generated`). **`db.py`**-Bootstrap nutzt **`display_name`**. | **Übungs-Assistent (P0)** vorhanden; generalisierter Service + **Planungs-KI** folgen. |
|
||||||
|
| **Übungs-KI laut Spec** | P0: Kurzfassung + Skill‑Vorschläge (`include_summary` / `include_skills`); **kein** Auto-KI beim Speichern (S5 im Umsetzungsplan). | Feinspez: `summary_ai_generated` bei manueller Kurzfassung zurücksetzen; Rate-Limits; Prompt-Admin-UI. |
|
||||||
|
| **Fähigkeiten-Stammdaten** | Migration **`065_skills_wiki_karate_relevance`:** `skills.karate_relevance` (Text), `skills.relevance_level` (1–3, optional); dazu weiterhin `description`, `focus_areas`, Kategorien, `skill_level_definitions` (Level 1–5 je Skill). | Diese Felder sind **expliziter Prompt-Kontext** für Skill-Vorschläge (Disambiguierung Karate vs. universal) – siehe §6. |
|
||||||
|
| **Skill-Scoring & Discovery (ohne LLM)** | Router `skill_profiles.py` + Modul `skill_scoring.py`: u. a. `GET …/skill-profile` für **Rahmenprogramm**, **Trainingsmodul**, **Progressionsgraph**; `POST /skill-profiles/batch-summaries`; **`GET /api/skill-discovery/suggestions`** (Match Bibliotheksartefakte ⇄ `skill_ids`, mit `library_content_visibility_sql`). | Ergänzt §3 **Stufe 3**: deterministische **Skill-Abdeckung / Artefakt-Discovery** ist **bereits vorhanden** und kann später die **Planungs-KI** speisen (Ziel-Skill-Mengen, Vergleich „Profil des Rahmens“) – ersetzt aber **nicht** die Top‑K-Selektion aus dem **Übungskatalog** für eine konkrete Session. |
|
||||||
|
| **Profil / Planungs-Präferenzen** | `profiles.training_planning_prefs` (JSONB, vgl. `MODULE_VERSIONS` → `profiles`), Planungsmodul mit u. a. **Vorlagen inkl. Split-Sessions** (`planning`), `training_units` mit **Publish in Rahmen-Slot-Blueprint**. | Zukünftige KI-Planung kann **Prefs** und **Vorlagen-Struktur** als weiche Constraints einbeziehen; Rahmen↔Einheit-Fluss ist produktiv erweitert – für KI nur relevant, sobald Planungs-Endpunkte angebunden werden. |
|
||||||
|
| **Übungsliste API** | Keyset-Pagination u. a. `cursor_updated_at` + Tie-break `id` (`exercises`-Modul laut `MODULE_VERSIONS`). | Retrieval-Pipelines sollten **cursorbasiert** paginieren, nicht „alle IDs auf einmal“ laden. |
|
||||||
|
|
||||||
|
**Nächster produktiver Fokus:** Prompt-/Admin‑UI zur Pflege von `ai_prompts`, **Rate-Limits**, optional **Auto-KI beim Speichern**; danach Übergang zur **Planungs-KI** laut diesem Dokument.
|
||||||
|
|
||||||
|
**Architektur-Vorschau (Planungs-KI):** Damit die **kleinere, starre** Übungs-Pipeline nicht zur stillen Vorlage für Planung wird, sind **eigenes Modul**, **stufenweise Outputs mit Validierung** und ein **kompaktes Kontext-DTO** vorgesehen — siehe **`working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Kernproblem: Skalierung des Kontextes
|
||||||
|
|
||||||
|
Aus einer **großen Übungssammlung** („>1000 Übungen“) können weder alle **Felder** (Ziele, Ablauf, Skills, Varianten …) noch alle **Zeilen** sinnvoll in einen LLM-Prompt.
|
||||||
|
|
||||||
|
**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 |
|
||||||
|
|-----------|--------|
|
||||||
|
| **Auftrag** | z. B. Sektionstyp, Dauerbudget, Schwierigkeit, erlaubte Phasen/Streams |
|
||||||
|
| **Hard Constraints** | Gruppe, Termin/Zeitraum, Governance-Filter bereits angewendet |
|
||||||
|
| **Komprimierte Historie** | Letzte *N* Einheiten als **Liste von Übungs-IDs + Kurzlabels** (+ optional Haupt-Skills), keine vollen Fließtexte |
|
||||||
|
| **Ziele / Rahmen** | Kurztexte aus Rahmen-Slot/Zielblöcken oder Trainer-Prompt |
|
||||||
|
| **Kandidaten-Top‑K** | z. B. 30–120 Übungen, **je Zeile gekürzt** (Titel, `summary`, 2–5 Skill-Namen/Stufen); **nie** der gesamte Katalog |
|
||||||
|
| **Strukturierte Kanten optional** | Kleine Mengen Kanten aus Progressionsgraph: „Nachbarn von zuletzt genutzten Übungen“ |
|
||||||
|
|
||||||
|
**Zahlen‑Richtwerte (überarbeitungsfähig):**
|
||||||
|
Kandidaten **vor** dem LLM typischerweise **50–150** Einträge; im Prompt durch Token-Limit weiter **truncate** oder **zweistufig** (grober Ranking-Schritt ohne LLM, dann finer mit LLM auf Top‑40).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pipeline: „Selektion vor dem Prompt“
|
||||||
|
|
||||||
|
Die **„optimale“** Auswahl entsteht **nicht**, indem das Modell den Katalog „im Kopf“ hält, sondern über eine **mehrstufige Pipeline**:
|
||||||
|
|
||||||
|
### Stufe 1 – Harte Filter (deterministisch, DB)
|
||||||
|
|
||||||
|
Synchron zur bestehenden Suche/List-API-Logik, z. B.:
|
||||||
|
|
||||||
|
- Sichtbarkeit / Verein / `official`‑Regeln
|
||||||
|
- Aktivitäts-/Archiv-Status der Übung
|
||||||
|
- Fokusbereich, Stil, Zielgruppe (wenn Trainings-/Gruppenkontext das vorgibt)
|
||||||
|
- Ausschluss bereits in **dieser Einheit** fester Übernutzung (optional)
|
||||||
|
|
||||||
|
Ergebnis: Menge \(M\) – kann noch sehr groß sein.
|
||||||
|
|
||||||
|
### Stufe 2 – Kontext-Verankerung (deterministisch + Graph)
|
||||||
|
|
||||||
|
- **Historie:** aus letzten *N* Gruppeneinheiten extrahierte `exercise_id`s (optional Variant).
|
||||||
|
- **Progressionsgraph:** ausgehend davon Nachbarn (eingehend/ausgehend begrenzte Tiefe) – bereits im Produkt als **unterstützend** modelliert (**CURR‑013**).
|
||||||
|
- **Rahmen/Slot-Ziele:** Überlapp mit Skill-Tags oder Stichwortliste (falls formalisiert).
|
||||||
|
- **Variantenketten:** `prerequisite_variant_id` / `progression_level` nur innerhalb bereits gewählter Übung prüfen oder als Hint an den LLM-Block durchreichen.
|
||||||
|
|
||||||
|
Ergebnis: **„Anker‑Menge“** + **„Graph‑Nachbarschaft“** → priorisierte Kandidaten.
|
||||||
|
|
||||||
|
### Stufe 3 – Weiches Ranking / Retrieval (halb-strukturiert)
|
||||||
|
|
||||||
|
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).
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Stufe 4 – LLM (optional zweistufig)
|
||||||
|
|
||||||
|
- **Optional 1:** LLM nur **sortiert/rankted** bereits vorgegebene IDs (Ranking mit kurzer Begründung).
|
||||||
|
- **Optional 2:** Zwei Calls: erst „welche drei Schwerpunkte“ / „Welche Constraints habe ich übersehen?“, zweiter Call nur mit gekürztem Top‑K – nur wenn UX den Mehraufwand rechtfertigt.
|
||||||
|
|
||||||
|
**Ausgabe-Contract:** Zurückkommen dürfen **nur gültige `exercise_id`s** aus der übergebenen Kandidatenliste (Server validiert gegen Set); **Halluzinationsrisiko damit entschärft**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Antwort auf die konkrete Frage: „Alle Übungen in den Prompt?“
|
||||||
|
|
||||||
|
**Nein.** Workflow:
|
||||||
|
|
||||||
|
1. **DB + Regeln + Graph + Historie** reduzieren auf **einige Hundert bis wenige tausend** Rohzeilen höchstens **intern** – aber
|
||||||
|
2. in den **LLM-Prompt** gehen nur **Top‑K kompakte Artefakte** (siehe §2).
|
||||||
|
|
||||||
|
Das LLM löst dann **Ranking, Reihenfolge, Timing-Hinweise, Trainer-sprachliche Kurzkommentare** – nicht die Frage „gibt es diese Übung überhaupt im System?“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vector DB (z. B. Qdrant) – wann nötig, wann nicht?
|
||||||
|
|
||||||
|
### 5.1 Ziel embeddings
|
||||||
|
|
||||||
|
Semantic Retrieval: „wie springt Coordinative Belastung ohne das Wort ‚Koordination‘ im Titel zu stehen.“ Das ist **über** reine Filtersuche und oft **über** stumpfe Volltextsuche erreichbar.
|
||||||
|
|
||||||
|
### 5.2 Option A – ohne separate Vector DB
|
||||||
|
|
||||||
|
- **PostgreSQL + `pgvector`** (Extension): Embeddings-Spalte an `exercises` (oder an „Search Document“-Zeilen), Indices, Abfrage zusammen mit SQL-Filtern in **einer Transaktions-DB**.
|
||||||
|
- **Größenordnung** einige 10 k–100 k Zeilen für Übungen: für Shinkan **oft ausreichend**.
|
||||||
|
- Vorteile: ein Betriebspfad, Mandanten-/Governance weiter in SQL lösen; Backup wie heute.
|
||||||
|
|
||||||
|
### 5.3 Option B – Qdrant (oder anderer Dediz-Vektorstore)
|
||||||
|
|
||||||
|
Sinnvoller zeitlicher Punkt oder technische Auslöser:
|
||||||
|
|
||||||
|
- sehr hohe Latenz-Anforderung mit **Hybrid-Filter** über viele kombinierte Metadaten in nahezu Echtzeit,
|
||||||
|
- Entkopplung: Embedding-Pipeline läuft asynchron und soll die **Operational DB** nicht beschweren,
|
||||||
|
- später **mehrsprachig** oder **mehrere Embedding-Versionen**/Re-Index ohne großen Migrationstress,
|
||||||
|
- Team bevorzugt **Dedicated** Vector-Ops gegenüber Postgres-Ops.
|
||||||
|
|
||||||
|
### 5.4 Empfehlung für diese Codebasis (überarbeitungsfähig)
|
||||||
|
|
||||||
|
1. **Phase 1:** Harte Filter + Graph + Historie + **PostgreSQL-Volltext** + Top‑K; LLM erst auf komprimierten Kandidaten → hoher Nutzen ohne neuen Infrastructure-Typen.
|
||||||
|
2. **Phase 2:** Bei nachweisbaren „Recall-Lücken“ (Trainer findet nichts Passendes trotz großem Korpus) **`pgvector` in Postgres** evaluieren – **vor** zusätzlicher Infrastruktur wie Qdrant.
|
||||||
|
3. **Phase 3:** Qdrant (o. ä.) wenn Größenordnung, Betrieb oder Produkt-Anforderungen **pgvector** sprengen oder klar eine **embedding-first** Produktstraße geplant wird.
|
||||||
|
|
||||||
|
**Fazit:** Eine dedizierte **Vector DB ist nicht zwingende Voraussetzung** für vernünftige Selektion; sie ist eine **Ausbaustufe**, wenn **semantische Lücke** und Skalierung es rechtfertigen. **Selektion** ist immer **„Filter + Ranking + kleines Top‑K“**, unabhängig vom Speicherort der Vektoren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Datenpflege für gutes Retrieval (fachlicher Hebel)
|
||||||
|
|
||||||
|
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); `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.
|
||||||
|
|
||||||
|
Das fachliche Konzept sollte entscheiden: **wie viel automatische Pflege vs. Trainer-Pflichtfelder**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Produkt-/Release-Stufen (Anknüpfung)
|
||||||
|
|
||||||
|
Priorität **jetzt**: **Übungsanlage**, danach **Planung**.
|
||||||
|
|
||||||
|
| Stufe | Nutzen | Technik-Schwerpunkt |
|
||||||
|
|-------|--------|---------------------|
|
||||||
|
| **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) | 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 für **Planungs**-KI konsistent und wird parametrierbar erweitert; **§1.1** spiegelt den **aktuellen Implementierungs**-Vorsprung (Skill-Scoring ohne LLM) wider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Compliance & Datenschutz (Kurzhinweis)
|
||||||
|
|
||||||
|
- Datenminimierung: **keine Teilnehmerliste** ohne expliziten Scope; Kontext über **Einheiten-Metadaten** und Übungen.
|
||||||
|
- **OpenRouter**/Modellwahl: Organisation intern klären (AV/Verarbeitungsvertrag, Datenflüsse außerhalb EU – siehe Repo-Compliance-Dokumente).
|
||||||
|
- **Logging:** Prompts keine unnötigen personenbezogenen Daten; wenn Debug: Retention definieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Offene Punkte für die fachliche Verfeinerung
|
||||||
|
|
||||||
|
- Gewichtung „**Wiederholung** vs. **Progression** vs. **Motivation**“ (domänenspezifisch).
|
||||||
|
- Umgang mit **Kombinationsübungen** und **Coach-Stufen B/C** in der Datenübergabe.
|
||||||
|
- Soll das System **„Lücken“** aus der **Matrix-Auflösung** aktiv quantifizieren oder nur Narrative verwenden?
|
||||||
|
- Akzeptierte **Übersetzungen**: nur Deutsch oder mehrsprachige Embeddings erforderlich?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Glossar
|
||||||
|
|
||||||
|
| Begriff | Bedeutung |
|
||||||
|
|---------|-----------|
|
||||||
|
| **Top‑K** | Feste kleine Obergrenze Übungen pro LLM-Anfrage |
|
||||||
|
| **Hard filter** | Deterministische SQL-/Policy-Einschränkung vor KI |
|
||||||
|
| **Kontext-Paket** | Zusammengesetztes, tokenlimitiertes Eingabeobjekt für den Prompt |
|
||||||
|
| **Retrieval** | algorithmischer Schritt ohne Generierung („wer kommt überhaupt in Frage“) |
|
||||||
331
.claude/docs/technical/CAPABILITY_CATALOG.v1.md
Normal file
331
.claude/docs/technical/CAPABILITY_CATALOG.v1.md
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
# Capability-Katalog Shinkan v1
|
||||||
|
|
||||||
|
**Status:** Konzept (verbindliche Zieldefinition; M3 teilweise umgesetzt)
|
||||||
|
**Stand:** 2026-06-06
|
||||||
|
**Bezüge:** `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` (Stufe E), `MULTI_TENANCY_RBAC_ARCHITECTURE.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`** (Produktentscheidungen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zweck
|
||||||
|
|
||||||
|
Dieses Dokument definiert **benannte Capabilities** (Wer darf welche **Funktion** ausführen?) — getrennt von:
|
||||||
|
|
||||||
|
- **Governance** (Darf ich *dieses Objekt* lesen/ändern? → `visibility`, `club_id`, `created_by`)
|
||||||
|
- **Feature-Limits** (Wie viel darf der **Verein**? → `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`)
|
||||||
|
|
||||||
|
Capabilities beantworten: *„Darf ein Trainer mit Rolle X die Funktion Y im Verein Z überhaupt nutzen?“*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Namenskonvention
|
||||||
|
|
||||||
|
```
|
||||||
|
{domain}.{action}[.{qualifier}]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Segment | Beispiele |
|
||||||
|
|---------|-----------|
|
||||||
|
| `domain` | `exercises`, `media`, `planning`, `org`, `platform` |
|
||||||
|
| `action` | `read`, `create`, `update`, `delete`, `manage`, `execute` |
|
||||||
|
| `qualifier` | `ai.suggest`, `join_request`, `inbox.review` |
|
||||||
|
|
||||||
|
**CRUD-Mapping:**
|
||||||
|
|
||||||
|
| Aktion | Capability-Suffix | Bedeutung |
|
||||||
|
|--------|-------------------|-----------|
|
||||||
|
| Lesen (Listen/Detail) | `.read` | Navigation + API-Lesen erlaubt |
|
||||||
|
| Anlegen | `.create` | POST/INSERT |
|
||||||
|
| Bearbeiten | `.update` | PUT/PATCH (eigenes + berechtigtes Fremdes) |
|
||||||
|
| Löschen | `.delete` | DELETE (strenger als update) |
|
||||||
|
| Verwalten | `.manage` | Org-Funktionen, Freigaben, Mitglieder |
|
||||||
|
| Ausführen (ohne Persistenz) | `.execute` | z. B. KI-Vorschau, Coach-Lauf |
|
||||||
|
|
||||||
|
Objektbezogene Feinheiten (nur Ersteller, nur Vereinsadmin des Objekt-Vereins) bleiben in **Governance** — Capabilities sind das **Tür-Schloss** davor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Account-Lifecycle (Voraussetzung für Capabilities)
|
||||||
|
|
||||||
|
| `account_state` | Bedingung | Typische Capabilities |
|
||||||
|
|-----------------|-----------|------------------------|
|
||||||
|
| `anonymous` | Keine Session | nur öffentliche Routen (`/login`, Rechtstexte, `clubs/public-directory`) |
|
||||||
|
| `unverified` | Session, `email_verified=false` | `account.resend_verification`, `account.logout` |
|
||||||
|
| `verified_pending_club` | Verifiziert, keine aktive `club_members` | `club.join_request`, `club.creation_request` (M7), `account.settings` — **kein** Lesezugriff auf Domänen-Inhalte (siehe Entscheidungs-Doc §1.1) |
|
||||||
|
| `active_member` | Mind. eine aktive Vereinsmitgliedschaft | Domänen-Capabilities gemäß Vereinsrolle |
|
||||||
|
| `platform_admin` | `role` ∈ `admin`, `superadmin` | `platform.*` zusätzlich |
|
||||||
|
|
||||||
|
**Regel:** Domänen-Capabilities (`exercises.*`, `planning.*`, …) erfordern mindestens `active_member`, sofern nicht `platform_admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rollen-Scopes
|
||||||
|
|
||||||
|
### 4.1 Portal-Rollen (`profiles.role`)
|
||||||
|
|
||||||
|
| Rolle | Scope | Kurz |
|
||||||
|
|-------|-------|------|
|
||||||
|
| `user` | Portal | Standard nach Registrierung (Zielbild; heute oft `trainer` Legacy) |
|
||||||
|
| `trainer` | Portal | Legacy — mittelfristig durch `user` + Vereinsrollen ersetzen |
|
||||||
|
| `admin` | Portal | Plattform-Admin (Vereine anlegen, erweiterte Ops) |
|
||||||
|
| `superadmin` | Portal | Vollzugriff Plattform + Superadmin-Werkzeuge |
|
||||||
|
|
||||||
|
### 4.2 Vereinsrollen (`club_member_roles.role_code`)
|
||||||
|
|
||||||
|
| Rolle | Fachlich |
|
||||||
|
|-------|----------|
|
||||||
|
| `club_admin` | Vereinsorganisation, Mitglieder, Struktur |
|
||||||
|
| `trainer` | Planung, Übungen, Durchführung |
|
||||||
|
| `content_editor` | Inhalte pflegen (Bibliothek) |
|
||||||
|
| `division_lead` | Spartenleitung (später division-scope) |
|
||||||
|
|
||||||
|
Mehrfachrollen pro Mitgliedschaft sind möglich (OR-Verknüpfung der Capabilities).
|
||||||
|
|
||||||
|
### 4.3 Mapping heutiger Helfer → Capabilities
|
||||||
|
|
||||||
|
| Heutiger Code (`club_tenancy.py`) | Ziel-Capability-Cluster |
|
||||||
|
|-----------------------------------|-------------------------|
|
||||||
|
| `can_manage_club_org` | `org.structure.manage`, `org.members.manage`, `org.inbox.review` |
|
||||||
|
| `can_plan_in_club` | `planning.*`, `exercises.create/update`, `modules.*`, `framework.*` |
|
||||||
|
| `is_platform_admin` | `platform.*` (Bypass Mandant, Audit-Pflicht) |
|
||||||
|
| `is_superadmin` | `platform.superadmin.*` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Capability-Katalog (v1)
|
||||||
|
|
||||||
|
Legende Spalten:
|
||||||
|
|
||||||
|
- **Min. Account:** `verified_pending_club` | `active_member` | `platform_admin`
|
||||||
|
- **Vereinsrollen:** leer = alle aktiven Mitglieder; sonst mindestens eine Rolle
|
||||||
|
- **Feature-ID:** optionales Kontingent (siehe Club-Membership-Doc); leer = kein Limit
|
||||||
|
- **Governance:** zusätzliche Objektprüfung ja/nein
|
||||||
|
|
||||||
|
### 5.1 Account & Onboarding
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI |
|
||||||
|
|---------------|--------------|---------------|------------|----------------|
|
||||||
|
| `account.settings.read` | `unverified` | — | — | `GET /profiles/me`, Einstellungen |
|
||||||
|
| `account.settings.update` | `unverified` | — | — | `PUT /profiles/{id}` (eigenes Profil) |
|
||||||
|
| `account.password.change` | `unverified` | — | — | `PUT /api/auth/pin` |
|
||||||
|
| `account.resend_verification` | `unverified` | — | — | `POST /api/auth/resend-verification` |
|
||||||
|
| `club.directory.read` | `verified_pending_club` | — | — | `GET /clubs/public-directory` |
|
||||||
|
| `club.join_request.create` | `verified_pending_club` | — | — | `POST /me/club-join-requests`, Registrierung mit `requested_club_id` |
|
||||||
|
| `club.join_request.withdraw` | `verified_pending_club` | — | — | `DELETE /me/club-join-requests/{id}` |
|
||||||
|
| `club.join_request.read_own` | `verified_pending_club` | — | — | `GET /me/club-join-requests` |
|
||||||
|
|
||||||
|
### 5.2 Organisation (Verein)
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI |
|
||||||
|
|---------------|--------------|---------------|------------|----------------|
|
||||||
|
| `org.club.read` | `active_member` | * | — | `GET /clubs`, `GET /clubs/{id}` (eigene Vereine) |
|
||||||
|
| `org.club.create` | `platform_admin` | — | — | `POST /clubs` |
|
||||||
|
| `org.club.update` | `platform_admin` | `club_admin` | — | `PUT /clubs/{id}` |
|
||||||
|
| `org.club.delete` | `platform_admin` | — | — | `DELETE /clubs/{id}` |
|
||||||
|
| `org.structure.manage` | `active_member` | `club_admin` | `training_groups` | Sparten, Gruppen CRUD |
|
||||||
|
| `org.members.read` | `active_member` | `club_admin` | — | `GET /clubs/{id}/members` |
|
||||||
|
| `org.members.manage` | `active_member` | `club_admin` | `active_members` | POST/PUT/DELETE Mitglieder |
|
||||||
|
| `org.members.directory` | `active_member` | * | — | `GET /clubs/{id}/members/directory` (ohne E-Mail für Nicht-Admins) |
|
||||||
|
| `org.join_request.review` | `active_member` | `club_admin` | — | Join-Request accept/reject, Inbox |
|
||||||
|
| `org.inbox.read` | `active_member` | `club_admin` | — | Posteingang Join + Content-Reports |
|
||||||
|
|
||||||
|
### 5.3 Übungen
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `exercises.read` | `active_member` | * | — | ja (visibility) |
|
||||||
|
| `exercises.create` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | `exercises` | — |
|
||||||
|
| `exercises.update` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | — | ja |
|
||||||
|
| `exercises.delete` | `active_member` | `club_admin` (+ Ersteller privat) | — | ja |
|
||||||
|
| `exercises.bulk_metadata` | `active_member` | `content_editor`, `club_admin` | — | ja |
|
||||||
|
| `exercises.ai.suggest` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | — |
|
||||||
|
| `exercises.ai.regenerate` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | ja (edit) |
|
||||||
|
| `exercises.media.read` | `active_member` | * | — | ja |
|
||||||
|
| `exercises.media.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja |
|
||||||
|
| `exercises.variants.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||||
|
|
||||||
|
**Representative Endpoints:** `/api/exercises*`, `/api/exercises/ai/*`, Medien-Datei-Download.
|
||||||
|
|
||||||
|
### 5.4 Medien-Bibliothek (Archiv)
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `media.library.read` | `active_member` | * | — | ja |
|
||||||
|
| `media.library.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja |
|
||||||
|
| `media.library.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||||
|
| `media.library.lifecycle` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
| `media.rights.declare` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
| `media.admin.rights_review` | `platform_admin` | — | — | Plattform-Admin Legacy-Review |
|
||||||
|
|
||||||
|
### 5.5 Trainingsmodule & Rahmenprogramme
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `modules.read` | `active_member` | * | — | ja |
|
||||||
|
| `modules.create` | `active_member` | `trainer`, `content_editor`, `club_admin` | `training_programs` | — |
|
||||||
|
| `modules.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||||
|
| `modules.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja |
|
||||||
|
| `framework.read` | `active_member` | * | — | ja |
|
||||||
|
| `framework.create` | `active_member` | `trainer`, `club_admin` | `training_programs` | — |
|
||||||
|
| `framework.update` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
| `framework.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja |
|
||||||
|
| `plan_templates.read` | `active_member` | * | — | ja |
|
||||||
|
| `plan_templates.manage` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
|
||||||
|
### 5.6 Progressionspfade
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `progression.read` | `active_member` | * | — | ja |
|
||||||
|
| `progression.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||||
|
|
||||||
|
### 5.7 Planung & Durchführung
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `planning.calendar.read` | `active_member` | * | — | ja (Gruppe/Verein) |
|
||||||
|
| `planning.units.create` | `active_member` | `trainer`, `club_admin`, `division_lead` | `training_units` | ja |
|
||||||
|
| `planning.units.update` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja |
|
||||||
|
| `planning.units.delete` | `active_member` | `club_admin`, `trainer` (eigene) | — | ja |
|
||||||
|
| `planning.units.run` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja |
|
||||||
|
| `planning.coach.execute` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
| `planning.ai.suggest` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — |
|
||||||
|
| `planning.ai.progression_path` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — |
|
||||||
|
|
||||||
|
### 5.8 Fähigkeiten & Scoring
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `skills.catalog.read` | `active_member` | * | — | globaler Katalog |
|
||||||
|
| `skills.discovery.read` | `active_member` | `trainer`, `content_editor` | — | — |
|
||||||
|
| `skill_profiles.read` | `active_member` | * | — | ja (Artefakt) |
|
||||||
|
|
||||||
|
### 5.9 Governance & Meldungen
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID |
|
||||||
|
|---------------|--------------|---------------|------------|
|
||||||
|
| `governance.content_report.create` | `active_member` | * | — |
|
||||||
|
| `governance.content_report.review` | `active_member` | `club_admin` | — |
|
||||||
|
| `governance.change_request.*` | `active_member` | `content_editor`, `club_admin` | — |
|
||||||
|
|
||||||
|
### 5.10 Plattform (nur Portal-Admin / Superadmin)
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Portal-Rolle | Feature-ID |
|
||||||
|
|---------------|--------------|--------------|------------|
|
||||||
|
| `platform.admin.access` | `platform_admin` | `admin`, `superadmin` | — |
|
||||||
|
| `platform.users.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.catalogs.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.maturity_models.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.wiki_import.execute` | `platform_admin` | `superadmin` | `wiki_import` |
|
||||||
|
| `platform.ai_prompts.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.exercise_enrichment.execute` | `platform_admin` | `superadmin` | `ai_calls` |
|
||||||
|
| `platform.user_content.moderate` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.legal_documents.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.media_storage.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.club_creation.approve` | `platform_admin` | `superadmin` | — |
|
||||||
|
|
||||||
|
*Geplant:* `club.creation_request.submit` → `verified_pending_club`; Freigabe über `platform.club_creation.approve`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Standard-Zuordnung Vereinsrolle → Capabilities (v1, fest)
|
||||||
|
|
||||||
|
Diese Tabelle ist die **initiale** Grant-Matrix (`club_role_capability_grants`). Später durch Custom Roles ersetzbar — gleiche Capability-IDs.
|
||||||
|
|
||||||
|
| Capability-Cluster | `club_admin` | `trainer` | `content_editor` | `division_lead` |
|
||||||
|
|--------------------|:------------:|:---------:|:----------------:|:---------------:|
|
||||||
|
| `org.structure.manage` | ✓ | — | — | ✓ (eigene Sparte, später) |
|
||||||
|
| `org.members.manage` | ✓ | — | — | — |
|
||||||
|
| `org.join_request.review` | ✓ | — | — | — |
|
||||||
|
| `exercises.read` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `exercises.create/update` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `exercises.delete` | ✓ | — | — | — |
|
||||||
|
| `exercises.ai.*` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `media.library.*` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `modules.*` / `framework.*` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `planning.*` | ✓ | ✓ | — | ✓ |
|
||||||
|
| `planning.coach.execute` | ✓ | ✓ | — | ✓ |
|
||||||
|
| `governance.content_report.review` | ✓ | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API-Vertrag (Ziel)
|
||||||
|
|
||||||
|
### 7.1 Effektive Rechte für Frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/me/entitlements?club_id={optional}
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort (Ausschnitt):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"account_state": "active_member",
|
||||||
|
"portal_role": "user",
|
||||||
|
"club_id": 12,
|
||||||
|
"club_roles": ["trainer"],
|
||||||
|
"capabilities": {
|
||||||
|
"exercises.read": true,
|
||||||
|
"exercises.ai.suggest": true,
|
||||||
|
"org.members.manage": false
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"ai_calls": { "allowed": true, "used": 4, "limit": 50, "remaining": 46, "reset_at": "2026-07-01T00:00:00Z" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend: Navigation und Buttons nur aus dieser Antwort — **keine** duplizierten Rollen-Checks in JSX (Ausnahme: rein kosmetische Labels).
|
||||||
|
|
||||||
|
### 7.2 Backend-Enforcement
|
||||||
|
|
||||||
|
Zentral (Zielmodul `authorization/capabilities.py` oder Erweiterung `club_tenancy.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert_capability(tenant, "exercises.ai.suggest", club_id=tenant.effective_club_id)
|
||||||
|
assert_club_feature(tenant, "ai_calls", club_id=tenant.effective_club_id) # siehe Club-Membership-Doc
|
||||||
|
# + bestehende Governance auf Objekt-Ebene
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementierungsreihenfolge (Capabilities)
|
||||||
|
|
||||||
|
| Phase | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| C0 | Account-Gates (`unverified`, `verified_pending_club`) — ohne Capability-DB |
|
||||||
|
| C1 | `capabilities` + `club_role_capability_grants` seed aus §5–6 |
|
||||||
|
| C2 | `GET /api/me/entitlements` + Frontend-Nav |
|
||||||
|
| C3 | Enforcement: KI-Endpoints, `exercises.create`, `planning.*` |
|
||||||
|
| C4 | Restliche Router schrittweise; Audit in `ACCESS_LAYER_ENDPOINT_AUDIT.md` |
|
||||||
|
| C5 | Custom Roles (optional) — gleiche IDs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Abgrenzung & Drift-Schutz
|
||||||
|
|
||||||
|
1. **Neue Nutzerfunktion** → `register_capability()` in `rights_registrations/<modul>.py`, dann Endpoint mit `probe_capability`. Namenskonvention hier dokumentieren — **kein** Bulk-Seed in Migrationen.
|
||||||
|
2. **Kontingent** → `register_feature()` im selben Modul; Consume über `consume_club_feature_with_usage`.
|
||||||
|
3. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback.
|
||||||
|
4. Capability ≠ Feature: `exercises.ai.suggest` (darf ich?) vs. `ai_calls` (wie viel übrig?).
|
||||||
|
5. Plattform-Admin-Bypass dokumentieren und auditieren (`platform_admin` sieht Mandant, nicht automatisch alle Quotas).
|
||||||
|
|
||||||
|
Siehe **`docs/working/RIGHTS_AND_FEATURES_REGISTRY.md`** (Registry-first, ersetzt Katalog-first aus 079).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Referenzen
|
||||||
|
|
||||||
|
| Dokument | Inhalt |
|
||||||
|
|----------|--------|
|
||||||
|
| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Vereinsabo, Feature-Registry, Kontingente |
|
||||||
|
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | TenantContext, Governance, Stufe E |
|
||||||
|
| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` | §4.6 Vereinsabo-Zielbild |
|
||||||
|
| `ACCESS_LAYER_ENDPOINT_AUDIT.md` | Endpoint-Pflege |
|
||||||
|
| Mitai `FEATURE_ENFORCEMENT.md` | 4-Phasen-Rollout-Vorbild |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Changelog**
|
||||||
|
|
||||||
|
- 2026-06-06: v1 — Initial-Katalog aus Ist-Code (`club_tenancy`, Router-Inventar) + Ziel-Onboarding.
|
||||||
478
.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Normal file
478
.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
# Vereins-Membership & Feature-System Shinkan v1
|
||||||
|
|
||||||
|
**Status:** Konzept + M1–M3 teilweise produktiv (siehe Entscheidungs-Doc §2)
|
||||||
|
**Stand:** 2026-06-06
|
||||||
|
**Bezüge:** Schwesterprojekt Mitai (`v9c_subscription_system.sql`, `FEATURE_ENFORCEMENT.md`), `CAPABILITY_CATALOG.v1.md`, `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zweck
|
||||||
|
|
||||||
|
Shinkan verkauft und limitiert **nicht Einzelpersonen** (wie Mitai), sondern **Vereine**. Dieses Dokument definiert:
|
||||||
|
|
||||||
|
- das **Feature-Registry**-Muster (limitierbare Funktionen),
|
||||||
|
- das **Vereins-Abo** (`club_plans`, `club_subscriptions`),
|
||||||
|
- **Kontingente** und Enforcement,
|
||||||
|
- die **Abbildung von Mitai** und **Vermeidung von Refactoring-Schulden**.
|
||||||
|
|
||||||
|
Capabilities (Rollen: *darf ich die Funktion?*) → `CAPABILITY_CATALOG.v1.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Grundprinzip: Zwei Achsen
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph cap [Achse 1 — Capabilities]
|
||||||
|
CR[club_role_capability_grants]
|
||||||
|
PR[portal_role_capability_grants]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph feat [Achse 2 — Features / Kontingente]
|
||||||
|
FP[club_plans]
|
||||||
|
FPL[club_plan_limits]
|
||||||
|
FS[club_subscriptions]
|
||||||
|
FU[club_feature_usage]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph gov [Achse 3 — Governance]
|
||||||
|
GV[visibility / club_id / created_by]
|
||||||
|
end
|
||||||
|
|
||||||
|
REQ[HTTP Request] --> ACCT[Account-Lifecycle]
|
||||||
|
ACCT --> cap
|
||||||
|
cap --> gov
|
||||||
|
gov --> feat
|
||||||
|
feat --> EXEC[Ausführung + increment]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Frage | System | Subjekt |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| Darf Trainer X KI nutzen? | Capability `exercises.ai.suggest` | `profile_id` + `club_role` |
|
||||||
|
| Wie viele KI-Aufrufe hat Verein Y? | Feature `ai_calls` | **`club_id`** |
|
||||||
|
| Darf ich diese Übung ändern? | Governance | Objekt + Mitgliedschaft |
|
||||||
|
|
||||||
|
**Beide Achsen müssen erfüllt sein** (AND), außer dokumentierte Plattform-Ausnahmen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mitai-Mapping (was übernehmen, was nicht)
|
||||||
|
|
||||||
|
### 3.1 Übernehmen (Pattern)
|
||||||
|
|
||||||
|
| Mitai (Person) | Shinkan (Verein) | Anmerkung |
|
||||||
|
|----------------|------------------|-----------|
|
||||||
|
| `features` (TEXT-PK, Registry) | `features` (`app='shinkan'`) | Gemeinsames Muster, ggf. später Jinkendo-weit |
|
||||||
|
| `tiers` | `club_plans` | Produktdefinition |
|
||||||
|
| `tier_limits` | `club_plan_limits` | Matrix Plan × Feature |
|
||||||
|
| `user_feature_restrictions` | `club_feature_overrides` | Admin-Override pro Verein |
|
||||||
|
| `user_feature_usage` | `club_feature_usage` | Verbrauch pro Verein |
|
||||||
|
| `access_grants` | `club_access_grants` | Trial, Promo, manuelle Freischaltung |
|
||||||
|
| `check_feature_access()` | `check_club_feature_access()` | Subjekt `club_id` |
|
||||||
|
| `increment_feature_usage()` | `increment_club_feature_usage()` | Nur bei INSERT / KI-Call |
|
||||||
|
| 4-Phasen-Rollout | identisch | Log → UI → Hard-Block |
|
||||||
|
| `GET /api/features/usage` | `GET /api/clubs/{id}/entitlements` | siehe Capability-Doc §7 |
|
||||||
|
|
||||||
|
### 3.2 Nicht übernehmen
|
||||||
|
|
||||||
|
| Mitai | Shinkan-Grund |
|
||||||
|
|-------|---------------|
|
||||||
|
| `profiles.tier` als Haupt-Abo | Verein zahlt, nicht Einzeltrainer |
|
||||||
|
| `subscriptions` (Shinkan `001`, INT-Features) | Ungenutzt, Schema-Drift |
|
||||||
|
| `get_effective_tier(profile_id)` für Shinkan-Limits | Ersetzen durch `get_effective_club_plan(club_id)` |
|
||||||
|
| Profil-zentrierte Enforcement-Hooks allein | Primär `club_id`; Profil nur für Attribution |
|
||||||
|
|
||||||
|
### 3.3 Parallelität Jinkendo-Familie (später)
|
||||||
|
|
||||||
|
`CENTRAL_SUBSCRIPTION_SYSTEM.md` (Mitai): zentrales Personen-Abo über Apps.
|
||||||
|
|
||||||
|
**Zielbild ohne Refactoring:**
|
||||||
|
|
||||||
|
```
|
||||||
|
features.enforcement_subject ∈ { 'club', 'profile', 'portal' }
|
||||||
|
|
||||||
|
effektives_limit(feature) = merge(
|
||||||
|
club_plan_limit(club_id, feature), # Shinkan-Hauptquelle
|
||||||
|
profile_grant_limit(profile_id, feature) # optional Jinkendo-Bonus
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge-Regel (Vorschlag): **Maximum** der erlaubten Kontingente, boolean = OR. Details vor Stripe festlegen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ist-Zustand Shinkan (Drift — zuerst bereinigen)
|
||||||
|
|
||||||
|
| Artefakt | Problem |
|
||||||
|
|----------|---------|
|
||||||
|
| `backend/migrations/001_auth_membership.sql` | `features.id SERIAL`, `tier_limits.tier VARCHAR` |
|
||||||
|
| `backend/auth.py` `check_feature_access()` | Erwartet Mitai-v9c-Schema (`features.id TEXT`, `tier_id`, `limit_type`, …) |
|
||||||
|
| Kein Router | Ruft `check_feature_access` auf |
|
||||||
|
| `profiles.tier` | Existiert, ohne Shinkan-Enforcement |
|
||||||
|
|
||||||
|
**Pflicht vor Phase 3 (Enforcement):** Migration `0XX_club_features_v1.sql` — v9c-kompatibles Feature-Schema + Vereins-Tabellen; alte `001`-Feature-Zeilen migrieren oder deprecaten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ziel-Schema (v1)
|
||||||
|
|
||||||
|
### 5.1 Feature-Registry (app-weit, Mitai-kompatibel)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Konzept — Implementierung als nummerierte Migration
|
||||||
|
CREATE TABLE features (
|
||||||
|
id TEXT PRIMARY KEY, -- z.B. 'ai_calls'
|
||||||
|
app TEXT NOT NULL DEFAULT 'shinkan',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT NOT NULL, -- 'content'|'planning'|'ai'|'org'|'integration'|'platform'
|
||||||
|
limit_type TEXT NOT NULL DEFAULT 'count', -- 'count' | 'boolean'
|
||||||
|
reset_period TEXT NOT NULL DEFAULT 'never', -- 'never' | 'daily' | 'monthly'
|
||||||
|
default_limit INTEGER, -- NULL=∞, 0=aus
|
||||||
|
enforcement_subject TEXT NOT NULL DEFAULT 'club', -- 'club'|'profile'|'portal'
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Vereins-Produkte & Abo
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE club_plans (
|
||||||
|
id TEXT PRIMARY KEY, -- 'free', 'verein_starter', 'verein_pro'
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
price_monthly_cents INTEGER,
|
||||||
|
price_yearly_cents INTEGER,
|
||||||
|
stripe_price_id_monthly TEXT,
|
||||||
|
stripe_price_id_yearly TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_subscriptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES club_plans(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- active|trial|past_due|cancelled
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ends_at TIMESTAMPTZ,
|
||||||
|
trial_ends_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id) -- ein aktiver Plan pro Verein (v1)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_plan_limits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
limit_value INTEGER, -- NULL=∞, 0=deaktiviert
|
||||||
|
UNIQUE (plan_id, feature_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Overrides, Grants, Verbrauch
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE club_feature_overrides (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
limit_value INTEGER NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_access_grants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT REFERENCES club_plans(id),
|
||||||
|
feature_id TEXT REFERENCES features(id), -- optional Einzel-Feature
|
||||||
|
grant_limit INTEGER,
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ends_at TIMESTAMPTZ NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_feature_usage (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reset_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (club_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Optional: Attribution / Fairness / Audit
|
||||||
|
CREATE TABLE club_feature_usage_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL,
|
||||||
|
feature_id TEXT NOT NULL,
|
||||||
|
profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL, -- 'ai_suggest', 'exercise_create', ...
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Capabilities (Rollen — Kurzreferenz)
|
||||||
|
|
||||||
|
Siehe `CAPABILITY_CATALOG.v1.md` für IDs. Tabellen:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE capabilities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
min_account_state TEXT NOT NULL DEFAULT 'active_member',
|
||||||
|
linked_feature_id TEXT REFERENCES features(id), -- optional Kontingent
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_role_capability_grants (
|
||||||
|
role_code TEXT NOT NULL, -- club_admin, trainer, ...
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (role_code, capability_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE portal_role_capability_grants (
|
||||||
|
portal_role TEXT NOT NULL, -- admin, superadmin
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (portal_role, capability_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Shinkan Feature-Katalog (Seed v1)
|
||||||
|
|
||||||
|
Übernahme aus `001_auth_membership.sql` + Ist-Endpoints, angereichert:
|
||||||
|
|
||||||
|
| feature_id | category | limit_type | reset_period | enforcement_subject | Default Free | Beschreibung |
|
||||||
|
|------------|----------|------------|--------------|---------------------|--------------|--------------|
|
||||||
|
| `exercises` | content | count | never | club | 100 | Anzahl Übungen im Verein (Bestand) |
|
||||||
|
| `exercise_media` | content | count | monthly | club | 20 | Medien-Uploads / Monat |
|
||||||
|
| `training_units` | planning | count | monthly | club | 40 | Geplante/durchgeführte Einheiten |
|
||||||
|
| `training_programs` | planning | count | never | club | 5 | Module + Rahmenprogramme (kombiniert v1) |
|
||||||
|
| `training_groups` | org | count | never | club | 10 | Trainingsgruppen |
|
||||||
|
| `active_members` | org | count | never | club | 25 | Aktive Mitglieder |
|
||||||
|
| `ai_calls` | ai | count | monthly | club | 0 | KI-Aufrufe (Suggest, Regenerate, Planung) |
|
||||||
|
| `ai_pipeline` | ai | boolean | never | club | 0 | Erweiterte KI-Pipelines (Batch, später) |
|
||||||
|
| `wiki_import` | integration | boolean | never | portal | 0 | MediaWiki-Import (Superadmin) |
|
||||||
|
| `data_export` | integration | boolean | never | club | 0 | Export-Funktionen (wenn eingeführt) |
|
||||||
|
|
||||||
|
**Hinweis:** Free-Defaults sind Produktentscheidung — Tabelle dient Implementierung.
|
||||||
|
|
||||||
|
### 6.1 Beispiel-Pläne (Seed)
|
||||||
|
|
||||||
|
| plan_id | ai_calls/Monat | exercises | active_members |
|
||||||
|
|---------|----------------|-----------|----------------|
|
||||||
|
| `free` | 0 | 100 | 25 |
|
||||||
|
| `verein_starter` | 30 | 500 | 80 |
|
||||||
|
| `verein_pro` | 200 | NULL (∞) | NULL |
|
||||||
|
| `pilot` | 100 | NULL | NULL |
|
||||||
|
|
||||||
|
Jeder Verein erhält bei Anlage durch Superadmin initial `club_subscriptions.plan_id = 'free'` (oder `pilot`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Auflösungslogik
|
||||||
|
|
||||||
|
### 7.1 Effektiver Vereinsplan
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_effective_club_plan(cur, club_id: int) -> str:
|
||||||
|
"""
|
||||||
|
1. Aktiver club_access_grants mit plan_id (höchste Priorität, Zeitfenster)
|
||||||
|
2. club_subscriptions.status == 'active' → plan_id
|
||||||
|
3. Fallback 'free'
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Feature-Limit (analog Mitai `check_feature_access`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def check_club_feature_access(
|
||||||
|
cur,
|
||||||
|
club_id: int,
|
||||||
|
feature_id: str,
|
||||||
|
*,
|
||||||
|
profile_id: int | None = None, # nur für Logging / optionale Profil-Boni später
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Priorität:
|
||||||
|
1. club_feature_overrides (club_id, feature_id)
|
||||||
|
2. club_plan_limits für get_effective_club_plan(club_id)
|
||||||
|
3. features.default_limit
|
||||||
|
|
||||||
|
Auswertung:
|
||||||
|
- limit_type boolean: limit_value == 1
|
||||||
|
- limit_type count: used < limit (club_feature_usage, reset beachten)
|
||||||
|
|
||||||
|
Returns: { allowed, limit, used, remaining, reason, reset_at }
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Vollständige Request-Kette
|
||||||
|
|
||||||
|
```
|
||||||
|
1. require_auth
|
||||||
|
2. assert_account_state(min_state) # unverified / verified_pending_club / active_member
|
||||||
|
3. get_tenant_context
|
||||||
|
4. assert_capability(tenant, cap_id) # Rollen-Achse
|
||||||
|
5. assert_content_governance(...) # nur bei Objekt-Endpoints
|
||||||
|
6. check_club_feature_access(club_id, feature_id)
|
||||||
|
7. … Business-Logik …
|
||||||
|
8. consume_club_feature_with_usage(…) + merge_feature_usage_into_response(payload, usage)
|
||||||
|
# Standard: zählen, JSON-Log phase=consume, feature_usage in Response
|
||||||
|
9. optional: club_feature_usage_events (profile_id, action)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response-Standard (alle Consume-Endpoints):** JSON-Feld `feature_usage` — Map `feature_id → { allowed, used, limit, remaining, reason, … }` wie `GET /me/entitlements`. Frontend: `request()` synchronisiert Entitlements automatisch (`featureUsageSync.js`); UI-Komponenten brauchen keinen Einzelcode.
|
||||||
|
|
||||||
|
### 7.4 Wer zählt als Verbrauch?
|
||||||
|
|
||||||
|
| Aktion | increment | Subjekt |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
| `POST /exercises` (neu) | `exercises` | `club_id` des Objekts oder `effective_club_id` |
|
||||||
|
| Medien-Upload | `exercise_media` | Verein des Mediums |
|
||||||
|
| KI Suggest/Regenerate | `ai_calls` | `effective_club_id` |
|
||||||
|
| Mitglied hinzufügen | `active_members` | Ziel-`club_id` |
|
||||||
|
| Trainingsgruppe anlegen | `training_groups` | `club_id` |
|
||||||
|
|
||||||
|
**Mitai-Regel:** Counter **nicht** bei UPDATE/DELETE erhöhen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API-Oberfläche
|
||||||
|
|
||||||
|
### 8.1 Nutzer / Vereinsadmin
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/clubs/{club_id}/entitlements
|
||||||
|
```
|
||||||
|
|
||||||
|
Kombiniert Capabilities + Feature-Kontingente (siehe `CAPABILITY_CATALOG.v1.md` §7.1).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/me/entitlements?club_id=12
|
||||||
|
```
|
||||||
|
|
||||||
|
Bequemer Alias für aktiven Verein.
|
||||||
|
|
||||||
|
### 8.2 Superadmin / Plattform
|
||||||
|
|
||||||
|
| Endpoint | Zweck |
|
||||||
|
|----------|-------|
|
||||||
|
| `GET/PUT /api/admin/club-plans` | Plan-CRUD |
|
||||||
|
| `GET/PUT /api/admin/club-plan-limits` | Matrix |
|
||||||
|
| `GET/PUT /api/admin/clubs/{id}/subscription` | Verein-Abo |
|
||||||
|
| `GET/PUT /api/admin/clubs/{id}/feature-overrides` | Sonderkontingente |
|
||||||
|
| `POST /api/admin/clubs/{id}/access-grants` | Trial/Promo |
|
||||||
|
|
||||||
|
Vorbild UI: Mitai `AdminTierLimitsPage.jsx`, `AdminUserRestrictionsPage.jsx` → Vereins-Kontext.
|
||||||
|
|
||||||
|
### 8.3 Geplant: Vereinsgründung
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/club-creation-requests # Nutzer (verified_pending_club)
|
||||||
|
GET /api/admin/club-creation-requests
|
||||||
|
POST /api/admin/club-creation-requests/{id}/approve # legt club + subscription an
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Vier-Phasen-Rollout (aus Mitai)
|
||||||
|
|
||||||
|
| Phase | Shinkan-Aktivität | Nutzer sichtbar? |
|
||||||
|
|-------|-------------------|------------------|
|
||||||
|
| **0** | Schema-Migration, Seed `features` + `club_plans`, Drift `001` bereinigen | Nein |
|
||||||
|
| **1** | Account-Gates + Capability-Grants (ohne Limits) | Onboarding-Hinweise |
|
||||||
|
| **2** | `check_club_feature_access` — **nur JSON-Log** (`feature_logger` analog Mitai) | Nein |
|
||||||
|
| **3** | `GET …/entitlements` + UsageBadge im UI | Ja (Kontingent-Anzeige) |
|
||||||
|
| **4** | HTTP 403 bei Limit + `increment` | Ja (Hard-Block) |
|
||||||
|
|
||||||
|
**Reihenfolge innerhalb Phase 4:** zuerst `ai_calls`, dann `exercise_media`, dann Bestands-Limits (`exercises`, `active_members`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. CI / Test-Isolation (Betrieb)
|
||||||
|
|
||||||
|
Unabhängig vom Membership-System — **Pflicht** wegen Prod-Vorfälle (`access_layer_it_*@test.local`):
|
||||||
|
|
||||||
|
| Regel | Umsetzung |
|
||||||
|
|-------|-----------|
|
||||||
|
| Integrationstests nie gegen Prod-DB | Eigene Test-DB oder Job-Postgres in Gitea |
|
||||||
|
| `ENVIRONMENT=production` + `ALLOW_INTEGRATION_TESTS` | Default `0`, Tests abbrechen |
|
||||||
|
| Test-Accounts | E-Mail `@test.local` oder `profiles.is_test_account` |
|
||||||
|
| Cleanup | Fixture-`finally` + Nightly-Job löscht Leichen |
|
||||||
|
|
||||||
|
`.gitea/workflows/test.yml`: pytest-backend gegen Deploy-DB **ersetzen** durch isolierte DB (eigenes Epic, parallel zu Membership).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Implementierungs-Roadmap (gesamt)
|
||||||
|
|
||||||
|
| Schritt | Deliverable | Membership-relevant |
|
||||||
|
|---------|-------------|-------------------|
|
||||||
|
| M0 | CI-Isolation + Prod-Cleanup-Runbook | Nein |
|
||||||
|
| M1 | Migration Feature-Schema v9c + `club_plans`/`club_subscriptions` (leer nutzbar) | **Ja** |
|
||||||
|
| M2 | `check_club_feature_access` + Seed Pläne | **Ja** |
|
||||||
|
| M3 | Account-Lifecycle + Capability-Grants | Capabilities |
|
||||||
|
| M4 | `GET /me/entitlements` | **Ja** |
|
||||||
|
| M5 | Enforcement `ai_calls` (Phase 4) | **Ja** |
|
||||||
|
| M6 | Admin Plan-Matrix UI | **Ja** |
|
||||||
|
| M7 | `club_creation_requests` | Prozess |
|
||||||
|
| M8 | Stripe / Rechnung | Später |
|
||||||
|
|
||||||
|
**Nach Produktentscheidungen 2026-06-06** (Details `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §4):
|
||||||
|
|
||||||
|
| Phase | Paket | Priorität |
|
||||||
|
|-------|--------|-----------|
|
||||||
|
| A | Onboarding-Gates vollständig (`verified_pending_club`) | **Als Nächstes** |
|
||||||
|
| B | M7 Vereinsgründung beantragen | hoch |
|
||||||
|
| C | M5 Hard-Block `ai_calls` | danach |
|
||||||
|
| D | M6 Superadmin-UI | danach |
|
||||||
|
| E | Systemrolle `co_trainer` + Frontend-Entitlements | v1 Rollen |
|
||||||
|
| F | Trainer-Member-Budgets (v2) | später |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Offene Produktentscheidungen
|
||||||
|
|
||||||
|
Vor M6 festlegen:
|
||||||
|
|
||||||
|
1. **Zählen `active_members`:** alle Mitglieder oder nur Rollen mit Planungsrecht?
|
||||||
|
2. **Soft-Limit vs. Hard-Stop:** Warnung bei 80 % oder sofort 403?
|
||||||
|
3. **Pilotverein:** eigener Plan `pilot` mit hohen Limits?
|
||||||
|
4. **KI-Fairness:** nur Vereinslimit oder zusätzlich Max pro Trainer/Monat?
|
||||||
|
5. **Offizielle Inhalte:** für `verified_pending_club` sichtbar oder gesperrt? → **entschieden: gesperrt** (`MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.1)
|
||||||
|
6. **Portal `admin` vs. `superadmin`:** Wer darf Vereine anlegen? (Ziel: nur `superadmin` für Freigabe)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Referenzen
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| `c:/dev/mitai-jinkendo/backend/migrations/v9c_subscription_system.sql` | Mitai-Schema-Vorlage |
|
||||||
|
| `c:/dev/mitai-jinkendo/.claude/docs/architecture/FEATURE_ENFORCEMENT.md` | 4-Phasen-Modell |
|
||||||
|
| `c:/dev/mitai-jinkendo/.claude/docs/technical/MEMBERSHIP_SYSTEM.md` | Mitai-Hauptdoku |
|
||||||
|
| `c:/dev/mitai-jinkendo/.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` | Jinkendo-Familie später |
|
||||||
|
| `CAPABILITY_CATALOG.v1.md` | Rollen & Capabilities |
|
||||||
|
| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6 | Ursprüngliches Vereinsabo-Zielbild |
|
||||||
|
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Stufe E/F |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Changelog**
|
||||||
|
|
||||||
|
- 2026-06-06: v1 — Mitai-Mapping, Ziel-Schema, Feature-Seed, Auflösungslogik, Rollout.
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
# Exercises API Specification
|
# Exercises API Specification
|
||||||
|
|
||||||
**Version:** 1.5
|
**Version:** 1.6
|
||||||
**Datum:** 2026-05-08
|
**Datum:** 2026-05-20
|
||||||
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
|
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
|
||||||
**Autor:** Claude Code
|
**Autor:** Claude Code
|
||||||
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
|
**Änderungen v1.6:** Freigabelevel-UI-Hinweis; `exercise_skills` ohne `is_primary` in Requests (Legacy-Feld wird ignoriert/forciert false); Permissions-Bereich an Ist-Code angeglichen; Intensität kanonisch `niedrig|mittel|hoch`
|
||||||
**Änderungen v1.5:** Medien-/Inline-Workflow aktualisiert (Modal-Picker, Drag&Drop UX im Frontend), Klarstellung zu `context` (legacy/optional), Hinweise zu Platzhaltern in Rich-Text-Feldern.
|
**Änderungen v1.5:** Medien-/Inline-Workflow aktualisiert (Modal-Picker, Drag&Drop UX im Frontend), Klarstellung zu `context` (legacy/optional), Hinweise zu Platzhaltern in Rich-Text-Feldern.
|
||||||
|
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
|
||||||
**Änderungen v1.3:** `GET /exercises` erweiterte Query-Parameter (`include_variants`, Multi-Filter, `ai_search`-Platzhalter); Dokumentation angepasst
|
**Änderungen v1.3:** `GET /exercises` erweiterte Query-Parameter (`include_variants`, Multi-Filter, `ai_search`-Platzhalter); Dokumentation angepasst
|
||||||
**Änderungen v1.2:** KI-Assistenz Endpoints, Skill-Level-System (benannte Stufen), intensity als low/medium/high
|
**Änderungen v1.2:** KI-Assistenz Endpoints, Skill-Level-System (benannte Stufen), intensity als low/medium/high
|
||||||
**Änderungen v1.1:** Exercise Blocks Endpoints, Permissions dokumentiert, age_groups korrigiert
|
**Änderungen v1.1:** Exercise Blocks Endpoints, Permissions dokumentiert, age_groups korrigiert
|
||||||
|
|
@ -185,11 +186,11 @@ Lightweight-Liste; bei `include_variants=true` zusätzlich z. B.:
|
||||||
"skill_id": 10,
|
"skill_id": 10,
|
||||||
"skill_name": "Distanzgefühl",
|
"skill_name": "Distanzgefühl",
|
||||||
"skill_category": "Kumite",
|
"skill_category": "Kumite",
|
||||||
"is_primary": true,
|
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau",
|
"target_level": "aufbau",
|
||||||
"ai_suggested": false
|
"ai_suggested": false,
|
||||||
|
"is_primary": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -307,7 +308,6 @@ Lightweight-Liste; bei `include_variants=true` zusätzlich z. B.:
|
||||||
"skills": [
|
"skills": [
|
||||||
{
|
{
|
||||||
"skill_id": 10,
|
"skill_id": 10,
|
||||||
"is_primary": true,
|
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau"
|
"target_level": "aufbau"
|
||||||
|
|
@ -578,7 +578,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau",
|
"target_level": "aufbau",
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"is_primary": true,
|
|
||||||
"confidence": 0.92
|
"confidence": 0.92
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -588,7 +587,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "einsteiger",
|
"required_level": "einsteiger",
|
||||||
"target_level": "grundlagen",
|
"target_level": "grundlagen",
|
||||||
"intensity": "mittel",
|
"intensity": "mittel",
|
||||||
"is_primary": false,
|
|
||||||
"confidence": 0.74
|
"confidence": 0.74
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -621,6 +619,38 @@ Trainer muss im Frontend aktiv übernehmen.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
|
**UI-Hinweis:** Das Feld `visibility` heißt in der Oberfläche **Freigabelevel** (`exerciseGovernanceLabels.js`).
|
||||||
|
|
||||||
|
### Lesen (`GET /exercises`, `GET /exercises/{id}`)
|
||||||
|
|
||||||
|
| `visibility` | Wer darf lesen? |
|
||||||
|
|--------------|-----------------|
|
||||||
|
| `official` | Plattform-weit |
|
||||||
|
| `private` | Ersteller (`created_by`); Plattform-Admin |
|
||||||
|
| `club` | Aktive Mitglieder des Objekt-`club_id`; Plattform-Admin ohne Mitgliedschaft (Audit-Zugang) |
|
||||||
|
|
||||||
|
Implementierung: `library_content_visible_to_profile` / `exercise_visible_to_profile` in `club_tenancy.py`.
|
||||||
|
|
||||||
|
### Bearbeiten (`PUT`, Varianten-CRUD, Medien an Übung)
|
||||||
|
|
||||||
|
| Bedingung | Wer darf bearbeiten? |
|
||||||
|
|-----------|----------------------|
|
||||||
|
| Ersteller | Immer (eigene Übung) |
|
||||||
|
| Plattform-Admin | Immer |
|
||||||
|
| `visibility=club` | Zusätzlich **`can_plan_in_club`** im Objekt-Verein: `club_admin`, `trainer`, `content_editor`, `division_lead` |
|
||||||
|
|
||||||
|
Implementierung: `_assert_can_edit_exercise` in `exercises.py`. **Varianten** haben kein eigenes Owner-Feld — gleiche Prüfung wie Eltern-Übung.
|
||||||
|
|
||||||
|
### Löschen (`DELETE /exercises/{id}`)
|
||||||
|
|
||||||
|
| `visibility` | Wer darf löschen? |
|
||||||
|
|--------------|-------------------|
|
||||||
|
| `official` | Nur Plattform-Admin |
|
||||||
|
| `club` | Nur **`club_admin`** im Objekt-Verein |
|
||||||
|
| `private` | Ersteller; oder Vereins-Admin, der mit dem Ersteller einen gemeinsamen Verein teilt |
|
||||||
|
|
||||||
|
Implementierung: `_assert_can_delete_exercise` in `exercises.py`.
|
||||||
|
|
||||||
### Sichtbarkeits-Workflow
|
### Sichtbarkeits-Workflow
|
||||||
|
|
||||||
| Von → Nach | Wer darf das? |
|
| Von → Nach | Wer darf das? |
|
||||||
|
|
@ -638,11 +668,12 @@ Trainer muss im Frontend aktiv übernehmen.
|
||||||
| `club → official` | Club-Admin, Super-Admin |
|
| `club → official` | Club-Admin, Super-Admin |
|
||||||
| `official → club` | Super-Admin |
|
| `official → club` | Super-Admin |
|
||||||
|
|
||||||
### Owner-Checks
|
### Owner-Checks (veraltet — siehe Tabellen oben)
|
||||||
|
|
||||||
- **Bearbeiten** (PUT): Nur Ersteller oder Club-Admin
|
Die folgenden Kurzregeln sind durch die Ist-Implementierung ersetzt; nur zur historischen Einordnung:
|
||||||
- **Löschen** (DELETE): Nur Ersteller oder Super-Admin
|
|
||||||
- **Lesen** (`private`): Nur Ersteller
|
- ~~Bearbeiten (PUT): Nur Ersteller oder Club-Admin~~ → siehe **Bearbeiten**-Tabelle (`can_plan_in_club`)
|
||||||
|
- ~~Löschen (DELETE): Nur Ersteller oder Super-Admin~~ → siehe **Löschen**-Tabelle
|
||||||
|
|
||||||
**403 Fehler-Beispiel:**
|
**403 Fehler-Beispiel:**
|
||||||
```json
|
```json
|
||||||
|
|
@ -904,7 +935,8 @@ Trainer muss im Frontend aktiv übernehmen.
|
||||||
### Exercise Skills
|
### Exercise Skills
|
||||||
- `required_level`: enum – `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
|
- `required_level`: enum – `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
|
||||||
- `target_level`: enum – gleiche Werte (optional/nullable)
|
- `target_level`: enum – gleiche Werte (optional/nullable)
|
||||||
- `intensity`: enum – `niedrig | mittel | hoch` (optional/nullable)
|
- `intensity`: enum – **`niedrig | mittel | hoch`** (optional/nullable; Default beim Speichern **`mittel`**)
|
||||||
|
- `is_primary`: **Legacy** — Spalte existiert in DB, wird bei POST/PUT **nicht ausgewertet** (immer `false` gespeichert); UI liefert/speichert kein Primär-Flag mehr; Scoring ignoriert das Feld
|
||||||
- `target_level` sollte >= `required_level` sein (Warnung, kein Fehler)
|
- `target_level` sollte >= `required_level` sein (Warnung, kein Fehler)
|
||||||
|
|
||||||
### Exercise Block Item
|
### Exercise Block Item
|
||||||
|
|
|
||||||
|
|
@ -99,20 +99,21 @@ Exercise Block ──── (N) Block Items ──── (1) Exercise
|
||||||
|
|
||||||
### 1.3 M:N Beziehungen (Primary/Secondary Pattern)
|
### 1.3 M:N Beziehungen (Primary/Secondary Pattern)
|
||||||
|
|
||||||
**Regel:** Alle Katalog-Zuordnungen nutzen M:N mit `is_primary` Flag.
|
**Regel:** Katalog-Zuordnungen (Fokus, Stil, Zielgruppe, …) nutzen M:N mit optionalem `is_primary`-Flag.
|
||||||
|
|
||||||
**Betroffene Relationen:**
|
**Betroffene Relationen (mit `is_primary`):**
|
||||||
- `exercise_focus_areas` (Übung ↔ Fokusbereiche)
|
- `exercise_focus_areas` (Übung ↔ Fokusbereiche)
|
||||||
- `exercise_styles` (Übung ↔ Trainingsstile)
|
- `exercise_styles` / `exercise_style_directions` (Übung ↔ Stilrichtungen)
|
||||||
|
- `exercise_training_types` (Übung ↔ Trainingsstile)
|
||||||
- `exercise_target_groups` (Übung ↔ Zielgruppen)
|
- `exercise_target_groups` (Übung ↔ Zielgruppen)
|
||||||
- `exercise_training_characters` (Übung ↔ Trainingscharaktere)
|
|
||||||
- `exercise_skills` (Übung ↔ Fähigkeiten)
|
|
||||||
|
|
||||||
**Primary/Secondary Semantik:**
|
**Ausnahme — `exercise_skills`:** Kein Primär-Flag in UI/API mehr; stattdessen **`intensity`** (`niedrig` \| `mittel` \| `hoch`, Default `mittel`). Spalte `is_primary` bleibt Legacy (Backend speichert immer `false`).
|
||||||
|
|
||||||
|
**Primary/Secondary Semantik (Katalog-Dimensionen):**
|
||||||
- **Primary:** Hauptzuordnung, entscheidend für Filter/Suche
|
- **Primary:** Hauptzuordnung, entscheidend für Filter/Suche
|
||||||
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
|
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
|
||||||
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension
|
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension (wo UI das noch anbietet)
|
||||||
- **UI:** Primary wird visuell hervorgehoben (z.B. fett, farbig)
|
- **UI:** Primary wird visuell hervorgehoben (z. B. fett, farbig) — Fähigkeiten: Intensitäts-Segmente statt Primary
|
||||||
|
|
||||||
**Legacy-Felder (DEPRECATED):**
|
**Legacy-Felder (DEPRECATED):**
|
||||||
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
|
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
# Frontend Routing & Navigation Specification
|
# Frontend Routing & Navigation Specification
|
||||||
|
|
||||||
**Version:** 1.2
|
**Version:** 1.3
|
||||||
**Datum:** 2026-04-30
|
**Datum:** 2026-05-20
|
||||||
**Status:** DRAFT - Awaiting Review
|
**Status:** DRAFT - Awaiting Review
|
||||||
**Autor:** Claude Code
|
**Autor:** Claude Code
|
||||||
|
**Änderungen v1.3:** Übungsformular Tab-Navigation unter `/exercises/:id/edit` (Stammdaten … Medien & Mehr); Freigabelevel als UI-Begriff
|
||||||
**Änderungen v1.2:** Übersicht **Übungen**: Tabs Liste \| Progressionsgraphen auf `/exercises`; Progressions-Editor ohne neue Routen (Panel + Formularblock unter `/exercises/:id/edit`)
|
**Änderungen v1.2:** Übersicht **Übungen**: Tabs Liste \| Progressionsgraphen auf `/exercises`; Progressions-Editor ohne neue Routen (Panel + Formularblock unter `/exercises/:id/edit`)
|
||||||
**Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
|
**Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@
|
||||||
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
||||||
/exercises/new → ExerciseFormPage (Create)
|
/exercises/new → ExerciseFormPage (Create)
|
||||||
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
|
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
|
||||||
/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline + Block Progressionsgraph)
|
/exercises/{id}/edit → ExerciseFormPage (Edit: Registerkarten + Varianten inline + Progressionsgraph)
|
||||||
|
|
||||||
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
|
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
|
||||||
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
||||||
|
|
@ -35,6 +36,25 @@
|
||||||
- Pagination: `/exercises?limit=50&offset=100`
|
- Pagination: `/exercises?limit=50&offset=100`
|
||||||
- Sortierung: `/exercises?sort=created_at&order=desc`
|
- Sortierung: `/exercises?sort=created_at&order=desc`
|
||||||
|
|
||||||
|
### 1.2 Übungsformular – Registerkarten (`/exercises/new`, `/exercises/:id/edit`)
|
||||||
|
|
||||||
|
**Implementierung:** `ExerciseFormPageRoot.jsx` + `ExerciseFormLayout.jsx` (`ExerciseFormTabBar`, `ExerciseFormPanel`).
|
||||||
|
|
||||||
|
| Tab-ID | Label | Verfügbarkeit |
|
||||||
|
|--------|-------|---------------|
|
||||||
|
| `stammdaten` | Stammdaten | immer |
|
||||||
|
| `anleitung` | Anleitung | immer |
|
||||||
|
| `einordnung` | Einordnung | immer |
|
||||||
|
| `kombination` | Kombination | nur `exercise_kind=combination` |
|
||||||
|
| `varianten` | Varianten | Edit-Modus; nicht bei Kombination; disabled bei Neuanlage |
|
||||||
|
| `medien` | Medien & Mehr | Edit-Modus; disabled bei Neuanlage |
|
||||||
|
|
||||||
|
**UX-Regeln:**
|
||||||
|
- Nur ein Panel sichtbar (`activeFormTab`); Navigation über `PageSectionNav`.
|
||||||
|
- **Freigabelevel** (Feld `visibility`) in Stammdaten — Konstante `EXERCISE_VISIBILITY_FIELD_LABEL`.
|
||||||
|
- Varianten-Änderungen werden mit **Speichern** in der Aktionsleiste persistiert (`persistPendingVariantChanges`); Button „Variante anlegen“ optional sofort.
|
||||||
|
- Kein URL-Hash pro Tab (Tab-State nur lokal).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Navigation-Patterns
|
## 2. Navigation-Patterns
|
||||||
|
|
@ -673,7 +693,7 @@ function App() {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version:** 1.2
|
**Version:** 1.3
|
||||||
**Letzte Änderung:** 2026-04-30
|
**Letzte Änderung:** 2026-05-20
|
||||||
**Status:** REVIEWED - Pending Implementation
|
**Status:** REVIEWED - Pending Implementation
|
||||||
**Review-Änderungen:** Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)
|
**Review-Änderungen:** Formular-Registerkarten; Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,16 @@
|
||||||
**Änderungen v1.1:** Prompts sind nicht hardcoded – sie werden aus der DB geladen (AI_PROMPT_SYSTEM_SPEC.md)
|
**Änderungen v1.1:** Prompts sind nicht hardcoded – sie werden aus der DB geladen (AI_PROMPT_SYSTEM_SPEC.md)
|
||||||
**Verwandte Specs:** AI_PROMPT_SYSTEM_SPEC.md (Prompt-DB + Platzhalter), SKILLS_MATRIX_SPEC.md (Fähigkeitsmatrix)
|
**Verwandte Specs:** AI_PROMPT_SYSTEM_SPEC.md (Prompt-DB + Platzhalter), SKILLS_MATRIX_SPEC.md (Fähigkeitsmatrix)
|
||||||
|
|
||||||
|
**Übergeordnete Produkt-Vision** (breiter Scope: Zielausbau, bereichsweise vs. Gesamtüberarbeitung, Varianten, Planungs-/Nachbereitungskontext, Admin-Masse):
|
||||||
|
`functional/AI_EXERCISE_ASSISTANT_VISION.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Übersicht
|
## 1. Übersicht
|
||||||
|
|
||||||
Zwei KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen:
|
KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen (Mindestpaket dieser Spec):
|
||||||
|
|
||||||
|
**Hinweis:** Die beiden folgenden Zeilen entsprechen **P0** der Phasierung in **`AI_EXERCISE_ASSISTANT_VISION.md`**; spätere Funkteile sind dort beschrieben.
|
||||||
|
|
||||||
| Funktion | Ziel |
|
| Funktion | Ziel |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
|
|
@ -155,7 +160,38 @@ KI gibt Vorschläge
|
||||||
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
|
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
|
||||||
Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"title": "Maai - Distanzübung",
|
"title": "Maai - Distanzübung",
|
||||||
|
|
@ -164,8 +200,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Required Fields:** mindestens `goal` ODER `execution` (je länger, desto besser)
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -182,7 +216,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau",
|
"target_level": "aufbau",
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"is_primary": true,
|
|
||||||
"confidence": 0.92
|
"confidence": 0.92
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -192,7 +225,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "einsteiger",
|
"required_level": "einsteiger",
|
||||||
"target_level": "grundlagen",
|
"target_level": "grundlagen",
|
||||||
"intensity": "mittel",
|
"intensity": "mittel",
|
||||||
"is_primary": false,
|
|
||||||
"confidence": 0.74
|
"confidence": 0.74
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
243
.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md
Normal file
243
.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Membership, RBAC & Kontingente — Produktentscheidungen
|
||||||
|
|
||||||
|
**Status:** Verbindlich (Zielbild & Roadmap-Priorisierung)
|
||||||
|
**Stand:** 2026-06-06
|
||||||
|
**Bezüge:** `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
|
||||||
|
|
||||||
|
Dieses Dokument hält **getroffene Produktentscheidungen** fest (Session 2026-06-06) und ergänzt die v1-Konzept-Specs um Umsetzungsrichtung. Technischer Implementierungsstand: Abschnitt 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Getroffene Entscheidungen
|
||||||
|
|
||||||
|
### 1.1 Onboarding: `verified_pending_club`
|
||||||
|
|
||||||
|
Nutzer **ohne aktive Vereinsmitgliedschaft** (E-Mail verifiziert) dürfen **nur**:
|
||||||
|
|
||||||
|
| Erlaubt | Nicht erlaubt (Zielbild) |
|
||||||
|
|---------|---------------------------|
|
||||||
|
| Konto / Einstellungen | Übungen, Planung, KI, Medien |
|
||||||
|
| Vereinsverzeichnis lesen | Vereinsinterne Inhalte (`club`), private Fremdinhalte |
|
||||||
|
| **Beitrittsantrag** an bestehenden Verein | Vollzugriff auf Bibliothek / offizielle Inhalte (Lesen) — **bewusst gesperrt** bis Mitgliedschaft |
|
||||||
|
| **Vereinsgründung beantragen** (Prozess M7, Superadmin-Freigabe) | |
|
||||||
|
|
||||||
|
**Kein** „Bibliothek durchstöbern“ für Bewerber — reduziert Datenexposition und vereinfacht UX („erst Verein, dann Arbeit“).
|
||||||
|
|
||||||
|
Technischer Zustand: `account_state = verified_pending_club` (siehe `CAPABILITY_CATALOG.v1.md` §3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Rollenmodell: Risikoarm statt Big-Bang
|
||||||
|
|
||||||
|
**Zielbild (langfristig):**
|
||||||
|
|
||||||
|
- **Fest:** nur `superadmin` (Plattform) als nicht konfigurierbare Systemrolle.
|
||||||
|
- **Dynamisch konfigurierbar:** alle Vereinsrollen und deren Capability-Bundles (später `club_custom_roles`).
|
||||||
|
- Optional: `admin` (Plattform) als abgeschwächter Portal-Admin bleibt vorerst bestehen (Ist-Code).
|
||||||
|
|
||||||
|
**Entscheidung v1 (risikoarm):**
|
||||||
|
|
||||||
|
| Maßnahme | Jetzt | Später |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| Alte Helfer (`can_plan_in_club`, `if (club_admin)` in JSX) | **Behalten** — weiter produktiv | Schrittweise durch `entitlements` ersetzen |
|
||||||
|
| Neue Endpoints / Features | Nur über **Capability-IDs** + Audit | — |
|
||||||
|
| Neue Vereinsrollen | Als **Systemrollen** ergänzen (z. B. `co_trainer`) | Custom Roles UI |
|
||||||
|
| `club_custom_roles` | **Nicht** in v1 | v2 Epic |
|
||||||
|
|
||||||
|
**Begründung:** Backend und Frontend haben hunderte Verdrahtungen auf `trainer` / `club_admin` / Plattform-Rollen. Parallelbetrieb Capability-System + Legacy-Helfer ist sicherer als einmaliges Aufbrechen.
|
||||||
|
|
||||||
|
**Co-Trainer (geplant als Systemrolle):** weniger Capabilities als `trainer` (z. B. kein `planning.*`, kein `exercises.create`) — Umsetzung nach Onboarding-Gates + Entitlements-Rollout, nicht vorher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Vereins-Kontingente (Membership-Pakete)
|
||||||
|
|
||||||
|
**Jetzt:** Schema und Anzeige vorbereiten; **keine** detaillierte Paket-Logik (z. B. „3 Trainer + 10 Co-Trainer“) implementieren.
|
||||||
|
|
||||||
|
| Vorbereitet (DB/Module) | Bewusst zurückgestellt |
|
||||||
|
|-------------------------|-------------------------|
|
||||||
|
| `features`, `club_plans`, `club_subscriptions` | Eigene Feature-IDs `trainer_seats` / `co_trainer_seats` |
|
||||||
|
| Bestands-Limits (`exercises`, `training_groups`, `ai_calls`, …) | Zählregel „nur planungsberechtigte Mitglieder“ vs. alle Mitglieder |
|
||||||
|
| `GET /me/entitlements` Feature-Teil | Stripe / Rechnung (M8) |
|
||||||
|
|
||||||
|
**Prinzip:** Neue Kontingent-Typen = neue `features`-Zeile + Plan-Limits + optional Capability-`linked_feature_id` — ohne Schema-Bruch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 Trainer-Budget innerhalb Vereins-Kontingent (v2)
|
||||||
|
|
||||||
|
**Anforderung:** Vereins-KI-Kontingent liegt beim Verein; **Vereinsadmin** kann pro Trainer ein **Sub-Budget** vergeben (Fairness, „Kontingent-Fresser“).
|
||||||
|
|
||||||
|
**Entscheidung:**
|
||||||
|
|
||||||
|
- v1: nur **Vereins-Ebene** (`club_plan_limits`, `club_feature_usage`).
|
||||||
|
- v2: neue Tabellen (Skizze):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Skizze — noch nicht migriert
|
||||||
|
club_member_feature_budgets (club_id, profile_id, feature_id, limit_value, …)
|
||||||
|
club_member_feature_usage (club_id, profile_id, feature_id, usage_count, reset_at, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prüf-Kette v2:** Capability → Mitglieds-Budget (falls gesetzt, `profile_id` aus Session) → Vereins-Kontingent.
|
||||||
|
|
||||||
|
**Fairness-Modell (offen, Tendenz):** harte Sub-Budgets (Modell A) — Trainer darf sein Budget nicht überschreiten, auch wenn Verein noch Rest hat.
|
||||||
|
|
||||||
|
**Roadmap:** Phase 5b / Meilenstein **M9** in `docs/working/RBAC_ENFORCEMENT_ROADMAP.md` — Vereinsadmin-UI zur Verteilung, Entitlements mit persönlichem + Vereins-Rest, Auswertung je Person.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 Enforcement-Phasen (unverändert, bestätigt)
|
||||||
|
|
||||||
|
| Phase | Verhalten | Nutzer sichtbar |
|
||||||
|
|-------|-----------|-----------------|
|
||||||
|
| 2 (M2/M3) | JSON-Log, kein Block | Nein (außer Logs) |
|
||||||
|
| 3 (M4) | `GET /me/entitlements` + Badge | Kontingent-Anzeige |
|
||||||
|
| 4 (M5+) | HTTP 403 + `increment` | Hard-Block |
|
||||||
|
|
||||||
|
Env-Schalter: `ACCOUNT_GATE_ENFORCE` (Default `1`, Endpoint-Helfer), `ACCOUNT_GATE_API_ENFORCE` (Default `1`, API-Middleware Phase A), `CAPABILITY_ENFORCE` / `CLUB_FEATURE_ENFORCE` (Default `0`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Implementierungsstand (Ist, Codebase)
|
||||||
|
|
||||||
|
**DB-Schema:** `20260606083` · App **0.8.199** (`backend/version.py`)
|
||||||
|
**Roadmap (detailliert):** `docs/working/RBAC_ENFORCEMENT_ROADMAP.md`
|
||||||
|
|
||||||
|
### M1 — Feature-Schema v9c ✅
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| Migration `078_club_features_and_plans.sql` | ✅ |
|
||||||
|
| Legacy `001` archiviert | ✅ |
|
||||||
|
| `club_plans`, `club_subscriptions`, Usage-Tabellen | ✅ |
|
||||||
|
| Seed Features + Pläne (`free`, …) | ✅ |
|
||||||
|
| `club_features.py`: `check_club_feature_access`, `get_effective_club_plan` | ✅ |
|
||||||
|
| Backfill Vereine → Plan `free` | ✅ |
|
||||||
|
|
||||||
|
### M2 — Feature-Probe (Log only) ✅
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| `club_feature_logger.py` → `club-feature-usage.log` | ✅ |
|
||||||
|
| `probe_club_feature_access()` | ✅ |
|
||||||
|
| Hooks: KI-Endpoints, `POST /exercises`, Medien-Upload, Planungs-KI | ✅ |
|
||||||
|
| Consume-Standard + `feature_usage` in Response (`ai_calls`) | ✅ |
|
||||||
|
| `CLUB_FEATURE_ENFORCE=0` (Default) | ✅ |
|
||||||
|
|
||||||
|
### M3 — Account-Lifecycle + Capability-Grants ⚠️ teilweise
|
||||||
|
|
||||||
|
| Deliverable | Status | Lücke |
|
||||||
|
|-------------|--------|-------|
|
||||||
|
| Migration `079_capabilities.sql` + Seed | ✅ | — |
|
||||||
|
| `account_lifecycle.py`, `resolve_account_state` | ✅ | — |
|
||||||
|
| `capabilities.py`, `check_capability`, `probe_capability` | ✅ | — |
|
||||||
|
| `TenantContext.account_state` | ✅ | — |
|
||||||
|
| `GET /profiles/me` → `account_state`, `club_roles` | ✅ | — |
|
||||||
|
| Account-Gates auf **Schreib-/KI-Endpoints** | ✅ | Lesepfade für Bewerber noch offen |
|
||||||
|
| `CAPABILITY_ENFORCE=0` (nur Log) | ✅ | — |
|
||||||
|
| Onboarding UX: nur Bewerbung/Gründung | ✅ | Phase A: API-Middleware + `/onboarding` + reduzierte Nav |
|
||||||
|
| `club_creation_requests` (M7) | ✅ Basis | Capabilities + Admin-Freigabe |
|
||||||
|
| Quota-Bypass via Capability-Grants (083) | ✅ | kein paralleles Exemption-Schema |
|
||||||
|
| Custom Roles / Co-Trainer | ❌ | bewusst v2 |
|
||||||
|
| Legacy-Helfer entfernt | ❌ | bewusst parallel |
|
||||||
|
|
||||||
|
### M4 — Anzeige ✅ teilweise
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| `GET /api/me/entitlements` | ✅ |
|
||||||
|
| `EntitlementsContext`, `hasCapability()` | ✅ (UI nutzt noch kaum) |
|
||||||
|
| `FeatureUsageBadge` | ✅ nur KI im Übungsformular |
|
||||||
|
| `featureUsageSync` in `request()` | ✅ |
|
||||||
|
|
||||||
|
### M5 — Hard-Block + vollständiger Verbrauch ⚠️
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| `consume_club_feature_with_usage` Standard | ✅ `ai_calls` |
|
||||||
|
| `CLUB_FEATURE_ENFORCE=1` produktiv | ❌ Default 0 |
|
||||||
|
| Consume `exercises`, `exercise_media`, … | ❌ |
|
||||||
|
|
||||||
|
### M6 — Admin UI Rollen & Rechte ⚠️
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| `/admin/rights` Capability-Matrix (Portal + Verein) | ✅ |
|
||||||
|
| Klartext zuerst, Enforcement-Badge | ✅ 2026-06-07 |
|
||||||
|
| Kontingent-Bypass + Vereinspläne (Seed) | ✅ |
|
||||||
|
| Neue Pläne / Rollen anlegen (CRUD) | ❌ |
|
||||||
|
|
||||||
|
### Bewusst zurückgestellt
|
||||||
|
|
||||||
|
| ID | Inhalt |
|
||||||
|
|----|--------|
|
||||||
|
| M0 | CI-Isolation / Test-DB |
|
||||||
|
| M8 | Stripe |
|
||||||
|
| v2 | Trainer-Budgets, Custom Roles |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architektur-Zielbild (kompakt)
|
||||||
|
|
||||||
|
```
|
||||||
|
Request
|
||||||
|
→ require_auth
|
||||||
|
→ account_state (Gate)
|
||||||
|
→ TenantContext
|
||||||
|
→ assert_capability (Rolle / Funktion)
|
||||||
|
→ check_club_feature_access (Vereins-Kontingent)
|
||||||
|
→ [v2] member_feature_budget (Trainer-Budget)
|
||||||
|
→ Governance (Objekt)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Drei Achsen:** Account-Lifecycle · Capabilities · Features (Kontingente). Governance bleibt vierte Prüfung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Empfohlene Roadmap (nach Entscheidungen)
|
||||||
|
|
||||||
|
| Phase | Paket | Warum zuerst |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| **A** | **Onboarding-Gates vollständig** | ✅ umgesetzt (API + Frontend `/onboarding`) |
|
||||||
|
| **B** | **M7 Vereinsgründung beantragen** | **Als Nächstes** — zweiter Pfad für `verified_pending_club` |
|
||||||
|
| **C** | **M5 Hard-Block `ai_calls`** | Free-Plan `0` wird real; Badge (M4) liefert Erklärung |
|
||||||
|
| **D** | **M6 voll** | Pläne-CRUD, Rollen-CRUD | ⚠️ Matrix da |
|
||||||
|
| **E** | Entitlements im Frontend (`hasCapability`) | Entscheidung 1.2 risikoarm |
|
||||||
|
| **F** | **M9 Kontingent-Verteilung** — Vereinsadmin vergibt Sub-Budgets pro Person (`profile_id`); Prüfung + Consume personenbezogen; UI Vereinsorga | Entscheidung 1.4, Roadmap Phase 5b |
|
||||||
|
| **G** | `co_trainer` + Custom Roles (v2) | Entscheidung 1.2 |
|
||||||
|
|
||||||
|
M0 parallel, nicht blockierend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Offene Punkte (vor M6 / v2)
|
||||||
|
|
||||||
|
1. Fairness Modell A/B/C für Trainer-Budget (Tendenz: A).
|
||||||
|
2. Ob `admin` (Portal) langfristig neben `superadmin` bleibt.
|
||||||
|
3. Ob offizielle Inhalte für Bewerber **nie** lesbar bleiben (aktuell: ja).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Referenzen
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| `CAPABILITY_CATALOG.v1.md` | Capability-IDs, Account-States |
|
||||||
|
| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Feature-Registry, Kontingente |
|
||||||
|
| `backend/club_features.py` | Vereins-Features |
|
||||||
|
| `backend/capabilities.py` | Capability-Auflösung |
|
||||||
|
| `backend/account_lifecycle.py` | Account-Gates |
|
||||||
|
|
||||||
|
## 7. Superadmin im Verein (FAQ)
|
||||||
|
|
||||||
|
Siehe **`docs/working/RBAC_ENFORCEMENT_ROADMAP.md` §4**: Plattform-Admin (`admin`, `superadmin`) erhält **Capability-Bypass** für Vereins-Funktionen ohne `club_admin`-Mitgliedschaft. Mandant über aktiven Verein wählen; Kontingente via Bypass. Einzelne Legacy-Pfade (z. B. Löschen `visibility=club`) sind noch nicht vereinheitlicht — Ziel Phase 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Changelog**
|
||||||
|
|
||||||
|
- 2026-06-06: Initial — Entscheidungen Onboarding, Rollen-Risiko, Kontingente, Trainer-Budget v2; Ist-Stand M1–M3; Roadmap A–F.
|
||||||
|
- 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation.
|
||||||
|
- 2026-06-07: M4–M6 Ist-Stand, Roadmap-Verweis, Superadmin-FAQ; Admin-Matrix UX + Enforcement-Audit.
|
||||||
|
- 2026-06-08: Roadmap Phase 5b / M9 — Vereinsadmin-Kontingentverteilung pro Person; Enforce Dev verifiziert (0.8.202).
|
||||||
|
|
@ -227,7 +227,9 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie
|
||||||
## 8. Verwandtes Dokument
|
## 8. Verwandtes Dokument
|
||||||
|
|
||||||
- **`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`** – verbindliche Umsetzungsstufen A–F, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt.
|
- **`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`** – verbindliche Umsetzungsstufen A–F, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt.
|
||||||
|
- **`CAPABILITY_CATALOG.v1.md`** – Rollen, Capability-IDs, Account-Lifecycle, Endpoint-Mapping.
|
||||||
|
- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** – Vereinsabo, Feature-Registry (Mitai-v9c-Pattern), Kontingente.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2026-05-05
|
**Letzte Aktualisierung:** 2026-06-06
|
||||||
|
|
|
||||||
144
.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md
Normal file
144
.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
# Navigation — Return-Kontext (Rücksprung)
|
||||||
|
|
||||||
|
**Stand:** 2026-05-20
|
||||||
|
**Status:** Spezifikation + Phase 1–2 umgesetzt
|
||||||
|
**Ziel:** In der PWA (ohne Browser-Back) zuverlässig an den fachlichen Ausgangspunkt zurückkehren — inkl. sinnvollem Label und optional UI-State.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Viele Flows navigieren von Kontext A zu Editor/Detail B (z. B. Übungsliste → Modulbearbeitung). Die Zielseite kennt A nicht und bietet nur einen **fest verdrahteten** Zurück-Link (z. B. immer „Modul-Bibliothek“). In der installierten PWA fehlt zusätzlich die Browser-Chrome.
|
||||||
|
|
||||||
|
Betroffen u. a.:
|
||||||
|
|
||||||
|
- Übungsliste → Modul anlegen/bearbeiten
|
||||||
|
- Planung → Einheiten-Editor (teilweise gelöst via `planningReturn`)
|
||||||
|
- Modals mit Speichern + Redirect auf Vollseite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategie (Hybrid)
|
||||||
|
|
||||||
|
| Mechanismus | Wann |
|
||||||
|
|-------------|------|
|
||||||
|
| **Expliziter Return-Kontext** (`appReturn` in Router-State) | Seitenwechsel, bei denen das Ziel einen fachlichen Rücksprung anbieten soll |
|
||||||
|
| **History-Back** (`navigate(-1)`) | Fallback, wenn kein Kontext gesetzt ist und History-Eintrag existiert |
|
||||||
|
| **Default-Pfad** | Fallback der Zielseite (z. B. Modul-Bibliothek) |
|
||||||
|
| **Modal schließen** | Overlays/Peek — kein Routing-Return |
|
||||||
|
|
||||||
|
**Nicht** als alleinige Lösung: reines Browser-Back (History durch `replace`, Deep Links, Reload unzuverlässig).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
Router-State-Schlüssel: **`appReturn`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
v: 1, // Schema-Version
|
||||||
|
path: '/exercises', // Ziel-URL (inkl. Query, falls nötig)
|
||||||
|
label: 'Zurück zur Übungsliste', // Anzeige im UI (vollständiger Satz)
|
||||||
|
kind: 'exerciseList', // optional: Typ für erweiterte Wiederherstellung
|
||||||
|
payload: { ... } // optional: kind-spezifische Daten
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `kind`-Werte (erweiterbar)
|
||||||
|
|
||||||
|
| kind | payload | path-Ableitung |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| `exerciseList` | — | `/exercises` (Filter/Auswahl via sessionStorage) |
|
||||||
|
| `planningHub` | `buildPlanningHubReturnState(...)` | `planningHubPathFromReturnState(payload)` |
|
||||||
|
| `trainingModulesList` | — | `/planning/training-modules` |
|
||||||
|
| `planTemplatesList` | — | `/planning/plan-templates` |
|
||||||
|
| `frameworkProgramsList` | — | `/planning/framework-programs` |
|
||||||
|
| `settings` | — | `/settings` |
|
||||||
|
| `dashboard` | — | `/` |
|
||||||
|
| `mediaLibrary` | — | `/media` |
|
||||||
|
| `trainingRun` | `{ unitId }` | `/planning/run/:unitId` |
|
||||||
|
| `currentLocation` | — | aktuelle Route (z. B. Einheiten-Editor) |
|
||||||
|
| (frei) | — | `path` direkt gesetzt |
|
||||||
|
|
||||||
|
### Legacy-Kompatibilität
|
||||||
|
|
||||||
|
Bestehendes Feld **`planningReturn`** (Planung ↔ Einheiten-Editor) wird beim Lesen in `appReturn` **bridged** — keine Big-Bang-Migration nötig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API (Frontend)
|
||||||
|
|
||||||
|
Zentrale Datei: `frontend/src/utils/navReturnContext.js`
|
||||||
|
|
||||||
|
| Funktion | Zweck |
|
||||||
|
|----------|--------|
|
||||||
|
| `buildNavReturnContext({ path, label, kind?, payload? })` | Kontext-Objekt erzeugen |
|
||||||
|
| `buildExercisesListReturnContext()` | Standard-Rückkehr Übungsliste |
|
||||||
|
| `buildPlanningHubReturnContext(hubState)` | Planungs-Hub inkl. Filter-Query |
|
||||||
|
| `buildTrainingModulesListReturnContext()` | Modul-Bibliothek |
|
||||||
|
| `readNavReturnFromLocation(location)` | Kontext aus `location.state` (+ Legacy) |
|
||||||
|
| `resolveNavReturnTarget(location, fallback)` | `{ path, label }` für UI |
|
||||||
|
| `goNavReturn(navigate, location, fallback?)` | Programmatischer Rücksprung (priorisiert: Kontext → History → Fallback) |
|
||||||
|
| `navigateWithAppReturn(navigate, to, returnContext, options?)` | Navigation mit gesetztem `appReturn` |
|
||||||
|
| `preserveAppReturnOnNavigate(navigate, location, to, options?)` | Weiterleiten, bestehenden Kontext behalten (z. B. nach `replace`) |
|
||||||
|
|
||||||
|
UI-Komponente: **`PageReturnButton`** — app-typischer Zurück-Schalter (Button mit Pfeil, kein Router-Link).
|
||||||
|
Links **zum** Ziel: **`NavStateLink`** mit `returnContext` der Quellseite.
|
||||||
|
|
||||||
|
### Editor-Aktionen
|
||||||
|
|
||||||
|
Auf Vollseiten-Editoren mit **`FormActionBar`** (`placement="bottom"`) oder **`PageFormEditorChrome`**:
|
||||||
|
|
||||||
|
- **Kein** separater Zurück-Link/Button oben (wirkt in der App redundant)
|
||||||
|
- **Abbrechen** → `goBack()` / `goNavReturn(...)` (Einsprungspunkt)
|
||||||
|
- **Speichern & Schließen** → nach erfolgreichem Save ebenfalls `goBack()`
|
||||||
|
- Sticky Action Bar unten nutzen
|
||||||
|
|
||||||
|
**PageReturnButton** nur auf **Leseseiten** ohne Editor-Leiste (z. B. Übungsdetail, Einstellungen-Unterseiten, Trainingsablauf).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regeln für Entwickler
|
||||||
|
|
||||||
|
1. **Jede Navigation** von Kontext A zu Editor B, wo der Nutzer „weitermachen“ soll, setzt `appReturn` (oder nutzt `navigateWithAppReturn`).
|
||||||
|
2. **Zielseite** zeigt `PageReturnButton` mit sinnvollem **Default-Fallback** (Bibliothek/Hub).
|
||||||
|
3. **Nach Create + `replace: true`:** Return-Kontext mit `preserveAppReturnOnNavigate` erhalten.
|
||||||
|
4. **Modals:** Schließen reicht; Redirect nach Speichern = Seiten-Navigation → Return setzen.
|
||||||
|
5. **Kein Return-Kontext** in `location.state` für interne Bibliothek → Detail → Bearbeiten, wenn Herkunft = offensichtliche Elternliste (Default-Fallback genügt).
|
||||||
|
6. **UI-State** (Filter, Auswahl): weiter über bestehende Session-Mechanismen (z. B. `exerciseListSessionState`), nicht im Return-Payload duplizieren, außer kind erfordert Query-Reconstruction (Planung).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umsetzungsstand
|
||||||
|
|
||||||
|
### Phase 1 (Pilot)
|
||||||
|
|
||||||
|
- [x] Spec + Utility + Tests
|
||||||
|
- [x] `PageReturnButton` (ersetzt Link-Variante)
|
||||||
|
- [x] Übungsliste → Modul speichern → Modul-Editor
|
||||||
|
- [x] Planung: `SaveExercisesAsModuleModal` leitet Return-Kontext weiter
|
||||||
|
- [x] `TrainingUnitEditPage`: `goBack` über `goNavReturn` (Legacy-bridge)
|
||||||
|
|
||||||
|
### Phase 2 (Flows verbinden)
|
||||||
|
|
||||||
|
- [x] Listen → Editoren: Übungen, Module, Vorlagen, Rahmenprogramme
|
||||||
|
- [x] Dashboard → Übung bearbeiten / Trainingsablauf / Einheit bearbeiten
|
||||||
|
- [x] Einstellungen-Unterseiten (Rechtliches, Systeminfo)
|
||||||
|
- [x] Trainingsablauf + Coach-Modus (`trainingRun`, Planungs-Fallback)
|
||||||
|
- [x] Medienbibliothek → verknüpfte Übungen/Einheiten
|
||||||
|
- [x] `ExercisePeekModal` → Vollseite mit Return
|
||||||
|
- [x] Editoren: Abbrechen + Speichern & Schließen → Einsprungspunkt
|
||||||
|
|
||||||
|
### Optional (später)
|
||||||
|
|
||||||
|
- Globaler Zurück-Button in App-Chrome (Mobile)
|
||||||
|
- Nach Speichern: explizite Aktion „Zurück zum Ausgang“ im Toast
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenzen
|
||||||
|
|
||||||
|
- Bestehend: `frontend/src/utils/planningUnitRoutes.js` (`planningReturn`)
|
||||||
|
- Session Übungsliste: `frontend/src/utils/exerciseListSessionState.js`
|
||||||
|
- PWA-Kontext: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`, App-Shell in `App.jsx`
|
||||||
136
.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md
Normal file
136
.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
# Parallele Trainingsstreams — Technische Spezifikation (Umsetzung)
|
||||||
|
|
||||||
|
**Status:** Umsetzung **Phase 1 (teils)** · **Stand:** 2026-05-14
|
||||||
|
**Fachgrundlage:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt die **Umsetzung** auf Basis der **aktuellen Codebasis** (Stand 2026-05-14): **`training_unit_phases` / `training_unit_parallel_streams`** (Migration **063**) und **`training_unit_sections`** mit Phasen-/Stream-Bezug; **`training_unit_section_items`** (Übung/Notiz, optional `planning_method_profile` für Kombinationsübungen, Migration **057**); Rahmen-**Blueprint**-Einheiten mit `framework_slot_id` (**037**); Leitung **`lead_trainer_profile_id`** (**038**); Co-Trainer **`assistant_trainer_profile_ids`** JSONB (**042**); Durchführung und Coaching über **`TrainingUnitRunPage`**, **`TrainingCoachPage`** und **`trainingPlanUtils.js`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ist-Stand (Code, 2026-05-14)
|
||||||
|
|
||||||
|
| Bereich | Aktuell |
|
||||||
|
|---------|---------|
|
||||||
|
| **Schema** | Migration **063:** `training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` **oder** `parallel_stream_id`. |
|
||||||
|
| **API** | `GET /api/training-units/:id` — **`phases`** (verschachtelt) + flache **`sections`**. `PUT/POST` mit **`phases`** für Breakout-Einheiten (**0.8.138**); höchstens eines von `phases`, `sections`, `exercises` pro Request (Planning-Router). Legacy-PUT mit nur `sections` erzeugt/ergänzt Ganzgruppen-Phase. |
|
||||||
|
| **Planung (UI)** | Breakout-Panel: Ganzgruppen-/parallele Phasen, Streams; Speichern phasenbasiert (`trainingUnitSectionsForm.js`, `TrainingPlanningPage`). |
|
||||||
|
| **Durchführung** | `TrainingUnitRunPage.jsx` + `trainingPlanUtils.js` (`sectionsWithPlanLocForDisplay`, `buildPlanRunViewModelFromSections`) — Phasenfolge in „Plan & Ablauf“. |
|
||||||
|
| **Coaching** | `TrainingCoachPage.jsx` + `flattenPlanTimeline`, Stream-Picks, Rejoin vor Ganzgruppe/nächstem Split (`coachShouldPromptSplitRejoinTransition`), Nachbereitung mit `buildCoachSavePlanPayload`, danach Navigation zu `/planning/run/:id`. |
|
||||||
|
| **Kombinationsübung** | Unverändert je Item; `planning_method_profile`, Coach-Kombi-Stufe A. |
|
||||||
|
| **Trainer-Zuweisung** | `lead_trainer_profile_id`, `assistant_trainer_profile_ids` am Einheitskopf; **Stream-**`assigned_trainer_profile_ids` im Schema — UI/Policy noch nicht vollständig (siehe **§8 offen**). |
|
||||||
|
| **Rahmenprogramm** | Blueprint-`training_units` können dieselbe Phasenstruktur tragen; Kopie aus Slot (`from-framework-slot`, **0.8.138**). |
|
||||||
|
|
||||||
|
**Hinweis:** Die frühere Planungsvariante „nur lineare `training_unit_sections` ohne Phasen“ gilt weiter für Alt-Daten; Migration **063** ordnet Bestand einer Default-Ganzgruppenphase zu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Zielarchitektur (logisch)
|
||||||
|
|
||||||
|
```
|
||||||
|
training_unit (Kalender-Einheit)
|
||||||
|
├── phase (order, kind: whole_group | parallel, optional Metadaten)
|
||||||
|
│ ├── [whole_group] → sections[] → items[] (wie heute)
|
||||||
|
│ └── [parallel] → stream (order, label, optional trainer_ids[])
|
||||||
|
│ └── sections[] → items[]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Abwärtskompatibilität:** Einheiten **ohne** explizite Phasen/Streams verhalten sich wie heute: **implizit** eine einzige „Gemeinschaftsphase“ mit den vorhandenen Sektionen (Migration: alle bestehenden Sektionen an diese Default-Hülle hängen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Datenmodell — Optionen
|
||||||
|
|
||||||
|
**Ist (063):** Die unten skizzierte **empfohlene** Normalform ist unter den genannten Tabellennamen produktiv; die Abschnitte 3.1/3.2 bleiben zur Einordnung erhalten.
|
||||||
|
|
||||||
|
### 3.1 Empfohlen: explizite Phasen + Streams (normalisiert)
|
||||||
|
|
||||||
|
Die Tabellen sind **umgesetzt** (Namen final):
|
||||||
|
|
||||||
|
| Tabelle | Zweck |
|
||||||
|
|---------|--------|
|
||||||
|
| `training_unit_phases` | `training_unit_id`, `order_index`, `phase_kind` (`whole_group` \| `parallel`), optional `title`, `guidance_notes`, optional `planned_duration_min` |
|
||||||
|
| `training_unit_parallel_streams` | `phase_id` (FK, nur wenn parent parallel), `order_index`, `title`/`label`, optional `notes`, optional `assigned_trainer_profile_ids` JSONB (oder 1:n-Hilfstabelle) |
|
||||||
|
|
||||||
|
**Anpassung `training_unit_sections`:** Zusätzliche FK-Spalte(n), z. B.:
|
||||||
|
|
||||||
|
- `phase_id` **NULL** und `parallel_stream_id` **NULL** → **Legacy / Default-Einheitsphase** (Migration setzt Default-Phase); oder
|
||||||
|
- genau einer von `phase_id` (whole group) oder `parallel_stream_id` gesetzt.
|
||||||
|
|
||||||
|
**Constraints:** CHECK: nicht beide gesetzt; bei `phase_kind = parallel` Sektionen nur unter `parallel_stream_id`; bei `whole_group` nur unter `phase_id`.
|
||||||
|
|
||||||
|
**Vorteil:** Klare Semantik, Reporting, API-Shape konsistent.
|
||||||
|
|
||||||
|
### 3.2 Minimalvariante (nicht ideal fachlich)
|
||||||
|
|
||||||
|
Nur **`training_unit_parallel_streams`** + `parallel_stream_id` auf Sektionen; Phasen implizit durch „Marker“-Sektionen oder Konvention. **Nicht empfohlen**, erschwert UI und Erklärbarkeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API
|
||||||
|
|
||||||
|
- **`GET /api/training-units/:id`** (und Listen-Payloads wo vollständiger Plan nötig): verschachtelte Struktur **Phasen → Streams → sections → items** oder flache `sections` mit ausgefüllten `phase_id` / `parallel_stream_id` (Frontend kann normalisieren).
|
||||||
|
- **`PUT/PATCH`:** Atomares Ersetzen der Phasen/Streams/Sektionen analog zu bestehendem `_replace_unit_sections`-Muster; **Validierung** der CHECK-Regeln serverseitig.
|
||||||
|
- **Blueprint / Rahmen:** Blueprint-`training_units` dürfen dieselbe Struktur tragen; `GET` Kalenderliste blendet Blueprints weiter aus (`framework_slot_id IS NOT NULL`).
|
||||||
|
|
||||||
|
**Governance / Mandant:** Unverändert über Einheit → `group_id`; keine neuen Mandanten-Entitäten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Frontend
|
||||||
|
|
||||||
|
### 5.1 Planung (`TrainingPlanningPage`)
|
||||||
|
|
||||||
|
- Darstellung als **vertikale Phasen**: Gemeinschaftsblöcke + Parallelphase mit **N Spalten** (Streams).
|
||||||
|
- **Wiederverwendung:** `TrainingUnitSectionsEditor` **pro Stream** und pro Gemeinschaftsphase — analog zur Wiederverwendung **pro Rahmen-Slot** in `TrainingFrameworkProgramEditPage`.
|
||||||
|
- **Co-Trainer:** UI pro Stream (`assigned_trainer_profile_ids`); Regel zur **Kopfliste** `assistant_trainer_profile_ids` festlegen (z. B. Union aller Stream-Zuweisungen für „Wer ist heute dabei“ + Rückwärtskompatibilität wenn Stream-Felder leer).
|
||||||
|
|
||||||
|
### 5.2 Durchführung (`TrainingUnitRunPage`)
|
||||||
|
|
||||||
|
- Gemeinschaftsphasen: heutiges **lineares** Verhalten.
|
||||||
|
- Parallelphase: **Tabs, Akkordeon oder Swipe** zwischen Streams; Fortschritt **pro Stream** (Storage-Key z. B. `${unitId}:${streamId}`).
|
||||||
|
- Kombi-Items: unverändert `CombinationPlanBracket` / `effectiveComboMethodProfile`.
|
||||||
|
- Optional später: Filter „nur meine Spur“ anhand Session-Profil vs. Stream-Zuweisung.
|
||||||
|
|
||||||
|
### 5.3 Vorlagen (`training_plan_templates`)
|
||||||
|
|
||||||
|
- Erweiterung um **dieselbe** Phasen/Streams-Semantik (Kindtabellen oder serialisiertes JSON — Abgleich mit Kopierlogik aus Vorlage in Einheit).
|
||||||
|
- **Kein** Live-Spiegel: weiterhin Materialisierung beim Anwenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Bezug Kombinationsübungen
|
||||||
|
|
||||||
|
- **Variante A** (Rotation innerhalb einer Teilstrecke): ein oder mehrere **Items** vom Typ Kombi im jeweiligen Stream; Archetyp und Parameter wie in `TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`.
|
||||||
|
- **Variante B** (synchron Hallenweit): erweiterte **Phasen-** oder **Stream-übergreifende** Metadaten — **nicht** in MVP-Zwang; eigenes Teilpaket nach fachlicher Freigabe (`PARALLEL_TRAINING_STREAMS_CONCEPT.md` §5.2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Migration und Risiken
|
||||||
|
|
||||||
|
1. **Datenmigration:** Alle existierenden `training_unit_sections` einer Einheit einer **Default-Phase** `whole_group` zuordnen.
|
||||||
|
2. **API-Versionierung:** Clients, die nur flache `sections` erwarten, müssen angepasst werden (oder Server liefert **beides** kurzzeitig — nur wenn nötig).
|
||||||
|
3. **Performance:** Tiefe Kopien (Rahmen-Slot, Duplikat Einheit) müssen rekursiv Phasen/Streams mitsamt Sektionen/Items kopieren.
|
||||||
|
4. **Tests:** pytest für PUT/GET mit gemischten Phasen; ggf. Playwright-Smoke für Planung/Run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementierungsphasen (Abgleich)
|
||||||
|
|
||||||
|
| Phase | Inhalt | Stand 2026-05-14 |
|
||||||
|
|-------|--------|------------------|
|
||||||
|
| **P1** | Schema Phasen + Streams; Migration **063**; GET/PUT verschachtelt; Planungs-UI; Run + Coach phasenbasiert | **Teilweise erledigt** — Run-UI nutzt Phasen-Timeline in der Anzeige; **Stream-Tabs** optional noch zu vereinheitlichen (§5.2) |
|
||||||
|
| **P2** | Trainer-Zuordnung pro Stream + effektive Anzeige; Vorlagen erweitert | **Offen** |
|
||||||
|
| **P3** | Synchroner Hallen-Takt / Rotationsmatrix (falls fachlich freigegeben) | **Offen** |
|
||||||
|
|
||||||
|
**Offene Punkte (kurz):** siehe **`docs/HANDOVER.md`** Tabelle „Coaching & Breakout“.
|
||||||
|
|
||||||
|
## 9. Verwandte Dokumente
|
||||||
|
|
||||||
|
| Dokument | Bezug |
|
||||||
|
|----------|--------|
|
||||||
|
| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md` | Fachziele, Begriffe, Entscheidungsfragen |
|
||||||
|
| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slot vs. Parallelität |
|
||||||
|
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombi, `planning_method_profile` |
|
||||||
|
| `technical/DATABASE_SCHEMA.md`, `backend/migrations/` | DDL-Historie |
|
||||||
|
| `TrainingPlanningPage.jsx`, `TrainingUnitRunPage.jsx`, `TrainingFrameworkProgramEditPage.jsx` | Planung, Durchführung, Rahmen |
|
||||||
|
| `frontend/src/utils/trainingPlanUtils.js`, `TrainingCoachPage.jsx` | Phasen-Timeline, Rejoin, Coach-Speichern |
|
||||||
184
.claude/docs/technical/SKILL_SCORING_SPEC.md
Normal file
184
.claude/docs/technical/SKILL_SCORING_SPEC.md
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
|
||||||
|
|
||||||
|
**Stand:** 2026-05-20
|
||||||
|
**Status:** Variante A (regelbasiert) umgesetzt — **v1.3** (Peer-Kontext getrennt + Listen-Filter)
|
||||||
|
**Modul:** `backend/skill_scoring.py`, Router `backend/routers/skill_profiles.py`
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Trainer wählen **Schwerpunkt-Fähigkeiten** und finden passende **Bausteine** für die Trainingsplanung:
|
||||||
|
|
||||||
|
- **Trainingsmodule** — wiederverwendbare Übungsfolgen
|
||||||
|
- **Rahmenprogramme** — Programme mit Zielen und Session-Slots
|
||||||
|
- **Regressionspfade** (Progressionsgraphen) — Übungsketten
|
||||||
|
|
||||||
|
Das Scoring beantwortet: *Wie stark trainiert dieser Baustein eine Fähigkeit?* und *Wie stark ist er im Vergleich zu anderen **sichtbaren** Bausteinen **desselben Typs**?*
|
||||||
|
|
||||||
|
## Fachliche Kernregel: Peer-Kontext (nicht vermischen)
|
||||||
|
|
||||||
|
| Planungs-Artefakt | Vergleichsgruppe (`universal_percent`) |
|
||||||
|
|-------------------|----------------------------------------|
|
||||||
|
| Trainingsmodul | nur andere **sichtbare Module** |
|
||||||
|
| Rahmenprogramm | nur andere **sichtbare Rahmenprogramme** |
|
||||||
|
| Regressionspfad | nur andere **sichtbare Pfade** |
|
||||||
|
|
||||||
|
**Nicht** verglichen werden:
|
||||||
|
|
||||||
|
- Module vs. Rahmenprogramme vs. Pfade (kein Mix)
|
||||||
|
- Artefakte anderer Vereine, auf die der Nutzer keinen Planungszugriff hat
|
||||||
|
|
||||||
|
**Sichtbarkeit:** `library_content_visibility_sql` — private, vereinsinterne und offizielle Inhalte gemäß Mandant/Rolle, analog zu anderen Bibliothekslisten.
|
||||||
|
|
||||||
|
## Datenquellen
|
||||||
|
|
||||||
|
| Artefakt | Übungen aus |
|
||||||
|
|----------|-------------|
|
||||||
|
| Rahmenprogramm (gesamt) | Alle Blueprint-`training_units` der Slots → `training_unit_section_items` |
|
||||||
|
| Rahmenprogramm (pro Slot) | Blueprint einer Session |
|
||||||
|
| Trainingsmodul | `training_module_items` (nur `item_type = exercise`) |
|
||||||
|
| Progressionsgraph | `from_exercise_id` + `to_exercise_id` je Kante (Vorkommen zählt) |
|
||||||
|
|
||||||
|
Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`).
|
||||||
|
|
||||||
|
## Gewichtungsformel (v1.1 / v1.2)
|
||||||
|
|
||||||
|
Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt):
|
||||||
|
|
||||||
|
1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min).
|
||||||
|
2. Pro verknüpfte Fähigkeit der Übung:
|
||||||
|
- `Beitrag = Basis-Minuten × Anzahl Vorkommen × Link-Faktor`
|
||||||
|
- **Link-Faktor** = Intensität × Stufen-Faktor
|
||||||
|
|
||||||
|
### Intensität (Nutzeneinschätzung, UI-Feld)
|
||||||
|
|
||||||
|
| Wert | Faktor |
|
||||||
|
|------|--------|
|
||||||
|
| niedrig | 0,85 |
|
||||||
|
| mittel / leer | 1,0 |
|
||||||
|
| hoch | 1,2 |
|
||||||
|
|
||||||
|
### Stufen-Spanne (`required_level` → `target_level`, UI „von/bis“)
|
||||||
|
|
||||||
|
Kanonische Slugs: basis … optimierung (1–5). Fehlen beide: Faktor 1,0.
|
||||||
|
|
||||||
|
- **Spanne** = Anzahl Stufen von „von“ bis „bis“ (1–5)
|
||||||
|
- **Mittelpunkt** = durchschnittliche Stufe
|
||||||
|
- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,96–1,20
|
||||||
|
|
||||||
|
### Bewusst nicht im Scoring
|
||||||
|
|
||||||
|
| Feld | Grund |
|
||||||
|
|------|--------|
|
||||||
|
| `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein |
|
||||||
|
| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt |
|
||||||
|
|
||||||
|
## Aggregierte Metriken
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `weight` / `score` | Absolutes **Trainingsgewicht** (gewichtete Minuten) — über alle Fähigkeiten eines Artefakts vergleichbar |
|
||||||
|
| `share_percent` | Anteil am `total_weight` **innerhalb dieses Artefakts** (summiert 100 %) — sekundär |
|
||||||
|
| `by_main_category[]` | Je Unterkategorie `top_skill` (stärkste Fähigkeit nach Gewicht) |
|
||||||
|
| `universal_percent` | Anteil am **Maximum derselben Fähigkeit im Peer-Kontext** (max. 100 %) |
|
||||||
|
| `is_club_best_for_skill` | Stärkster sichtbarer Peer für diese Fähigkeit (★ in UI) |
|
||||||
|
| `club_best` | Referenz-Peer (Titel, Typ, Gewicht) — **Legacy-Name**, fachlich Peer-Best |
|
||||||
|
|
||||||
|
### Berechnung `universal_percent`
|
||||||
|
|
||||||
|
```
|
||||||
|
effective_ref = max(max_weight_in_peer_corpus(skill_id), eigenes_gewicht)
|
||||||
|
universal_percent = min(100, weight / effective_ref × 100)
|
||||||
|
```
|
||||||
|
|
||||||
|
Corpus je Typ: `compute_planning_corpus_by_type()` scannt sichtbare Artefakte getrennt nach `framework_program`, `training_module`, `progression_graph`.
|
||||||
|
|
||||||
|
Discovery-Sortierung nutzt **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil. Discovery verwendet typ-getrennte Referenz (`fw_ref`, `mod_ref`, `graph_ref`).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile`; `reference_scale` (Peer-Kontext Rahmenprogramme) |
|
||||||
|
| GET | `/api/training-modules/{id}/skill-profile` | `overall`; `reference_scale` (Peer-Kontext Module) |
|
||||||
|
| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall`; `reference_scale` (Peer-Kontext Pfade) |
|
||||||
|
| POST | `/api/skill-profiles/batch-summaries` | Kompakte Profile für Listen; Body: `frameworkProgramIds`, `trainingModuleIds`, …; Response: `summaries`, `reference_scale_by_type`, `club_best_by_skill` |
|
||||||
|
| GET | `/api/skill-discovery/suggestions?skill_ids=1,2,3` | Ranking sichtbarer Artefakte; Query `types`, `limit` |
|
||||||
|
|
||||||
|
Zugriff: `get_tenant_context` + `library_content_visibility_sql` wie Parent-Artefakt.
|
||||||
|
|
||||||
|
### `reference_scale` / `reference_scale_by_type`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scope": "planning_peer",
|
||||||
|
"artifact_type": "training_module",
|
||||||
|
"artifacts_scanned": 12,
|
||||||
|
"skills_in_corpus": 34,
|
||||||
|
"description": "Prozent = Anteil am stärksten sichtbaren Eintrag unter Trainingsmodulen …"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### Bearbeitung (Vollprofil)
|
||||||
|
|
||||||
|
| Ort | Panel | `artifactType` |
|
||||||
|
|-----|-------|----------------|
|
||||||
|
| Rahmenprogramm bearbeiten | Fähigkeiten-Schwerpunkte (+ Sessions) | `framework_program` |
|
||||||
|
| Trainingsmodul bearbeiten | Fähigkeiten im Modul | `training_module` |
|
||||||
|
| Progressionsgraph | Fähigkeiten entlang des Pfads | `progression_graph` |
|
||||||
|
|
||||||
|
Anzeige: Top je Kategorie (Editor) oder alle Fähigkeiten (Modal). Hinweise nennen Peer-Kontext explizit (z. B. „72 % Rahmenpr.“).
|
||||||
|
|
||||||
|
### Listen & Filter (UX wie Übungsliste)
|
||||||
|
|
||||||
|
| Liste | Filter |
|
||||||
|
|-------|--------|
|
||||||
|
| Rahmenprogramme (`/planning/framework-programs`) | Suche, Katalog (Fokus/Trainingsart/Zielgruppe), Session-Dauer, **Fähigkeiten** (`SkillTreeMultiSelect`), Mindest-% im Peer-Kontext, Sortierung nach Stärke |
|
||||||
|
| Trainingsmodule (`/planning/training-modules`) | Suche, **Fähigkeiten** (+ Min-%, Sortierung) |
|
||||||
|
|
||||||
|
- **Filter-Button** mit Badge, entfernbare **Chips**, Einstellungen im **Modal** (`PlanningArtifactFilterModal`)
|
||||||
|
- KPI-Kacheln: Top-Fähigkeit **je Unterkategorie** mit Score + Peer-%
|
||||||
|
- Vollprofil-Modal: `SkillProfileFullModal` mit `displayMode=full`
|
||||||
|
|
||||||
|
Profil wird nach Speichern neu geladen (`skillProfileTick` in Editoren).
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
**Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + API `/api/skill-discovery/suggestions` (optional Filter `types`).
|
||||||
|
|
||||||
|
## Frontend-Module (Auswahl)
|
||||||
|
|
||||||
|
| Pfad | Rolle |
|
||||||
|
|------|--------|
|
||||||
|
| `frontend/src/components/planning/PlanningArtifactFilterModal.jsx` | Filter-Modal |
|
||||||
|
| `frontend/src/components/planning/PlanningSkillFilterSection.jsx` | Fähigkeiten-Block im Modal |
|
||||||
|
| `frontend/src/utils/planningArtifactFilterChips.js` | Chip-Labels + Entfernen |
|
||||||
|
| `frontend/src/utils/frameworkProgramListHelpers.js` | Client-Filter Rahmenprogramme |
|
||||||
|
| `frontend/src/utils/trainingModuleListHelpers.js` | Client-Filter Module |
|
||||||
|
| `frontend/src/components/skills/SkillProfileCompact.jsx` | KPI-Kacheln in Listen |
|
||||||
|
| `frontend/src/components/SkillTreeMultiSelect.jsx` | Baumauswahl (Portal-Dropdown in Modals) |
|
||||||
|
|
||||||
|
## Grenzen / später
|
||||||
|
|
||||||
|
- Kein DB-Cache (`skill_profile_json`) — on-the-fly; bei >50 Artefakten pro Typ serverseitiger Index/Caching
|
||||||
|
- Entwicklungsziele am Rahmenkopf bleiben Freitext (kein Scoring)
|
||||||
|
- KI-Zusammenfassung (Variante B) nicht Teil von v1.0
|
||||||
|
- Trainings**einheiten** (Kalender) optional als nächste Erweiterung
|
||||||
|
- Filter-Persistenz („Als Standard speichern“) wie bei Übungen — noch nicht für Planungslisten
|
||||||
|
- Fähigkeiten-Filter im Dialog **Planung → Rahmen übernehmen** — Katalog ja, Skill-Filter optional nachziehen
|
||||||
|
- API-Feldnamen `club_*` / `skillMinClubPercent` — technische Altlast, semantisch Peer-Kontext
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score, Cap 100 %
|
||||||
|
- **Offen:** dedizierte Tests für `compute_planning_corpus_by_type` (Typ-Trennung)
|
||||||
|
|
||||||
|
## Verweise
|
||||||
|
|
||||||
|
| Dokument | Inhalt |
|
||||||
|
|----------|--------|
|
||||||
|
| `functional/DOMAIN_MODEL.md` | Domänenabschnitt Planungs-Fähigkeiten-Profil |
|
||||||
|
| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick Listen/Filter |
|
||||||
|
| `docs/HANDOVER.md` | Handover-Abschnitt Phase 3 |
|
||||||
|
| `technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Sichtbarkeit / Mandant |
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §2–§3** + SQL unter `backend/migrations/`. |
|
| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §2–§3** + SQL unter `backend/migrations/`. |
|
||||||
| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
|
| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
|
||||||
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURR‑Tabelle). |
|
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURR‑Tabelle). |
|
||||||
|
| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | **Parallele Streams / Breakout innerhalb einer Einheit** — orthogonale Domäne zu **Rahmen‑Slots** (Serien‑Sessions). |
|
||||||
|
| `technical/SKILL_SCORING_SPEC.md` | **Fähigkeiten-Profil** der Rahmen‑Slots / Module / Pfade; Listen-Filter und Peer‑Vergleich (nur gleicher Artefakttyp). |
|
||||||
|
|
||||||
**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 1–2. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „Alternative‑Pakete“ in der UI).
|
**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 1–2. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „Alternative‑Pakete“ in der UI).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
| Dokument | Bezug |
|
| Dokument | Bezug |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Bibliothek, Slot-Blueprint, Kopiersemantik (`from-framework-slot`) |
|
| `TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Bibliothek, Slot-Blueprint, Kopiersemantik (`from-framework-slot`) |
|
||||||
|
| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | Parallele Teilstrecken **innerhalb einer Einheit**; Kombi-Übungen weiter nutzbar **pro Stream** für Stationsrotation |
|
||||||
| `DATABASE_SCHEMA.md` | Aktueller Stand `training_units`, Sektionen, Items |
|
| `DATABASE_SCHEMA.md` | Aktueller Stand `training_units`, Sektionen, Items |
|
||||||
| `functional/DOMAIN_MODEL.md` | Domänenbegriffe (bei Bedarf zu erweitern) |
|
| `functional/DOMAIN_MODEL.md` | Domänenbegriffe (bei Bedarf zu erweitern) |
|
||||||
| `EXERCISES_*` (Katalog) | Einzelübungen, Varianten |
|
| `EXERCISES_*` (Katalog) | Einzelübungen, Varianten |
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
||||||
| exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
|
| exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
|
||||||
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
|
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
|
||||||
|
| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; 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 |
|
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
|
||||||
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
|
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
|
||||||
|
| dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) |
|
||||||
| training_modules | `/api/training-modules*` | ja | `get_tenant_context` | ja | Bibliotheks-Module wie Vorlagen/Rahmen; POST Default `club_id` bei `visibility=club` |
|
| training_modules | `/api/training-modules*` | ja | `get_tenant_context` | ja | Bibliotheks-Module wie Vorlagen/Rahmen; POST Default `club_id` bei `visibility=club` |
|
||||||
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
|
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
|
||||||
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
|
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
|
||||||
|
|
@ -31,19 +33,29 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
|
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
|
||||||
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
|
| 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 |
|
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
|
||||||
|
| matrix_editor | `/api/admin/matrix-editor/*` (Export/Import Editor-Bundle) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale Fähigkeitsmatrix ohne Mandantenkontext |
|
||||||
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | 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 |
|
||||||
|
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
|
||||||
|
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
|
||||||
|
| admin_user_content | `/api/admin/user-content/*` (Meta, Nutzer-Summary, Items, PATCH, DELETE) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; Moderation nutzerangelegter Inhalte inkl. privat; kein TenantContext |
|
||||||
|
|
||||||
**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.
|
**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.
|
**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-12 — Trainingsmodule (`/api/training-modules*`); Governance wie Planungsbibliothek.
|
Letzte Änderung: 2026-06-06 — Superadmin `/api/admin/user-content/*` (Nutzer-Inhalte Moderation).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Changelog (Fortführung)
|
### Changelog (Fortführung)
|
||||||
|
|
||||||
- **2026-05-12:** `training_modules` Router dokumentiert.
|
- **2026-05-23:** Superadmin-API `exercise_enrichment_admin` (Batch-Übungs-Anreicherung KI) dokumentiert.
|
||||||
|
- **2026-05-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert.
|
||||||
|
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
||||||
|
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||||
|
|
||||||
|
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
|
||||||
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||||
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||||
|
|
||||||
|
|
|
||||||
67
.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
Normal file
67
.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Umsetzungsplan – KI bei Übungen (stufenweise, Driftschutz)
|
||||||
|
|
||||||
|
**Version:** 0.2
|
||||||
|
**Datum:** 2026-05-29
|
||||||
|
**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · **`working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`** · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Drift vermeiden – verbindliche Regeln
|
||||||
|
|
||||||
|
1. **Spec vor Code:** Request/Response-Felder und Statuscodes an `KI_FEATURES_SPEC.md` ausrichten; Abweichungen zuerst Spec oder dieses Dokument anpassen.
|
||||||
|
2. **Prompts in der DB:** Keine produktionskritischen Prompt-Langtexte nur im Code; Defaults per **Migration** in `ai_prompts`, Anpassung durch Admins über vorgesehene Oberfläche (später) oder SQL.
|
||||||
|
3. **Skill-Retrieval-Profile:** Gewichte/Quotes in **`ai_skill_retrieval_profiles.config`** — Spezifikation `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; kein zweites gleichzeitiges Truth-Repo im Sourcecode außer defensiver Fallback `_FALLBACK_RETRIEVAL_CONFIG` in `exercise_ai.py`.
|
||||||
|
4. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen.
|
||||||
|
5. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec).
|
||||||
|
6. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`.
|
||||||
|
7. **Schema:** Neue DB-Objekte nur nummerierte Migration **`backend/migrations/`** (aktuell bis **068**) und `DB_SCHEMA_VERSION` anheben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stufen (Releases)
|
||||||
|
|
||||||
|
| Stufe | Inhalt | Exit-Kriterium |
|
||||||
|
|-------|--------|------------------|
|
||||||
|
| **S0** | Dieses Dokument + Verweise konsistent | Review abgehakt |
|
||||||
|
| **S1** | Migration `ai_prompts` + Defaults `exercise_summary`, `exercise_skill_suggestions`; `exercises.summary_ai_generated` | Migrierte DB, App startet |
|
||||||
|
| **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key |
|
||||||
|
| **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key |
|
||||||
|
| **S4** | Frontend: KI-Vorschlag, **Änderungsdialog** (Vorschau, Kurzfassung wählbar, Fähigkeiten pro Zeile an-/abwählbar), dann Übernahme ins Formular | Manuelle UX-Prüfung |
|
||||||
|
| **S4b** | **Skill-Retrieval:** Migration **`ai_skill_retrieval_profiles`**, `focus_areas_context` am **`POST …/ai/suggest`**, `exercise_ai` kontextbezogener Katalog (Gewichte, Caps, Keyword-Patches) | Migration 068 angelegt; Smoke mit Gewaltschutz / ohne Fokus |
|
||||||
|
| **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config |
|
||||||
|
| **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics |
|
||||||
|
|
||||||
|
**Aktueller Implementierungsstand:** **S4 + S4b** im Code (`exercise_ai` + Formular übermittelt `focus_areas_context`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementierungs-Checkliste (Technik)
|
||||||
|
|
||||||
|
- [ ] `OPENROUTER_API_KEY` / `OPENROUTER_MODEL` in `.env.example` dokumentiert (bereits teils vorhanden – prüfen).
|
||||||
|
- [ ] Fehlerbilder: `400` zu wenig Inhalt, `503` KI nicht konfiguriert, `502` Upstream-Fehler mit kurzer Message.
|
||||||
|
- [ ] Logging: **keine** vollständigen Prompts mit personenbezogenen Daten in Prod-Logs (optional DEBUG).
|
||||||
|
- [ ] Optional: Rate-Limit KI-Endpunkte (`slowapi`) – nach Bedarf.
|
||||||
|
- [ ] `MODULE_VERSIONS["exercises"]` / Changelog bei API-Erweiterung setzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Changelog dieses Plans
|
||||||
|
|
||||||
|
- **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad.
|
||||||
|
- **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen.
|
||||||
|
- **2026-05-29:** **S4b:** Migration **068**, `ai_skill_retrieval_profiles`; suggest `focus_areas_context`; Frontend sendet gesetzte Fokusbereiche; Spec `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Umsetzungsstand (Zwischencheckpoint)
|
||||||
|
|
||||||
|
**Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert).
|
||||||
|
|
||||||
|
**Erledigt (2026-05-29):** Migration **`068`** / Profil **`ai_skill_retrieval_profiles`** (Standard + Profil Gewaltschutz wenn `focus_areas.name` vorhanden); **`exercise_ai`** — Score/Kategorie-Zapfen/Text-Overlap/Keyword-Zuschläge; **API:** `ExerciseAiSuggestBody.focus_areas_context`; **Regenerate** nutzt DB-Fokuszeilen.
|
||||||
|
|
||||||
|
**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen.
|
||||||
|
|
||||||
|
**Offen nächste Schritte Pflege/Umsetzung:** weitere Retrieval-Profile (z. B. Karate-/Fitness-Schwerpunkt) per SQL später Admin-UI; optionales Feld **`skills.ai_context`** Kurzbeschreibung für KI; automatische KI beim Speichern (**S5**); Prompt-/Profil-Admin-UI ohne SQL; Rate-Limits.
|
||||||
|
|
||||||
|
**Bewusst noch nicht (`summary_ai_generated`):** zurücksetzen bei manueller Kurzfassung im UI; Admin-Pflege `ai_skill_retrieval_profiles`.
|
||||||
|
|
||||||
124
.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md
Normal file
124
.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
**Update 2026-06-07:** Progressionsgraph startet **Phase F** (`planning_progression_roadmap.py`) — Roadmap-first, Workflow-lite. Siehe **`PLANNING_PROGRESSION_ROADMAP_SPEC.md`** und **`docs/architecture/PLANNING_KI_ROADMAP.md`**. Gruppenanalyse bleibt in der **Trainingsplanungs-Pipeline** (§3 S0–S4), nicht im Graphen.
|
||||||
|
|
||||||
|
**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. Progressionsgraph vs. Trainingsplanung (2026-06-07)
|
||||||
|
|
||||||
|
| Pipeline | Kontext | Orchestrator |
|
||||||
|
|----------|---------|--------------|
|
||||||
|
| **Progressionsgraph (F)** | Zieltext, N Steps, Semantic Brief | `planning_progression_roadmap.py` |
|
||||||
|
| **Trainingsplanung (G, später)** | Gruppe, Historie, Rahmen, Zeit | `planning_ai_steps` + ggf. Mitai Workflow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Changelog
|
||||||
|
|
||||||
|
- **2026-06-07:** Verweis Phase F Roadmap-first; Abgrenzung Graphen/Planung.
|
||||||
|
- **2026-05-22:** Erstfassung als Vorschau-Dokument für mehrstufige Planungs-KI.
|
||||||
121
.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
Normal file
121
.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# KI Skill-Retrieval-Profile (`ai_skill_retrieval_profiles`)
|
||||||
|
|
||||||
|
**Version:** 0.1
|
||||||
|
**Datum:** 2026-05-29
|
||||||
|
**Status:** Umsetzung gestartet (Migration **068**)
|
||||||
|
**Ziel:** Für `POST /api/exercises/ai/suggest` (Skill-Katalogauszug) **Gewichte und Quoten** steuerbar machen:
|
||||||
|
|
||||||
|
- gebunden an **Übungs-Fokusbereich** (`focus_areas.id`),
|
||||||
|
- ein **Standardprofil** ohne Fokus,
|
||||||
|
- **optional zusammengeführte** Profile bei mehreren Fokusbereichen,
|
||||||
|
- **optional Keyword-Übersteuerungen** aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung).
|
||||||
|
|
||||||
|
**Technische Basis:** Skills mit `skills.main_category_id` → `skill_main_categories.slug` (`karate` | `allgemeine`) und `skills.category_id` → `skill_categories.slug` (`kondition`, `selbstverteidigung`, …).
|
||||||
|
|
||||||
|
**Bezüge:** `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md` · `backend/exercise_ai.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Datenmodell
|
||||||
|
|
||||||
|
### Tabelle `ai_skill_retrieval_profiles`
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|--------|-----|--------------|
|
||||||
|
| `id` | serial | Primärschlüssel |
|
||||||
|
| `focus_area_id` | int NULL FK → `focus_areas(id)` ON DELETE SET NULL | **`NULL`** nur für Standardeintrag möglich (siehe `is_default`) |
|
||||||
|
| `is_default` | boolean | Genau **eine** Zeile mit `true` |
|
||||||
|
| `name` | varchar | Kurzer Name (Admin später) |
|
||||||
|
| `description` | text | Hinweise für Pflege |
|
||||||
|
| `active` | boolean | Nur aktive werden geladen |
|
||||||
|
| `config` | jsonb | Siehe §2 |
|
||||||
|
|
||||||
|
**Constraints / Indizes**
|
||||||
|
|
||||||
|
- Eindeutig: `(focus_area_id)` WHERE `focus_area_id IS NOT NULL`
|
||||||
|
- Eindeutig: `(is_default)` WHERE `is_default = true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. JSON-Konfiguration `config.version = 1`
|
||||||
|
|
||||||
|
Alle Schlüssel **optional**; fehlende Werte fallen auf **einprogrammierten Fallback** in `exercise_ai.py` zurück (entspricht bisher grob „neutral“).
|
||||||
|
|
||||||
|
### 2.1 Gewichtungen (Ranking)
|
||||||
|
|
||||||
|
| Schlüssel | Typ | Bedeutung |
|
||||||
|
|-----------|-----|------------|
|
||||||
|
| `main_slug_weights` | `object[str, float]` | Multiplikator pro Hauptkategorie-Slug (`karate`, `allgemeine`) |
|
||||||
|
| `category_slug_weights` | `object[str, float]` | Multiplikator pro `skill_categories.slug` |
|
||||||
|
|
||||||
|
Basis-Score (vereinfacht):
|
||||||
|
`(importance oder 3) × main_w × cat_w × text_overlap_bonus × importance_multiplier`
|
||||||
|
|
||||||
|
### 2.2 Kapazitätsbegrenzung (Liste)
|
||||||
|
|
||||||
|
`_MAX_SKILLS_CATALOG_LINES` (aktuell **240**) Zeilen Gesamt:
|
||||||
|
|
||||||
|
| Schlüssel | Typ | Bedeutung |
|
||||||
|
|-----------|-----|------------|
|
||||||
|
| `category_max_share` | `object[str, float]` | Max. Anteil dieser **Unterkategorie** am Endergebnis (0–1), z. B. `{ "kondition": 0.25 }` |
|
||||||
|
| `main_min_share` | `object[str, float]` | Mindest-Zielanteil Hauptkategorie beim **Auswahl-Greedy** (weich; Rest nach Score aufgefüllt) |
|
||||||
|
|
||||||
|
### 2.3 Text / Token-Sparen
|
||||||
|
|
||||||
|
| Schlüssel | Typ | Standard | Bedeutung |
|
||||||
|
|-----------|-----|----------|------------|
|
||||||
|
| `description_plain_max_len` | int | 160 | Gekürzte Beschreibung pro Zeile |
|
||||||
|
| `karate_relevance_max_len` | int | **0** oder 80 | **`0`** = Feld `karate_relevance`/`relevance_level` in der Promptzeile **weglassen** |
|
||||||
|
|
||||||
|
### 2.4 Keyword-Overrides (optional)
|
||||||
|
|
||||||
|
Liste `keyword_overrides`: jedes Element:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"keywords_any": ["befreiung", "haltegriff"],
|
||||||
|
"case_insensitive": true,
|
||||||
|
"patch": {
|
||||||
|
"category_slug_weights": { "selbstverteidigung": 2.5 },
|
||||||
|
"category_max_share": { "koordination": 0.1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Textsuche in verkettetem Korpus **Titel, Ziel, Durchführung, Focus-Hint** (bereits plaintext). Reihenfolge: erst Basis-Profile zusammenmergen, dann **alle treffenden Overrides**‑`patch`‑Objekte **flach zusammenführen** (Gewichte multiplikativ übereinander, Caps den strengsten Wert nehmen – aktuelle Implementierung im Code dokumentiert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mehrere Fokusbereiche auf der Übung
|
||||||
|
|
||||||
|
Request-Body: `focus_areas_context: [{ "focus_area_id": n, "is_primary": bool }, …]`
|
||||||
|
|
||||||
|
**Aktuelle Merge-Strategie (v1):** Profile laden → **gleichgewichtete Mittelwert-Bildung** der numerischen Gewichte / Caps (implementiert für `main_slug_weights`, `category_slug_weights`, `category_max_share`, `main_min_share`, `*_max_len`). Anschließend **Keyword-Overrides** anwenden.
|
||||||
|
|
||||||
|
**Primär-Fokus:** Im Frontend soll die **primäre** Zeile aus `focus_areas_multi` **zuerst** in der Liste stehen; die Merge-Strategie kann später zu „Primär dominate“ erweitert werden.
|
||||||
|
|
||||||
|
Ohne Kontext oder ohne Treffer auf aktive Profile: **nur Standardprofil** (`is_default`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Seed-Daten (Migration)
|
||||||
|
|
||||||
|
- **`is_default=true`:** ausgewogene Standard-Gewichte, moderate Caps auf `kondition`/`koordination`, Karate-Relevanz gekürzt.
|
||||||
|
- **`Gewaltschutz`:** `focus_area_id` per `(SELECT id FROM focus_areas WHERE name = 'Gewaltschutz' LIMIT 1)` — höhere Gewichte für `kognition`, `psychische_faehigkeiten`, `soziale_faehigkeiten`, `selbstverteidigung`; gedrosseltes `kondition`/`koordination`; `karate_relevance_max_len`: 0; Keyword-Patches wie oben können nachgeschärft werden.
|
||||||
|
|
||||||
|
Weitere Profile (Karate-Schwerpunkt etc.) später per Admin-SQL oder UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API
|
||||||
|
|
||||||
|
`ExerciseAiSuggestBody` erweitert um **`focus_areas_context`** (Liste). Feld **`focus_area_hint`** bleibt für den **Prompt-Kontext** (bestehende Prompts).
|
||||||
|
|
||||||
|
`POST …/ai/regenerate` nutzt gespeicherte `exercise_focus_areas` zur gleichen Retrieval-Logik wie Suggest.
|
||||||
|
|
||||||
|
**Pflege der Profile:** Superadmin ohne Mandantenwahl — **`GET|POST /api/admin/ai-skill-retrieval-profiles`**, **`GET|PUT|DELETE /api/admin/ai-skill-retrieval-profiles/{id}`** (`routers/ai_skill_retrieval_admin.py`); Web-UI Superadmin unter **`/admin/ai-skill-retrieval`**.
|
||||||
|
|
||||||
|
## 6. Changelog
|
||||||
|
|
||||||
|
- **2026-05-29:** Superadmin-Pflege-Endpoints + UI‑Route dokumentiert (`/admin/ai-skill-retrieval`).
|
||||||
|
- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration.
|
||||||
68
.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md
Normal file
68
.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Superadmin: Übungs-Anreicherung per KI
|
||||||
|
|
||||||
|
Stand: 2026-05-23 · App 0.8.178
|
||||||
|
|
||||||
|
## Zweck
|
||||||
|
|
||||||
|
Plattform-weites Werkzeug für Superadmins, um Übungen (typisch `draft`, ohne Skills) **batchweise** per KI mit Fähigkeiten anzureichern und kontrolliert auf `in_review` zu setzen.
|
||||||
|
|
||||||
|
Verbessert indirekt die Planungs-KI (`POST /api/planning/exercise-suggest`), die gegen Skill-Profile rankt — unvollständige `exercise_skills` führen dort zu Volltext-dominiertem Ranking.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
- Route: `/admin/exercise-enrichment` (nur Superadmin)
|
||||||
|
- Admin-Menü: „Übungs-Anreicherung“
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Prefix: `/api/admin/exercise-enrichment`
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| GET | `/candidates` | Paginierte Kandidaten (Filter: status, visibility, focus_area, without_skills, with_ai_suggested_skills, include_club, search) |
|
||||||
|
| POST | `/preview` | Dry-Run — `{ exercise_ids[], modes: { skills, summary }, merge_mode }` |
|
||||||
|
| POST | `/apply` | `{ items: [{ exercise_id, merged_skills }], merge_mode, set_status }` |
|
||||||
|
|
||||||
|
Auth: `require_auth` + `is_superadmin` — **kein** `TenantContext` (EXEMPT, siehe ACCESS_LAYER_ENDPOINT_AUDIT.md).
|
||||||
|
|
||||||
|
## KI
|
||||||
|
|
||||||
|
Wiederverwendet `run_exercise_form_ai_suggestion` → Prompts `exercise_skill_suggestions` (MVP Pflicht), optional `exercise_summary`. Skill-Katalog via `build_contextual_skills_catalog_block` / `ai_skill_retrieval_profiles`.
|
||||||
|
|
||||||
|
## Merge-Modi (Skills)
|
||||||
|
|
||||||
|
- `additive` (Default): manuelle Skills bleiben; KI ergänzt neue; bestehende `ai_suggested`-Links werden aktualisiert
|
||||||
|
- `replace_ai_only`: nur `ai_suggested=true` entfernen, dann KI-Set anwenden
|
||||||
|
- `replace_all`: alle Skills ersetzen (explizit)
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
- Kandidaten: **Status** primär (Default `draft`); Sichtbarkeit Default **`private`**, wählbar bis „Alle“
|
||||||
|
- Skill-Merge Default: **`replace_all`** (alle Skills KI-neu, `ai_suggested=true` — unterscheidbar von manuell)
|
||||||
|
- Nach Apply: `set_status=in_review` (nie automatisch `approved`)
|
||||||
|
- Batch: keine Gesamtgrenze (bis 10.000 IDs); **Analyze** + explizite Nutzerbestätigung
|
||||||
|
- **Preview:** max. **3 Übungen/HTTP-Request** (parallel LLM), Frontend chunked — vermeidet Gateway-504 (~60s Fritz!Box)
|
||||||
|
- **Apply:** HTTP-Chunks à 25 (nur DB, kein LLM)
|
||||||
|
|
||||||
|
## Inhalte (modular)
|
||||||
|
|
||||||
|
| Modus | Prompt | Apply-Felder |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| Skills | `exercise_skill_suggestions` | `exercise_skills` inkl. Intensität, required/target_level, `ai_suggested` |
|
||||||
|
| Summary | `exercise_summary` | `summary`, `summary_ai_generated=true` |
|
||||||
|
| Anleitung | `exercise_instruction_rewrite` | `goal`, `execution`, `preparation`, `trainer_notes` |
|
||||||
|
|
||||||
|
## API (ergänzt)
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| GET | `/candidate-ids` | Alle IDs zum Filter (Select-all) |
|
||||||
|
| POST | `/analyze` | `{ exercise_ids[], modes }` → Kosten-Schätzung vor Start |
|
||||||
|
|
||||||
|
## Keine Migration
|
||||||
|
|
||||||
|
Bestehende Spalte `exercise_skills.ai_suggested` reicht; kein Enrichment-Log in MVP.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`backend/tests/test_exercise_enrichment_admin.py` — 403, Merge-Logik, Status draft→in_review.
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap)
|
||||||
|
|
||||||
|
**Stand:** 2026-05-20
|
||||||
|
**Status:** Phase 1 umgesetzt; Phase 3 v1.0 umgesetzt (regelbasiert); Phase 2 teilweise offen
|
||||||
|
|
||||||
|
## Phase 1 (umgesetzt)
|
||||||
|
|
||||||
|
### Listen-Anzeige Session-Dauer
|
||||||
|
|
||||||
|
- **GET `/api/training-framework-programs`:** `session_duration_min`, `session_duration_max` (aus Blueprint-`training_units.planned_duration_min`), `goal_titles_agg`, ID-Arrays für Katalog-M:N.
|
||||||
|
- **UI:** Rahmenprogramm-Liste, Trainingsplanung (Einheiten-Liste/Kalender), Import-Dialog (Programm + pro Slot).
|
||||||
|
|
||||||
|
### Import-Filter (clientseitig)
|
||||||
|
|
||||||
|
- Textsuche (Titel, Beschreibung, Ziele, Katalog-Namen)
|
||||||
|
- Fokusbereich, Trainingsart, Zielgruppe (Checkboxen, Katalog-API)
|
||||||
|
- Ziel-Session-Dauer in Minuten (±10 Min Toleranz gegen Min/Max der Slots)
|
||||||
|
|
||||||
|
**Grenze:** Entwicklungsziele sind **freie Texte** pro Rahmen (`training_framework_goals.title`), keine kontrollierte Taxonomie → Filter nur Volltext, keine homogene „Ziel-Tags“-Liste.
|
||||||
|
|
||||||
|
## Phase 2 (empfohlen, ohne KI)
|
||||||
|
|
||||||
|
| Kriterium | Datenquelle heute | Verbesserung |
|
||||||
|
|-----------|-------------------|--------------|
|
||||||
|
| Fokusbereich / Stil / Trainingsart / Zielgruppe | M:N am Rahmenkopf | bereits filterbar |
|
||||||
|
| Entwicklungsziele | Freitext-Ziele | Optional: Ziel-Vorlagen-Katalog oder Tags (Migration) |
|
||||||
|
| Session-Dauer | `planned_duration_min` pro Slot | erledigt |
|
||||||
|
| Fähigkeiten-Schwerpunkt | noch nicht | siehe Phase 3 |
|
||||||
|
|
||||||
|
**API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek.
|
||||||
|
|
||||||
|
## Phase 3 — Fähigkeiten aus Übungen (umgesetzt v1.0)
|
||||||
|
|
||||||
|
**Spec:** `.claude/docs/technical/SKILL_SCORING_SPEC.md`
|
||||||
|
|
||||||
|
- Gewichtetes Profil: Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph
|
||||||
|
- `GET /api/skill-discovery/suggestions?skill_ids=…` für Bibliotheks-Vorschläge
|
||||||
|
- UI: Profil-Panels in Editoren + Tab „Planungs-Vorschläge“ auf der Fähigkeiten-Seite
|
||||||
|
- **Kein** automatisches Überschreiben der Stammdaten-Fokusbereiche
|
||||||
|
|
||||||
|
### Variante B — KI-Zusammenfassung (OpenRouter, optional, offen)
|
||||||
|
|
||||||
|
1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen.
|
||||||
|
2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`).
|
||||||
|
3. Speichern als `ai_context_summary` (Version, Modell, Timestamp) — **nur Vorschlag**, manuelle Bestätigung vor Übernahme in Stammdaten.
|
||||||
|
|
||||||
|
**Vorteil:** natürliche Schwerpunkte auch bei unvollständigen Skill-Links.
|
||||||
|
**Risiko:** Halluzination, Kosten, Datenschutz (Vereinsdaten in Prompt).
|
||||||
|
|
||||||
|
### Empfehlung
|
||||||
|
|
||||||
|
Zuerst **Variante A** für Listen/Filter und Abgleich mit manuell gesetzten Fokusbereichen; KI nur als **„Vorschlag generieren“-Button** im Rahmen-Editor, wenn Regelwerk und Katalog-Zuordnung zu dünn sind.
|
||||||
|
|
||||||
|
## Offene Produktfragen
|
||||||
|
|
||||||
|
1. Soll Filter **UND** (alle Kriterien) oder **ODER** (mindestens eines) sein? — Import aktuell **UND**.
|
||||||
|
2. Rahmen mit **unterschiedlichen** Slot-Dauern: Liste zeigt Min–Max; Filter „90 Min“ trifft Range.
|
||||||
|
3. Sollen homogenisierte **Entwicklungsziel-Tags** ein eigener Katalog werden (Admin), analog `target_groups`?
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
# Parallele Trainingsstreams — Ist-Analyse und risikoarmer Umsetzungsplan
|
||||||
|
|
||||||
|
**Status:** Stufe A (Analyse/Plan, ohne produktive Umsetzung in jener Session)
|
||||||
|
**Stand:** 2026-05-14
|
||||||
|
**Verbindliche fachliche Basis:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
||||||
|
|
||||||
|
Dieses Dokument **persistiert** die strukturierte Prüfung der realen Codebasis (`training_planning.py`, `training_framework_programs.py`, `training_unit_sections`/`items`, Frontend Planung/Run/Coach) und den empfohlenen Implementierungspfad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zusammenfassung
|
||||||
|
|
||||||
|
- Plan-Inhalt pro Einheit ist heute **eine flache Liste** `training_unit_sections` mit **`UNIQUE (training_unit_id, order_index)`** (Migration 031) und `training_unit_section_items`; zentral: **`_fetch_sections`**, **`_replace_unit_sections`**, **`_hydrate_training_unit_payload`** in `backend/routers/training_planning.py`.
|
||||||
|
- Parallele Phasen/Streams **passen** zu den Produktregeln (ein Kalendertermin, N Streams, je Miniplan), sind im Schema aber **nicht** abbildbar ohne Erweiterung und **ohne Auflösung** des globalen `order_index`-Modells.
|
||||||
|
- **Empfehlung:** **Normalisierte** Tabellen `training_unit_phases`, `training_unit_parallel_streams`, erweiterte `training_unit_sections` mit FK auf Phase bzw. Stream, **partielle Unique-Indizes** statt `UNIQUE (training_unit_id, order_index)` für alle Sektionen.
|
||||||
|
- **Blocker im Code:** u. a. `POST /api/training-units/{id}/apply-training-module` mit **`section_order_index` global pro Einheit** (`_resolve_training_unit_section_id`).
|
||||||
|
- **Nicht persistiert an anderer Stelle:** Erste Fassung existierte nur als Chat-Antwort; dieses File ist die **kanonische** Arbeitskopie im Repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ist-Analyse (kurz)
|
||||||
|
|
||||||
|
### Datenbank
|
||||||
|
|
||||||
|
- `training_unit_sections`: u. a. `training_unit_id`, `order_index`, `UNIQUE (training_unit_id, order_index)`.
|
||||||
|
- `training_unit_section_items`: Übung/Notiz, `planning_method_profile` (Kombi), `source_training_module_id`.
|
||||||
|
|
||||||
|
### Backend (`training_planning.py`)
|
||||||
|
|
||||||
|
- `_replace_unit_sections`: DELETE aller Sektionen der Einheit + INSERT (vollständiger Ersetzungsbaum).
|
||||||
|
- `_sections_clone_payload` + `_copy_blueprint_into_scheduled_unit`: tiefe Kopie für `from-framework-slot`.
|
||||||
|
- `_flatten_exercises_from_sections`: flaches `exercises` am Unit-Payload.
|
||||||
|
- `apply_training_module_to_training_unit`: Sektion per **`section_order_index`** global.
|
||||||
|
|
||||||
|
### Rahmen (`training_framework_programs.py`)
|
||||||
|
|
||||||
|
- Blueprint-`training_units` pro Slot; gleiche `_replace_unit_sections`-Semantik.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- Planung: `TrainingPlanningPageRoot.jsx`, `TrainingUnitSectionsEditor`, `buildSectionsPayload` / `normalizeUnitToForm`.
|
||||||
|
- Run: `TrainingUnitRunPage.jsx` — Fortschritt `sessionStorage` Key `sj_training_run_checked_${unitId}`.
|
||||||
|
- Coach: `TrainingCoachPage.jsx` — `flattenPlanTimeline` (linearer Ablauf).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Kaum Abdeckung für Plan-Inhalt; vorhanden u. a. `test_training_unit_assignments.py` (Merge Co-Trainer, ohne DB), `test_training_units_list_keyset.py` (Keyset-Validierung).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Technische Optionen und Empfehlung
|
||||||
|
|
||||||
|
| Option | Kurz |
|
||||||
|
|--------|------|
|
||||||
|
| A JSONB nur auf `training_units` | Niedriges DDL-Risiko, hohes Drift-/Wartungsrisiko — **nicht empfohlen** |
|
||||||
|
| B Normalisiert Phasen/Streams | **Empfohlen** — eine Wahrheit, saubere Kopie, Rahmen kompatibel |
|
||||||
|
| C Nur UI-Konvention ohne DB | Widerspricht Produkt — **abgelehnt** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Migrations- und Kompatibilitätsstrategie
|
||||||
|
|
||||||
|
- Default **`whole_group`‑Phase** für alle bestehenden Einheiten; alle bisherigen Sektionen erhalten `phase_id`.
|
||||||
|
- Unique-Regel: **pro Phase** bzw. **pro Stream** `order_index` eindeutig (partielle Unique-Indizes).
|
||||||
|
- API optional: zusätzlich abgeleitetes flaches `sections` für Übergang — Entscheidung je nach Consumer (praktisch nur dieses Frontend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API- / Frontend-Hotspots
|
||||||
|
|
||||||
|
- `GET`/`PUT` `/api/training-units/{id}`: verschachtelte `phases` / `streams` / `sections` / `items`.
|
||||||
|
- `POST .../apply-training-module`: Kontext **Phase/Stream + Sektionsindex im Träger**.
|
||||||
|
- Run/Coach: stream-spezifischer Fortschritt; `flattenPlanTimeline` phase-aware oder pro Stream.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Implementierungspakete (Überblick)
|
||||||
|
|
||||||
|
0. Spike DDL + Contract-Doku
|
||||||
|
1. **Erledigt (2026-05-14):** Migration **063** + `training_planning`: Phasen/Streams-Schema, Backfill whole_group, `GET` mit `phases`, Legacy-`sections`-PUT unverändert (eine whole_group-Phase).
|
||||||
|
2. PUT mit echten Parallelphasen / Streams, `apply-training-module` mit Stream-Kontext, `from-framework-slot`-Kopie
|
||||||
|
3. Planung UI
|
||||||
|
4. Run + Coach
|
||||||
|
5. Co-Trainer pro Stream
|
||||||
|
6. MVP+ (Duplizieren, Verschieben, „nur meine Spur“)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risiken
|
||||||
|
|
||||||
|
- Migration Unique-Constraint / bestehende Daten.
|
||||||
|
- Regression Run/Coach / Dashboard-Joins (meist unkritisch, solange `training_unit_id` auf Sektionen bleibt).
|
||||||
|
- Rahmen-Blueprints: gleiche Struktur wie Kalender-Einheiten anstreben (oder bewusst zweite Phase nur Kalender).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Offene Produkt-/Technikfragen
|
||||||
|
|
||||||
|
- Rahmen-Blueprint parallel im MVP oder erst nach Kalender-Einheit?
|
||||||
|
- Semantik `exercises`-Flatlist bei Parallelität.
|
||||||
|
- Merge-Regel `assistant_trainer_profile_ids` Kopf vs. Stream-Zuweisungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Verweise
|
||||||
|
|
||||||
|
- Fachkonzept: `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
|
||||||
|
- Technische Spec (Entwurf): `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
||||||
|
- Domänenüberblick: `.claude/docs/functional/DOMAIN_MODEL.md` (Abschnitt Parallele Streams)
|
||||||
|
- `./PARALLEL_TRAINING_STREAMS_PREREQ_PROMPT.md` — **Prompt** für Folgesession (Performance/Wartung/Vorbereitung)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Vorbereitende Arbeiten (Session 2026-05-13)
|
||||||
|
|
||||||
|
Ohne produktives Parallel-Feature, nur Risikoabbau und Transparenz:
|
||||||
|
|
||||||
|
- **`training_planning.py`:** Lesepfad `_fetch_sections` in SQL-Konstanten + `_fetch_section_items_for_section` / `_hydrate_section_item_combination_slots` strukturiert; `_replace_unit_sections` delegiert an `_insert_one_replacement_section`; `_hydrate_training_unit_payload` dokumentiert.
|
||||||
|
- **Tests:** `tests/test_training_planning_sections_pure.py` (flatten, ohne DB); `tests/test_training_planning_sections_integration.py` (Roundtrip replace↔fetch bei `TRAINING_PLANNING_INTEGRATION=1`).
|
||||||
|
- **Frontend:** Kurzkommentare an Planung (`TrainingPlanningPageRoot`, `buildSectionsPayload`), Run, Coach, `flattenPlanTimeline` — Anbindungspunkte für spätere Phase/Stream-Logik.
|
||||||
|
- **DOMAIN_MODEL:** UNIQUE-Hinweis und „keine Migration ohne Freigabe“.
|
||||||
|
|
||||||
|
**Empfohlene nächste Schritte:** Pakete **0** (DDL/Contract festzurren) und **1** (Schema + Migration + hydrate/replace laut Plan Abschnitt 4–6) in einer dedizierten Feature-Session; danach Paket **2** (PUT/Module/Clone).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Prompt: Vorbereitungs- / Vorarbeit-Session (Performance & Wartung) für „Parallele Trainingsstreams“
|
||||||
|
|
||||||
|
**Kontext:** Du arbeitest in **Shinkan Jinkendo** (`c:\Dev\shinkan-jinkendo`). Das Feature **Parallele Trainingsstreams / Breakout** ist **inhaltlich** spezifiziert; eine **Ist-Analyse und ein risikoarmer Umsetzungsplan** liegen **persistiert** in:
|
||||||
|
|
||||||
|
- `.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md`
|
||||||
|
- Fachlich: `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
|
||||||
|
- Technik-Entwurf: `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
||||||
|
|
||||||
|
**Deine Rolle:** Du hast bereits **Refaktorierungs- und Wartungsaufgaben** mit Fokus **Performance, Lesbarkeit und Testbarkeit** durchgeführt. In **dieser** Session geht es **nicht** darum, das komplette Parallel-Feature zu bauen, sondern um **Vorarbeiten („Prerequisites“)**, die die geplante Komplexitätsauflösung **sicherer und billiger** machen.
|
||||||
|
|
||||||
|
## Ziele
|
||||||
|
|
||||||
|
1. **Lesepfad Planung vereinheitlichen:** `backend/routers/training_planning.py` ist zentral für `_fetch_sections`, `_replace_unit_sections`, `_hydrate_training_unit_payload`, Klonen, Blueprint-Kopie, `apply-training-module`. Prüfe, ob klar abgegrenzte Hilfsfunktionen (ohne Verhaltensänderung) die **nächste** große Änderung erleichtern — **keine** Feature-Logik für Phasen/Streams hinzufügen, nur Struktur/Tests/Docs wenn nötig.
|
||||||
|
|
||||||
|
2. **Test-Lücken schließen (minimal, hoher Nutzen):** Heute fehlen **DB/API-Tests** für kritische Pfade (`_replace_unit_sections` Roundtrip, `from-framework-slot` Struktur-Kopie, optional `apply-training-module`). Ergänze **kleine, deterministische** Tests (pytest mit DB, falls im Projekt üblich), ohne riesige Fixtures.
|
||||||
|
|
||||||
|
3. **Frontend-Schneidstellen markieren:** kurze Kommentare oder ein **Working-Doc-Update**, wo `TrainingPlanningPageRoot`, `buildSectionsPayload`, `TrainingUnitRunPage`, `TrainingCoachPage` + `trainingPlanUtils.flattenPlanTimeline` später angebunden werden — **kein** großes UI-Rewrite.
|
||||||
|
|
||||||
|
4. **Migrations-Sicherheit:** Dokumentiere in **einem Absatz** im `ANALYSIS`-Dokument oder hier, welche **Unique-Constraints** (`training_unit_sections`: `UNIQUE (training_unit_id, order_index)`) die Parallelität blockieren — **ohne** sie schon zu ändern, außer es ist Teil einer **explizit** freigegebenen ersten Migration.
|
||||||
|
|
||||||
|
5. **Performance nur berührensensible Stellen:** Einzelabruf `GET /api/training-units/{id}` wird mit mehr JOINs kommen. Falls du **jetzt** N+1 oder redundante Arbeit in `_fetch_sections` siehst und das **risikoarm** verbesserbar ist, nur mit **Messpunkt/Messvorstellung** (kein unnötiger Micro-Optimismus).
|
||||||
|
|
||||||
|
## Leitplanken
|
||||||
|
|
||||||
|
- **Stabilität vor Geschwindigkeit:** Keine Änderung, die bestehende Einheiten, Rahmen-Blueprints oder Run-Modus bricht.
|
||||||
|
- **Keine pauschalen Refactors:** Nur Änderungen mit **klarem** Träger für das Parallel-Feature oder mit **Test-Regression-Schutz**.
|
||||||
|
- **Regeln:** `.claude/rules/ARCHITECTURE.md`, `CODING_RULES.md`, Zugriffsschicht wo relevant.
|
||||||
|
|
||||||
|
## Erwartete Ausgabe
|
||||||
|
|
||||||
|
1. Kurze **Liste erledigter Vorarbeiten** (Dateien, was warum).
|
||||||
|
2. **Empfohlene Reihenfolge** für die **nächste** Session, die Phasen/Streams **implementiert** (verweis auf `PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md` Pakete 0–2).
|
||||||
|
3. Falls nichts Sinnvolles ohne Feature-Branch riskiert werden kann: **explizit** „keine Code-Änderung“, nur Risiko-Notiz.
|
||||||
|
|
||||||
|
## Optionaler Startbefehl
|
||||||
|
|
||||||
|
```
|
||||||
|
Lies zuerst:
|
||||||
|
.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md
|
||||||
|
dann backend/routers/training_planning.py (Abschnitte um _fetch_sections, _replace_unit_sections).
|
||||||
|
```
|
||||||
529
.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
Normal file
529
.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
Normal file
|
|
@ -0,0 +1,529 @@
|
||||||
|
# Planungs-KI: Übungssuche & Kontext für Neu-Anlage
|
||||||
|
|
||||||
|
**Version:** 0.2
|
||||||
|
**Datum:** 2026-05-23
|
||||||
|
**Status:** P0–P2 ✅ · Phase A/B/B2 ✅ · **Phase C1–C3 ✅** · **Phase E ✅** (Semantik + Pfad-QA)
|
||||||
|
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ziel
|
||||||
|
|
||||||
|
Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können mit natürlichen Anfragen wie:
|
||||||
|
|
||||||
|
- „Vertiefung zu Übung XY“
|
||||||
|
- „Nächste sinnvolle Übung im Progressionsgraph Z“
|
||||||
|
- „Baut auf der bisherigen Planung auf — Reaktionsschnelligkeit mit Partnern“
|
||||||
|
- **Preset:** „Schlage mir die nächste Übung vor“
|
||||||
|
|
||||||
|
**Suche** (Bibliothek) und **Neu mit KI-Assistent** (Anlage) nutzen dasselbe **`PlanningExerciseContextPack`** — unterschiedliches Ergebnis (Treffer vs. Entwurf).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architektur (Mehrstufig)
|
||||||
|
|
||||||
|
| Stufe | Name | Technik | P0 |
|
||||||
|
|-------|------|---------|-----|
|
||||||
|
| **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ |
|
||||||
|
| **S1a** | Intent strukturieren | LLM `planning_exercise_search_intent` (Szenario-Pipeline) | ✅ P1 |
|
||||||
|
| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan + **Profil** | ✅ |
|
||||||
|
| **S1b+** | Profil-Vorselektion | `ExerciseMatchProfile` × `PlanningTargetProfile` | ✅ `profile_v1` |
|
||||||
|
| **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` |
|
||||||
|
| **S2** | Neu-Anlage | Bestehende `suggestExerciseAi` + Pack als Zusatzkontext | Später |
|
||||||
|
|
||||||
|
Zwischen jeder Stufe: **nur erlaubte `exercise_id`s** (Governance / Sichtbarkeit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Intent-Typen
|
||||||
|
|
||||||
|
| `intent_hint` | Bedeutung | Retrieval-Gewichtung (P0) |
|
||||||
|
|---------------|-----------|---------------------------|
|
||||||
|
| `suggest_next` | Nächste Übung (Default bei leerer/kurzer Query) | Progression + Skill-Overlap + Plan-Kontinuität |
|
||||||
|
| `progression_next` | Explizit Graph-Folge | Progression hoch |
|
||||||
|
| `deepen_exercise` | Vertiefung zu Anker-Übung | Skill-Overlap hoch, ähnlicher Fokus |
|
||||||
|
| `continue_plan_goal` | Auf bisherigen Plan aufbauen | Plan-Kontinuität, Wiederholungsstrafe |
|
||||||
|
| `free_search` | Freitext / Stichwort | Volltext hoch |
|
||||||
|
|
||||||
|
**S1a (später):** Freitext → JSON `{ intent, skill_hints[], requires_partner, level_hint, … }` validiert per Pydantic.
|
||||||
|
|
||||||
|
**P0:** `intent_hint` vom Client oder Keyword-Heuristik auf `query`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PlanningExerciseContextPack (S0)
|
||||||
|
|
||||||
|
Serverseitig aus Request + DB (tokenbewusst für spätere LLM-Stufen):
|
||||||
|
|
||||||
|
| Feld | Quelle | UI-Chip |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `unit_id`, Titel, `group_id`, Gruppenname | `training_units` + `training_groups` | Gruppe · Einheit |
|
||||||
|
| `section_order_index`, Abschnittstitel | `training_unit_sections` | Abschnitt |
|
||||||
|
| `planned_exercise_ids[]` | Items der Einheit (Reihenfolge) | „N Übungen im Plan“ |
|
||||||
|
| `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker |
|
||||||
|
| `anchor_skill_ids[]` | `exercise_skills` | (intern) |
|
||||||
|
| `progression_graph_id` | Request oder **Auto-Match** vom Anker (sichtbarer Graph mit passenden Ausgangskanten) | Graph |
|
||||||
|
| `progression_graph_name`, `progression_graph_auto_resolved` | Response `context_summary` | Graph (auto) |
|
||||||
|
| `anchor_exercise_variant_id` | Request / Abschnitt-Item / DB | (intern) |
|
||||||
|
| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker (variantenbewusst, Migration **034**) | (intern) |
|
||||||
|
| `progression_successor_variants` | `to_exercise_variant_id` pro Nachfolger | (intern) |
|
||||||
|
| `group_recent_exercise_ids[]` | Letzte Einheiten derselben Gruppe | Wiederholungsstrafe |
|
||||||
|
| `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) |
|
||||||
|
|
||||||
|
**Berechtigung:** `get_tenant_context` + `_assert_training_unit_permission` wie `GET /training-units/{id}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Hybrid-Retrieval (S1b, P0)
|
||||||
|
|
||||||
|
Kandidaten: sichtbare Übungen (`library_content_visibility_sql`), ohne `archived`, max. ~400 (recent).
|
||||||
|
|
||||||
|
**Score** (0–1, gewichtet nach Intent):
|
||||||
|
|
||||||
|
```
|
||||||
|
score = w_ft * fulltext_rank
|
||||||
|
+ w_prog * progression_hit
|
||||||
|
+ w_skill * skill_jaccard(anchor, candidate)
|
||||||
|
+ w_plan * plan_affinity
|
||||||
|
+ w_profile * profile_match(exercise, target)
|
||||||
|
+ w_repeat * (candidate in unit_plan ? -1 : 0)
|
||||||
|
+ w_group_repeat * (candidate in group_recent ? -0.5 : 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`profile_match`** (0–1): siehe §12–§13 — Katalog-Dimensionen + Skill-Gewichte + Skill-Gap.
|
||||||
|
|
||||||
|
**`reasons[]`** (regelbasiert, Deutsch): z. B. „Nachfolger im Progressionsgraph“, „Fähigkeiten passen zur Anker-Übung“, „Fokusbereich passend zum Planungsziel“, „Deckt Skill-Lücke im bisherigen Plan“, „Volltext-Treffer“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API
|
||||||
|
|
||||||
|
### `POST /api/planning/exercise-suggest`
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unit_id": 123,
|
||||||
|
"section_order_index": 0,
|
||||||
|
"phase_order_index": null,
|
||||||
|
"parallel_stream_order_index": null,
|
||||||
|
"anchor_exercise_id": 456,
|
||||||
|
"anchor_exercise_variant_id": 12,
|
||||||
|
"progression_graph_id": 7,
|
||||||
|
"query": "Schlage mir die nächste Übung vor",
|
||||||
|
"intent_hint": "suggest_next",
|
||||||
|
"limit": 20,
|
||||||
|
"exercise_kind_any": ["simple"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"context_summary": {
|
||||||
|
"unit_title": "…",
|
||||||
|
"group_name": "…",
|
||||||
|
"section_title": "Hauptteil",
|
||||||
|
"planned_count": 4,
|
||||||
|
"anchor_title": "Partner-Fangspiel"
|
||||||
|
},
|
||||||
|
"target_profile_summary": {
|
||||||
|
"sources": ["framework_catalog", "current_unit_plan", "anchor_exercise"],
|
||||||
|
"focus_areas": ["Reaktion & Abwehr"],
|
||||||
|
"top_skills": [{ "skill_id": 12, "name": "Reaktionsgeschwindigkeit", "weight": 1.0 }],
|
||||||
|
"has_skill_gap": true
|
||||||
|
},
|
||||||
|
"retrieval_phase": "profile_v1",
|
||||||
|
"intent_resolved": "suggest_next",
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"title": "…",
|
||||||
|
"summary": "…",
|
||||||
|
"score": 0.78,
|
||||||
|
"reasons": ["Nachfolger im Progressionsgraph", "Fokusbereich passend zum Planungsziel"],
|
||||||
|
"focus_area": "…"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modul:** `backend/planning_exercise_suggest.py` · `backend/planning_exercise_profiles.py` · Router `backend/routers/planning_exercise_suggest.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Frontend
|
||||||
|
|
||||||
|
| Ort | Verhalten |
|
||||||
|
|-----|-----------|
|
||||||
|
| `ExercisePickerModal` | Prop `planningContext` → Planungs-API statt reiner `listExercises`; Kontext-Chips; `reasons` unter Treffer |
|
||||||
|
| `TrainingUnitEditPage` | `planningContext` aus Einheit + Picker-Ziel (Anker = letzte Übung im Abschnitt) |
|
||||||
|
| **`ExercisesListPageRoot`** | Schalter **„Neu mit KI-Assistent“**: Planungs-KI-Suche (frei, ohne `unit_id`) + Neuanlage im Modal; **„+ Neu“** ausgeblendet |
|
||||||
|
| Rahmen / Kombi-Formular | analog, sobald `unit_id` / Slot-Blueprint bekannt |
|
||||||
|
| Übungsliste (ohne KI-Schalter) | weiter Volltext |
|
||||||
|
|
||||||
|
**Zweites Suchfeld** im Picker: Query = Volltext + ergänzender Begriff (ODER in P0 als Konkatenation an Backend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Neu-Anlage (Anbindung, Phase P1)
|
||||||
|
|
||||||
|
Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||||
|
|
||||||
|
- `planning_context` im Request-Body → `planning_context_json` in Übungs-Prompts (Migration **085**); Pfad-Builder + Picker ✅ **0.8.208**
|
||||||
|
- Kurzbeschreibung optional leer (freier Vorschlag) oder aus Intent/Skizze
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phasen-Roadmap
|
||||||
|
|
||||||
|
| Phase | Inhalt | Status |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| **P0** | Context-Pack, Hybrid-Score, API, Picker in Planung | ✅ |
|
||||||
|
| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
|
||||||
|
| **P1** | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil | ✅ |
|
||||||
|
| **P2 / B2** | LLM-Rerank bei engem Top-Feld (max. 2 Calls) | ✅ |
|
||||||
|
| **P3** | Skill-Discovery / Framework-Ziele im Pack | 🔲 |
|
||||||
|
| **A** | Voll-Library Hybrid-Ranking | ✅ **0.8.177** |
|
||||||
|
| **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** |
|
||||||
|
| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
||||||
|
| **C2** | Varianten in Trefferliste / Picker | ✅ **0.8.184** |
|
||||||
|
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** |
|
||||||
|
| **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** |
|
||||||
|
| **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** |
|
||||||
|
| **D** | Neu-Anlage: `planning_context` an `suggestExerciseAi` (Migration **085**) | ✅ **0.8.208** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Changelog
|
||||||
|
|
||||||
|
- **2026-05-23:** Phase C1 — Graph auto-match, variantenbewusste Nachfolger (`planning_exercise_progression.py`).
|
||||||
|
- **2026-05-23:** Phase B2 — Rerank bei engem Top-Feld; Phase B — Text-Signale; Phase A — Voll-Library (siehe §17–§19).
|
||||||
|
- **2026-05-22:** Erstfassung; P0 API + Planungs-Picker.
|
||||||
|
- **2026-05-22:** P0 implementiert (`planning_exercise_suggest.py`, Router, Picker); unsaved Formular-Plan noch nicht an API (nur persistierte Einheit).
|
||||||
|
- **2026-05-22:** P0.1 — `planning_exercise_profiles.py`, Profil-Score in Hybrid-Retrieval, `retrieval_phase: profile_v1`, `target_profile_summary`.
|
||||||
|
- **2026-05-22:** P2 — LLM-Rerank optional (`include_llm_rank`); Client `planned_exercise_ids[]`; Prompt Migration 072.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Bekannte Lücken & Backlog
|
||||||
|
|
||||||
|
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
|
||||||
|
- **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen.
|
||||||
|
- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer (**C2** — Dropdown + Graph-Vorschlag).
|
||||||
|
- **Graph-Builder (C3):** Ziel → Pfad vorschlagen → in Graph speichern — ✅ **0.8.185**
|
||||||
|
- **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**).
|
||||||
|
- **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score.
|
||||||
|
- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073).
|
||||||
|
- **Preset + LLM:** ✅ Erwartungs-LLM (074) bei Planungsbezug; Preset ohne Plan = kein Erwartungs-LLM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Szenario-Pipeline & Query-Erwartungsprofil (P1)
|
||||||
|
|
||||||
|
Komplexe Planungsanfragen brauchen **Schritte vor** dem Profil-Match — nicht jede Query ist gleich.
|
||||||
|
|
||||||
|
### 16.1 Szenario-Klassen
|
||||||
|
|
||||||
|
| `scenario_kind` | Typische Anfrage | LLM Intent? |
|
||||||
|
|-----------------|------------------|-------------|
|
||||||
|
| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Erwartungs-LLM (074) wenn Planungsbezug |
|
||||||
|
| `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) |
|
||||||
|
| `deepen` | Vertiefung Anker | Ja |
|
||||||
|
| `continue_plan` | Auf bisherigen Plan aufbauen | Ja |
|
||||||
|
| `additive_constraint` | Plan **+** Zusatz (z. B. Schnellkraft) | Ja |
|
||||||
|
| `free_search` | Offene Stichwortsuche | Ja |
|
||||||
|
|
||||||
|
**Routing:** `planning_exercise_target_pipeline.classify_planning_scenario()` → `should_run_llm_intent_pipeline()`.
|
||||||
|
|
||||||
|
### 16.2 Pipeline (Reihenfolge)
|
||||||
|
|
||||||
|
```
|
||||||
|
S0 Kontext-Pack
|
||||||
|
→ Heuristik-Intent + Szenario
|
||||||
|
→ [optional] LLM planning_exercise_search_intent
|
||||||
|
→ Basis PlanningTargetProfile (Rahmen, Plan, Anker, Gap)
|
||||||
|
→ Merge Query-Overlay (Katalog-IDs aus Hints)
|
||||||
|
→ Hybrid-Retrieval + Profil-Score
|
||||||
|
→ [optional] LLM-Rerank
|
||||||
|
```
|
||||||
|
|
||||||
|
Module: `planning_exercise_target_pipeline.py` · `planning_exercise_intent.py`
|
||||||
|
|
||||||
|
### 16.3 API (Erweiterung)
|
||||||
|
|
||||||
|
| Request | Default | Bedeutung |
|
||||||
|
|---------|---------|-----------|
|
||||||
|
| `include_llm_intent` | `true` | LLM nur wenn Szenario ≠ preset_next und Query nicht leer |
|
||||||
|
|
||||||
|
| Response | Bedeutung |
|
||||||
|
|----------|-----------|
|
||||||
|
| `scenario_kind` | Szenario-Klasse |
|
||||||
|
| `query_intent_summary` | intent, llm_applied, rationale, skill_hints_resolved |
|
||||||
|
| `intent_heuristic` | Heuristik vor LLM |
|
||||||
|
| `retrieval_phase` | z. B. `profile_v1+query_intent+llm_rank` |
|
||||||
|
|
||||||
|
**Prompt 073:** `planning_exercise_search_intent` — Ausgabe JSON mit `skill_hints`, `focus_hints`, `emphasis` (`additive`|`replace`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. LLM-Rerank (P2)
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
| Feld | Typ | Default | Bedeutung |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `planned_exercise_ids` | `int[]` | — | Optional: Reihenfolge aus Formular (überschreibt DB-Plan) |
|
||||||
|
| `include_llm_rank` | `bool` | `true` (Client) | Backend gated (B2): Rerank nur bei engem Top-Feld, max. 2 LLM-Calls |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|------|------|
|
||||||
|
| `retrieval_phase` | `profile_v1` oder `profile_v1+llm_rank` |
|
||||||
|
| `llm_rank_applied` | `true` wenn LLM erfolgreich sortiert hat |
|
||||||
|
| `hits[].llm_rank` | optional: Position nach LLM (1…n) |
|
||||||
|
|
||||||
|
**Fallback:** Kein API-Key, inaktiver Prompt oder Parse-Fehler → Hybrid-Reihenfolge unverändert, `llm_rank_applied: false`.
|
||||||
|
|
||||||
|
**Prompt:** Migration **072**, Slug `planning_exercise_search_rank` — Kandidaten als JSON mit Titel, summary, goal (Plaintext), skills; Ausgabe `{ ranked_ids, reasons }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. ExerciseMatchProfile & PlanningTargetProfile (Phase 1)
|
||||||
|
|
||||||
|
Ziel: deterministische Vorselektion über **Profil-Dimensionen** statt nur Titel/Jaccard.
|
||||||
|
|
||||||
|
### 12.1 ExerciseMatchProfile (pro Übung)
|
||||||
|
|
||||||
|
| Feld | Quelle |
|
||||||
|
|------|--------|
|
||||||
|
| `focus_area_ids` | `exercise_focus_areas` (Primary = 1.0, sonst 0.85) |
|
||||||
|
| `style_direction_ids` | `exercise_style_directions` |
|
||||||
|
| `training_type_ids` | `exercise_training_types` |
|
||||||
|
| `target_group_ids` | `exercise_target_groups` |
|
||||||
|
| `skill_weights` | `exercise_skills` × Intensitäts-Multiplikator (`skill_scoring._skill_link_multiplier`) |
|
||||||
|
|
||||||
|
Bulk-Lader: `load_exercise_match_profiles_bulk(cur, exercise_ids)`.
|
||||||
|
|
||||||
|
### 12.2 PlanningTargetProfile (Planungsziel)
|
||||||
|
|
||||||
|
Zusammensetzung aus mehreren Quellen (`sources[]`):
|
||||||
|
|
||||||
|
| Quelle | Inhalt |
|
||||||
|
|--------|--------|
|
||||||
|
| `framework_catalog` | Fokus/Stil/Trainingsstil/Zielgruppe aus `training_framework_program_*` |
|
||||||
|
| `framework_slot_skill_profile` | Skill-Profil des Slot-Blueprints (`profile_for_occurrences`) |
|
||||||
|
| `framework_overall_skill_profile` | Fallback: alle Blueprint-Einheiten des Rahmens |
|
||||||
|
| `current_unit_plan` | Skill-Profil der bereits eingeplanten Übungen dieser Einheit |
|
||||||
|
| `anchor_exercise` | Katalog + Skills der Anker-Übung (Intent-abhängig) |
|
||||||
|
| `skill_gap_vs_plan` | `target_skills − plan_skills` (normalisiert, Schwelle > 0.08) |
|
||||||
|
|
||||||
|
Builder: `build_planning_target_profile(cur, unit=…, planned_exercise_ids=…, anchor_exercise_id=…, intent=…)`.
|
||||||
|
|
||||||
|
Rahmen-Anbindung über `unit.framework_slot_id` oder `origin_framework_slot_id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Profil-Score (Formeln)
|
||||||
|
|
||||||
|
**Gewichtete Überlappung** (Katalog + Skills):
|
||||||
|
|
||||||
|
```
|
||||||
|
overlap(a, b) = Σ min(a[k], b[k]) / Σ max(a[k], b[k])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skill-Gap-Abdeckung:**
|
||||||
|
|
||||||
|
```
|
||||||
|
gap_coverage(gap, candidate) = Σ min(gap[k], candidate[k]) / Σ gap[k]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Profil-Score** (intent-gewichtet, Summe Dimensionen = 1.0):
|
||||||
|
|
||||||
|
```
|
||||||
|
profile_score = w_focus * overlap(focus)
|
||||||
|
+ w_style * overlap(style)
|
||||||
|
+ w_tt * overlap(training_type)
|
||||||
|
+ w_tg * overlap(target_group)
|
||||||
|
+ w_skill * overlap(skill_weights)
|
||||||
|
+ w_gap * gap_coverage(skill_gap)
|
||||||
|
```
|
||||||
|
|
||||||
|
Intent-Gewichte (Auszug): `deepen_exercise` → Skill hoch; `continue_plan_goal` → Gap hoch; `free_search` → Gap + Skill moderat.
|
||||||
|
|
||||||
|
Scorer: `score_exercise_against_target(exercise_profile, target_profile, intent=…) → (score, reasons[])`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Hybrid + Profil (P0.1)
|
||||||
|
|
||||||
|
Im Hybrid-Score kommt **`w_profile * profile_score`** hinzu (Intent-abhängig ~0.15–0.35). Jaccard auf Anker-Skills bleibt parallel (schneller Anker-Fokus).
|
||||||
|
|
||||||
|
**Response-Felder:**
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank |
|
||||||
|
| `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) |
|
||||||
|
|
||||||
|
**Phase 2 (P2 / B2):** siehe §15 und §18 — `include_llm_rank: true` vom Client, Backend entscheidet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Phase A — Voll-Library-Ranking (0.8.177)
|
||||||
|
|
||||||
|
- Kein OR-Profil-Pool (~500 Übungen) mehr.
|
||||||
|
- Alle sichtbaren Übungen (bis 8000) werden hybrid gescored (`fetch_all_visible_exercise_rows` + `rank_visible_library_hits`).
|
||||||
|
- API: `full_library_ranked: true`, `retrieval_phase` enthält `+full_library+`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Phase B / B2 — Text-Signale & Rerank-Gates (0.8.181–0.8.182)
|
||||||
|
|
||||||
|
**B — Text-Signale (`planning_exercise_text_signals.py`):**
|
||||||
|
|
||||||
|
- `section_guidance_notes`, Rahmen-Ziele/Notizen → Skill-/Katalog-Gewichte ohne LLM.
|
||||||
|
- `requires_partner` aus Intent filtert Kandidaten.
|
||||||
|
- `retrieval_phase +text_signals`.
|
||||||
|
|
||||||
|
**B2 — Rerank bei unklarem Ranking:**
|
||||||
|
|
||||||
|
- `hybrid_ranking_ambiguous(hits)` (Top-4-/Top-10-Gap).
|
||||||
|
- Rerank auch nach Erwartungs-/Intent-LLM, wenn Scores eng beieinander.
|
||||||
|
- Budget: max. **2** LLM-Calls (Profil + optional Rerank).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Phase C1 — Progressionsgraph im Planungskontext (0.8.183)
|
||||||
|
|
||||||
|
**Modul:** `planning_exercise_progression.py`
|
||||||
|
|
||||||
|
### Auto-Match Graph
|
||||||
|
|
||||||
|
Wenn `progression_graph_id` fehlt und Anker-Übung gesetzt: sichtbarer Graph mit passender `next_exercise`-Kante vom Anker (variantenbewusst). Bevorzugung: variantenspezifische Kanten > Anzahl Kanten.
|
||||||
|
|
||||||
|
### Variantenbewusste Nachfolger (Migration 034)
|
||||||
|
|
||||||
|
Generische Kante (`from_exercise_variant_id IS NULL`) gilt für jeden Anker; variantenspezifische Kante nur bei passender Anker-Variante.
|
||||||
|
|
||||||
|
Treffer: optional `hits[].suggested_variant_id`.
|
||||||
|
|
||||||
|
### Request / Response
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `anchor_exercise_variant_id` | Request — Variante der Anker-Übung |
|
||||||
|
| `progression_graph_name` | Response — Name des (auto-)Graphs |
|
||||||
|
| `progression_graph_auto_resolved` | Response — Auto-Match aktiv |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Phase C2 — Varianten in Treffern (0.8.184) ✅
|
||||||
|
|
||||||
|
- API: `variants[]`, `suggested_variant_name` pro Treffer (Batch aus `exercise_variants`).
|
||||||
|
- **`ExercisePickerModal`:** Dropdown pro Treffer; Graph-`suggested_variant_id` vorausgewählt; Übernahme setzt `exercise_variant_id`.
|
||||||
|
- **`hydrateExercisePlanningRow`:** übernimmt `exercise_variant_id` / `suggested_variant_id` in die Planungszeile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. Phase C3 — Graph-Builder (0.8.185) ✅
|
||||||
|
|
||||||
|
**API:** `POST /api/planning/progression-path-suggest`
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `query` | Ziel / Entwicklungsrichtung (Freitext, min. 3 Zeichen) |
|
||||||
|
| `max_steps` | 2–10, Default 5 |
|
||||||
|
| `progression_graph_id` | optional — Graph-Kontext für Nachfolger ab Schritt 2 |
|
||||||
|
| `include_llm_intent` | LLM nur Schritt 1 (Budget) |
|
||||||
|
|
||||||
|
**Response:** `steps[]` mit `exercise_id`, `variant_id`, `title`, `reasons`, `variants`; `retrieval_phase: …+path_builder`.
|
||||||
|
|
||||||
|
**Algorithmus:** Iterativ Hybrid-Ranking — Schritt 1 aus Zielprofil, Folgeschritte mit Anker = letzte Übung, ohne Duplikate.
|
||||||
|
|
||||||
|
**UI:** `ExerciseProgressionPathBuilder` im Progressionsgraph-Panel — Review, Varianten, `POST …/edges/sequence`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. Phase E — Semantik-Schicht + Pfad-QA (0.8.186) ✅
|
||||||
|
|
||||||
|
### Semantic Brief (`planning_exercise_semantics.py`)
|
||||||
|
|
||||||
|
Parallel zum Katalog-Overlay — **nicht ersetzend**:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `primary_topic` | z. B. `mae geri` |
|
||||||
|
| `must_phrases` / `exclude_phrases` | Phrasen-Match in Titel/Ziel/Varianten |
|
||||||
|
| `development_arc` | einstieg → … → perfektion |
|
||||||
|
| `semantic_strength` | 0–1 — steuert dynamisches Blend im Hybrid-Score |
|
||||||
|
| `retrieval_query` | fokussierte Volltext-Query (nicht ganzer Satz) |
|
||||||
|
|
||||||
|
Optional LLM: Prompt `planning_exercise_query_semantics` (Migration **075**).
|
||||||
|
|
||||||
|
**Hybrid-Score:** neuer Term `w_semantic * semantic_score` — Profil/Volltext werden bei hoher `semantic_strength` relativ abgeschwächt.
|
||||||
|
|
||||||
|
### Pfad-QA (`planning_exercise_path_qa.py`)
|
||||||
|
|
||||||
|
Nach Pfad-Bildung:
|
||||||
|
|
||||||
|
1. **Lücken-Messung** zwischen benachbarten Schritten (Skill-Jaccard + Semantik zum erwarteten Phasen-Segment)
|
||||||
|
2. **Brücken-Übungen** bei großen Lücken (zusätzliche Schritte, markiert `is_bridge`)
|
||||||
|
3. **LLM-QS** (Prompt `planning_exercise_path_qa`): Reihenfolge, Themen-Abdeckung, Empfehlungen
|
||||||
|
|
||||||
|
**API-Erweiterung** `progression-path-suggest`: `include_path_qa`, `include_llm_path_qa` · Response: `semantic_brief_summary`, `path_qa`.
|
||||||
|
|
||||||
|
**Pfad-Schritte:** Semantic Brief + Entwicklungsphase in **allen** Schritten (nicht nur Schritt 1).
|
||||||
|
|
||||||
|
### Phase E2 (0.8.187)
|
||||||
|
|
||||||
|
- **LLM-QS → Neuordnung:** `ordered_step_indices` im Prompt `planning_exercise_path_qa` (Migration **076**)
|
||||||
|
- **KI-Lückenfüller:** `planning_exercise_path_ai_fill.py` — `is_ai_proposal` wenn Bibliothek keine Brücke liefert
|
||||||
|
- Request: `include_path_reorder`, `include_ai_gap_fill`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 23. Phase E3 (0.8.203) ✅
|
||||||
|
|
||||||
|
- Off-Topic aus Pfad entfernen; `gap_fill_offers` mit `goal_for_ai`; voller KI-Call im UI (kein Pre-Vorschlag)
|
||||||
|
- Migration **077** `suggested_new_exercises` im Pfad-QS-Prompt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204–217) ✅
|
||||||
|
|
||||||
|
**Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung.
|
||||||
|
|
||||||
|
**Ist-Stand (vollständig):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||||
|
**Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md`
|
||||||
|
|
||||||
|
| Teil | Modul / API |
|
||||||
|
|------|-------------|
|
||||||
|
| Pipeline | `planning_progression_roadmap.py` (Workflow-lite) |
|
||||||
|
| Match | `planning_exercise_path_builder.py` — `roadmap_first`, `roadmap_override` |
|
||||||
|
| Skills | `planning_skill_expectations.py` — pro Stufe + Pfad |
|
||||||
|
| Gap-KI | `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py` |
|
||||||
|
| Persistenz | `planning_roadmap` JSONB (Migration **088**) |
|
||||||
|
| API | `progression-path-suggest`, `PUT` Graph, `POST …/edges/sequence` |
|
||||||
|
| Prompts | **078/079/087** — Slugs nur in `ai_prompts` |
|
||||||
|
| UI | `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal` |
|
||||||
|
|
||||||
|
**Graph-Bias:** `progression_graph_id` bevorzugt **bestehende Nachfolger** ab Schritt 2 (Gewicht ~4–10 %), baut aber **keinen** Pfad aus vorhandenen Knoten — siehe Ist-Doku §5.
|
||||||
|
|
||||||
|
**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 25. Backlog (offen)
|
||||||
|
|
||||||
|
Siehe priorisierte Liste in **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** §10:
|
||||||
|
|
||||||
|
1. UI-Wizard (Progressionsgraph) — separater Chat
|
||||||
|
2. Graph-Erweiterungsmodus (Start ab Knoten)
|
||||||
|
3. Trainingsplanung Phase G (Gruppenkontext, `planning_skill_expectations`)
|
||||||
|
4. Kontext auf allen Pfad-Schritten in der UI
|
||||||
|
5. Enrichment / Prompt-Feintuning
|
||||||
|
6. Mitai Workflow-Engine (langfristig)
|
||||||
209
.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
Normal file
209
.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
# Planungs-KI — Progressions-Roadmap (Phase F)
|
||||||
|
|
||||||
|
**Version:** 0.1
|
||||||
|
**Datum:** 2026-06-07
|
||||||
|
**Status:** VERBINDLICHE ZIELARCHITEKTUR — **F0–F9 umgesetzt** (0.8.217)
|
||||||
|
**Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse
|
||||||
|
|
||||||
|
**Ist-Stand (Module, API, Graph-Verhalten, Persistenz):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||||
|
|
||||||
|
**Bezüge:**
|
||||||
|
`working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` · `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `technical/AI_PROMPT_TARGET_ARCHITECTURE.md` · `docs/architecture/PLANNING_KI_ROADMAP.md` · `docs/HANDOVER.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Entscheidung (2026-06-07)
|
||||||
|
|
||||||
|
### 1.1 Problem
|
||||||
|
|
||||||
|
Der Pfad-Builder (Phase C3/E) ist **retrieval-first**: Zieltext → N Übungen aus der Bibliothek → QS nachbessern. Das entspricht nicht der menschlichen Planung (Ziel → Roadmap → Stufenspezifikation → Übung).
|
||||||
|
|
||||||
|
### 1.2 Festlegung
|
||||||
|
|
||||||
|
| Thema | Entscheidung |
|
||||||
|
|--------|----------------|
|
||||||
|
| **Progressionsgraph** | **Roadmap-first** — Phasen A→B→C, dann Bibliothek (D), dann Feinausplanung (E) |
|
||||||
|
| **Gruppenanalyse** | **Nicht** in der Graphen-Pipeline — erst bei **Trainingsplanung** (Einheit/Rahmen) |
|
||||||
|
| **Mitai Workflow-Engine** | **Nicht** jetzt portieren — **Workflow-lite** (`PlanningProgressionPipeline`), später workflow-ready |
|
||||||
|
| **Ein Mega-Prompt** | **Verboten** — validierte Artefakte pro Phase |
|
||||||
|
|
||||||
|
### 1.3 Abgrenzung Trainingsplanung
|
||||||
|
|
||||||
|
```
|
||||||
|
Progressionsgraph-Pipeline Trainingsplanungs-Pipeline (später)
|
||||||
|
───────────────────────── ───────────────────────────────────
|
||||||
|
Ziel + N Major Steps Gruppe + Historie + Termin + Rahmen
|
||||||
|
Kein Gruppenkontext Kontext-Pack S0 (AI_PLANNING_KI_MULTISTAGE_FORECAST)
|
||||||
|
Curriculum / Technikpfad Session-Füllung / Reihenfolge / Zeiten
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Menschliches Vorbild → Phasen
|
||||||
|
|
||||||
|
| Mensch | Phase | Output-Artefakt | LLM |
|
||||||
|
|--------|-------|-----------------|-----|
|
||||||
|
| Startpunkt + Zielzustand | **A** Zielanalyse | `goal_analysis` | Optional (klein) |
|
||||||
|
| Zwischenziele, gewichten, auf N reduzieren | **B** Roadmap | `roadmap` (`micro_objectives[]`, `major_steps[N]`) | Ja |
|
||||||
|
| Belastung, Übungstyp, Lernziel je Stufe | **C** Stufenspezifikation | `stage_specs[]` | Teilweise |
|
||||||
|
| Bibliothek / Brücke | **D** Match | `step_matches[]` oder `gaps[]` | Nein (Retrieval) |
|
||||||
|
| Skizze + Feinplan | **E** Übungsentwurf | bestehend `suggestExerciseAi` | On-demand |
|
||||||
|
|
||||||
|
**Phase B** = Kern: 8–12 `micro_objectives` → Konsolidierung → exakt `max_steps` `major_steps`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pipeline-Orchestrator (Workflow-lite)
|
||||||
|
|
||||||
|
Modul: **`backend/planning_progression_roadmap.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
ctx = ProgressionRoadmapContext(goal_query=..., max_steps=N, semantic_brief=...)
|
||||||
|
ctx = phase_a_goal_analysis(ctx) # deterministisch + optional LLM
|
||||||
|
ctx = phase_b_roadmap(ctx) # micro → major
|
||||||
|
ctx = phase_c_stage_specs(ctx) # je major_step
|
||||||
|
# Phase D/E: bestehende path_builder / retrieval / ai_fill — speisen von ctx.major_steps
|
||||||
|
```
|
||||||
|
|
||||||
|
Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-the-loop** (Roadmap-Review vor Übungs-Match).
|
||||||
|
|
||||||
|
**Später:** jede Phase = Workflow-Knoten (Mitai-kompatibel), keine API-Änderung an Artefakten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. JSON-Artefakte (Pydantic)
|
||||||
|
|
||||||
|
### 4.1 `goal_analysis` (Phase A)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"start_assumption": "Grundkenntnisse der Standführung, keine Perfektion",
|
||||||
|
"target_state": "Sicherer, präziser Mae Geri unter Belastung und in Anwendung",
|
||||||
|
"success_criteria": ["saubere Kammerhaltung", "Hüftführung", "Kime am Zielpunkt"],
|
||||||
|
"constraints": { "partner_required": false, "equipment": [] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `roadmap` (Phase B)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"micro_objectives": [
|
||||||
|
{ "id": "m1", "phase": "grundlage", "title": "Stellung und Kammerhaltung", "weight": 0.9, "depends_on": [] },
|
||||||
|
{ "id": "m2", "phase": "vertiefung", "title": "Hüft- und Kniekoordination", "weight": 0.85, "depends_on": ["m1"] }
|
||||||
|
],
|
||||||
|
"major_steps": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"phase": "grundlage",
|
||||||
|
"learning_goal": "Stabile Mae-Geri-Grundstellung",
|
||||||
|
"consolidates": ["m1"],
|
||||||
|
"rationale": "Einstieg ohne Perfektionsdruck"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consolidation_notes": ["Perfektion mit Anwendung zusammengeführt"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 `stage_spec` (Phase C, je Major Step)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"major_step_index": 2,
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["präzision", "koordination"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["reine Kraftübung ohne Technikbezug"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API (schrittweise)
|
||||||
|
|
||||||
|
### 5.1 Erweiterung `POST /api/planning/progression-path-suggest`
|
||||||
|
|
||||||
|
| Feld (neu) | Default | Bedeutung |
|
||||||
|
|------------|---------|-----------|
|
||||||
|
| `roadmap_first` | `false` → später `true` | Roadmap-Pipeline vor Retrieval |
|
||||||
|
| `include_roadmap_preview` | `true` wenn `roadmap_first` | Artefakte A/B/C in Response |
|
||||||
|
|
||||||
|
**Response (neu):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"progression_roadmap": {
|
||||||
|
"goal_analysis": { },
|
||||||
|
"roadmap": { },
|
||||||
|
"stage_specs": [ ],
|
||||||
|
"pipeline_phase": "roadmap_v1"
|
||||||
|
},
|
||||||
|
"steps": [ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Übergangsphase (0.8.204):** `include_roadmap_preview=true` liefert Roadmap **parallel** zum bestehenden retrieval-first Pfad — UI kann Roadmap reviewen, Schritte bleiben vorerst retrieval-basiert.
|
||||||
|
|
||||||
|
**Zielphase (F2):** `roadmap_first=true` — Retrieval pro Major Step aus `stage_specs`, nicht mehr iterativ „beste nächste Übung“.
|
||||||
|
|
||||||
|
### 5.2 Prompt-Slugs — nur in `ai_prompts`, nie im Code
|
||||||
|
|
||||||
|
**Regel:** Prompt-**Texte** leben ausschließlich in der Tabelle `ai_prompts` (Superadmin bearbeitbar, Vorschau, `openrouter_model` pro Zeile). Python referenziert nur **Slugs** (`PROMPT_SLUG_*` in `planning_progression_roadmap.py`). Kein verstecktes Hardcoding von Templates.
|
||||||
|
|
||||||
|
| Slug | Phase | Migration |
|
||||||
|
|------|-------|-----------|
|
||||||
|
| `planning_progression_start_target` | Start/Ziel | **087** |
|
||||||
|
| `planning_progression_goal_analysis` | A | **078** |
|
||||||
|
| `planning_progression_roadmap` | B | **078** |
|
||||||
|
| `planning_progression_stage_spec` | C | **079** |
|
||||||
|
|
||||||
|
**API:** `include_llm_roadmap` (Default `true`) — lädt Prompts via `load_and_render_ai_prompt`. Bei Fehler/kein OpenRouter: **deterministischer Fallback** (kein stilles Versagen).
|
||||||
|
|
||||||
|
**Response:** `prompt_slugs` (genutzte Slugs), `prompt_slug_catalog` (Referenz), `llm_*_applied` Flags.
|
||||||
|
|
||||||
|
**Admin:** Templates unter Kategorie `training` pflegen — siehe `AI_PROMPT_SYSTEM_SPEC.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI-Roadmap
|
||||||
|
|
||||||
|
1. **F1:** Roadmap-Box unter Ziel-Eingabe (Major Steps als Karten, editierbar) — vor Übungsliste
|
||||||
|
2. **F2:** Match-Ergebnis pro Major Step (Bibliothek / Lücke / KI anlegen)
|
||||||
|
3. **F3:** `roadmap_first` als Default im Graph-Builder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Was bewusst nicht in Phase F
|
||||||
|
|
||||||
|
- Gruppen-Historie, Belastungssteuerung der Gruppe
|
||||||
|
- Mitai `workflow_engine` Port
|
||||||
|
- Vollautomatisches Speichern ohne Trainer-Review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementierungsstände
|
||||||
|
|
||||||
|
| ID | Inhalt | Status |
|
||||||
|
|----|--------|--------|
|
||||||
|
| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | ✅ 0.8.204 |
|
||||||
|
| **F1** | `include_roadmap_preview` in API + deterministische A/B | ✅ 0.8.204 |
|
||||||
|
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | ✅ 0.8.205 |
|
||||||
|
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206–209 |
|
||||||
|
| **F4** | UI Roadmap-Review + `roadmap_override` | ✅ 0.8.207 |
|
||||||
|
| **F5** | Start/Ziel strukturiert + Prompt **087** + Zwei-Schritt-UI | ✅ 0.8.210–214 |
|
||||||
|
| **F6** | Gap-Prep + `planning_context` an Übungs-KI | ✅ 0.8.212–214 |
|
||||||
|
| **F7** | `planning_skill_expectations` | ✅ 0.8.215–216 |
|
||||||
|
| **F8** | Editierbare `stage_specs` in UI | ✅ 0.8.216 |
|
||||||
|
| **F9** | `planning_roadmap` JSONB (Migration **088**) | ✅ 0.8.217 |
|
||||||
|
| **G** | Trainingsplanung: eigene Pipeline + Workflow-Engine | 🔲 |
|
||||||
|
|
||||||
|
Details: `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Changelog
|
||||||
|
|
||||||
|
- **2026-05-22:** Ist-Stand F5–F9 dokumentiert; Verweis auf `PLANNING_PROGRESSION_GRAPH_KI.md`.
|
||||||
|
- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite.
|
||||||
81
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
81
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Progressionsgraph — Slot-Editor (Phase B)
|
||||||
|
|
||||||
|
**Stand:** 2026-06-10 · **Status:** In Umsetzung
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein Progressionsgraph = **ein linearer Hauptpfad** (Roadmap = strukturgebend). Jeder **Major Step** ist ein **Slot** mit:
|
||||||
|
|
||||||
|
- **primary** — Hauptübung des Slots (Pfadknoten)
|
||||||
|
- **siblings** — 0..n Schwestern (gleiche Stufe, `edge_type: sibling`)
|
||||||
|
|
||||||
|
KI-Entwürfe und Bibliotheksübungen leben **im selben Slot-Modell**, ohne sofortige Übungsanlage.
|
||||||
|
|
||||||
|
## Slot-Zustände (`kind`)
|
||||||
|
|
||||||
|
| kind | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `empty` | Noch keine Übung |
|
||||||
|
| `library` | `exercise_id` (+ optional `variant_id`) |
|
||||||
|
| `proposal` | KI-Entwurf (`ai_suggestion`, kein `exercise_id`) |
|
||||||
|
|
||||||
|
## Kanten
|
||||||
|
|
||||||
|
- `primary(n) → primary(n+1)` — `next_exercise` (nur befüllte Primärkette, lückenlos verbunden)
|
||||||
|
- `primary ↔ sibling` — `sibling` (pro Slot)
|
||||||
|
|
||||||
|
Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgenden befüllten Primär-Slots.
|
||||||
|
|
||||||
|
## Editor-Zustand (`ProgressionGraphDraft`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
goalQuery, startSituation, targetState, roadmapNotes, maxSteps,
|
||||||
|
majorSteps: MajorStep[],
|
||||||
|
slots: Slot[], // index = major_step_index
|
||||||
|
pathSkillExpectations?,
|
||||||
|
lastFindings?, // path_qa-Snapshot
|
||||||
|
dirty: boolean,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
|
||||||
|
|
||||||
|
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`.
|
||||||
|
|
||||||
|
## Findings-Panel
|
||||||
|
|
||||||
|
Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …).
|
||||||
|
|
||||||
|
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
|
||||||
|
|
||||||
|
Persistenz: `planning_roadmap.last_findings`.
|
||||||
|
|
||||||
|
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
|
||||||
|
|
||||||
|
Zusätzlich optional:
|
||||||
|
|
||||||
|
- `slot_contents[]` — `{ major_step_index, primary, siblings[] }`
|
||||||
|
- `last_findings` — letzter `path_qa`-Snapshot
|
||||||
|
|
||||||
|
## UI (konsolidiert)
|
||||||
|
|
||||||
|
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
|
||||||
|
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
|
||||||
|
- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
|
||||||
|
- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
|
||||||
|
|
||||||
|
## Ersetzt (Legacy, nicht mehr im Panel)
|
||||||
|
|
||||||
|
- `ExerciseProgressionPathBuilder` · `ProgressionChainEditor` — Code bleibt vorerst, nicht eingebunden
|
||||||
|
|
||||||
|
## Implementierungsreihenfolge
|
||||||
|
|
||||||
|
| ID | Inhalt |
|
||||||
|
|----|--------|
|
||||||
|
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten |
|
||||||
|
| B.1 | Slot-Karten, Bibliothek + Entwurf |
|
||||||
|
| B.2 | Findings-Panel + `evaluate_only` |
|
||||||
|
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ |
|
||||||
|
| B.4 | Route + Panel vereinfachen |
|
||||||
|
| B.5 | `last_findings` + Phase-C-Vorbereitung |
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, inkl. **§ 10.2.1**, **§ 10.4 Coaching-Stufen**, **Anhang A** Implementierungsabgleich — Drift-Schutz)
|
**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, inkl. **§ 10.2.1**, **§ 10.4 Coaching-Stufen**, **Anhang A** Implementierungsabgleich — Drift-Schutz)
|
||||||
**Technische Entwurfsspezifikation:** `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`
|
**Technische Entwurfsspezifikation:** `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`
|
||||||
**Stand dieses Dokuments:** 2026-05-12 (Abgleich mit Code App **0.8.110**, siehe `backend/version.py`)
|
**Stand dieses Dokuments:** 2026-05-20 (Abgleich mit Code, siehe `backend/version.py`)
|
||||||
|
|
||||||
## Ziele
|
## Ziele
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z
|
||||||
|
|
||||||
| Phase | Inhalt | Status |
|
| Phase | Inhalt | Status |
|
||||||
|-------|--------|--------|
|
|-------|--------|--------|
|
||||||
| **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“ | **umgesetzt (MVP Schritt 1)** |
|
| **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“; **Ergänzung 2026-05-20:** Fähigkeiten-Profil + Listen-Filter (Peer-Vergleich nur unter Modulen) — `technical/SKILL_SCORING_SPEC.md` | **umgesetzt (MVP Schritt 1)** |
|
||||||
| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise** — wie links; zusätzlich **057** `planning_method_profile`; Planungs-Merge Client (`effectiveComboMethodProfile`); Archetypen weiterhin **nur Code-Konstanten** (kein Admin) | **Offen:** Archetyp-Admin-UI; Profil↔Archetyp-Validierung Backend; „alle Slots vorbelegen“ / Presets (siehe Fachspez **§ 10.6**); Haupt-/Nebenmethoden an Kombi wo Spec es verlangt |
|
| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise** — wie links; zusätzlich **057** `planning_method_profile`; Planungs-Merge Client (`effectiveComboMethodProfile`); Archetypen weiterhin **nur Code-Konstanten** (kein Admin) | **Offen:** Archetyp-Admin-UI; Profil↔Archetyp-Validierung Backend; „alle Slots vorbelegen“ / Presets (siehe Fachspez **§ 10.6**); Haupt-/Nebenmethoden an Kombi wo Spec es verlangt |
|
||||||
| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant |
|
| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant |
|
||||||
| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** — Merge Katalog+Planung; `CombinationPlanBracket` in Peek/Run; globale Profilzahlen mit Labels (`describeGlobalComboProfile`); Stations-/Timing-Zusammenfassung inkl. Wdh.-Hinweise. **Stufe B/C** — **offen** (§ 10.6, Anhang A) |
|
| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** — Merge Katalog+Planung; `CombinationPlanBracket` in Peek/Run; globale Profilzahlen mit Labels (`describeGlobalComboProfile`); Stations-/Timing-Zusammenfassung inkl. Wdh.-Hinweise. **Stufe B/C** — **offen** (§ 10.6, Anhang A) |
|
||||||
|
|
|
||||||
10
.env.example
10
.env.example
|
|
@ -35,6 +35,16 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
||||||
OPENROUTER_API_KEY=your_api_key_here
|
OPENROUTER_API_KEY=your_api_key_here
|
||||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
||||||
|
|
||||||
|
# Vereins-Kontingente hart blockieren (KI-Kosten!). Nur 1, true oder yes aktivieren.
|
||||||
|
# Nach Änderung: docker compose -f docker-compose.dev-env.yml up -d backend
|
||||||
|
CLUB_FEATURE_ENFORCE=1
|
||||||
|
# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
|
||||||
|
# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).
|
||||||
|
|
||||||
|
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
|
||||||
|
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).
|
||||||
|
# SHINKAN_AI_DEBUG=1
|
||||||
|
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=noreply@jinkendo.de
|
SMTP_USER=noreply@jinkendo.de
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ jobs:
|
||||||
docker compose -f docker-compose.dev-env.yml build --no-cache
|
docker compose -f docker-compose.dev-env.yml build --no-cache
|
||||||
docker compose -f docker-compose.dev-env.yml up -d
|
docker compose -f docker-compose.dev-env.yml up -d
|
||||||
sleep 5
|
sleep 5
|
||||||
curl -sf http://localhost:8098/api/version && echo "✓ DEV API healthy"
|
if ! curl -sf http://localhost:8098/api/version; then
|
||||||
|
echo "✗ DEV API nicht erreichbar — Backend-Logs (Migration/Startup):"
|
||||||
|
docker compose -f docker-compose.dev-env.yml logs backend --tail 120 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ DEV API healthy"
|
||||||
curl -sf http://localhost:3098/api/version && echo "✓ DEV über Frontend-Nginx (wie Browser) healthy"
|
curl -sf http://localhost:3098/api/version && echo "✓ DEV über Frontend-Nginx (wie Browser) healthy"
|
||||||
echo "=== Shinkan DEV Deploy complete ==="
|
echo "=== Shinkan DEV Deploy complete ==="
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,29 @@
|
||||||
name: Test Suite
|
name: Test Suite
|
||||||
|
|
||||||
|
# develop: push/PR → Tests gegen Dev (parallel oder vor Deploy Development).
|
||||||
|
# main: kein push/PR-Trigger — vermeidet doppelten Dev-Lauf beim Merge develop→main;
|
||||||
|
# Prod-Tests nur via workflow_run nach erfolgreichem Deploy Production.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [develop]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, develop]
|
branches: [develop]
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Deploy Development", "Deploy Production"]
|
workflows: ["Deploy Development", "Deploy Production"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Wie Mitai-Jinkendo: pytest im laufenden backend-Container (Python aus Image, gleiche DB wie Deploy).
|
# Pytest im laufenden backend-Container; ACCESS_LAYER + TRAINING_PLANNING Integration gegen dieselbe PostgreSQL wie Deploy (Schema via Container-Start migriert).
|
||||||
pytest-backend:
|
pytest-backend:
|
||||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Backend pytest im deployten Container
|
- name: Backend pytest im deployten Container
|
||||||
run: |
|
run: |
|
||||||
|
set -e
|
||||||
EVENT_NAME="${{ github.event_name }}"
|
EVENT_NAME="${{ github.event_name }}"
|
||||||
REF_NAME="${{ github.ref_name }}"
|
REF_NAME="${{ github.ref_name }}"
|
||||||
|
BASE_REF="${{ github.base_ref }}"
|
||||||
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||||
APP_DIR="/home/lars/docker/shinkan"
|
APP_DIR="/home/lars/docker/shinkan"
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
|
@ -28,18 +33,33 @@ jobs:
|
||||||
APP_DIR="/home/lars/docker/shinkan-dev"
|
APP_DIR="/home/lars/docker/shinkan-dev"
|
||||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
COMPOSE_FILE="docker-compose.dev-env.yml"
|
||||||
fi
|
fi
|
||||||
elif [ "$REF_NAME" = "develop" ]; then
|
elif [ "$REF_NAME" = "develop" ] || [ "$BASE_REF" = "develop" ]; then
|
||||||
APP_DIR="/home/lars/docker/shinkan-dev"
|
APP_DIR="/home/lars/docker/shinkan-dev"
|
||||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
COMPOSE_FILE="docker-compose.dev-env.yml"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
|
echo "Warte auf stabilen backend-Container …"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then
|
||||||
|
echo "Backend bereit (Versuch $i)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
echo "Timeout: backend-Container nicht bereit"
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps || true
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs backend --tail 80 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
||||||
pip install -r /app/requirements-dev.txt &&
|
pip install -r /app/requirements-dev.txt &&
|
||||||
cd /app &&
|
cd /app &&
|
||||||
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
|
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
|
||||||
python scripts/security_release_checks.py &&
|
python scripts/security_release_checks.py &&
|
||||||
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
|
ACCESS_LAYER_INTEGRATION=1 TRAINING_PLANNING_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
|
||||||
"
|
"
|
||||||
|
|
||||||
lint-backend:
|
lint-backend:
|
||||||
|
|
@ -88,6 +108,90 @@ jobs:
|
||||||
npm run build
|
npm run build
|
||||||
echo "✓ Frontend build OK"
|
echo "✓ Frontend build OK"
|
||||||
|
|
||||||
|
# Phase-0 Lastsmoke: nur k6 — eigener Job (kein Node/Playwright), klare CI-Zuordnung.
|
||||||
|
k6-health-baseline:
|
||||||
|
name: k6 /health Baseline
|
||||||
|
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
E2E_TARGET_URL: https://dev.shinkan.jinkendo.de
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: E2E-Ziel wählen (Dev über Proxy vs. Production)
|
||||||
|
id: e2e
|
||||||
|
run: |
|
||||||
|
EVENT="${{ github.event_name }}"
|
||||||
|
WF_NAME="${{ github.event.workflow_run.name }}"
|
||||||
|
DEV_BASE="${{ env.E2E_TARGET_URL }}"
|
||||||
|
if [ "$EVENT" = "workflow_run" ] && [ "$WF_NAME" = "Deploy Production" ]; then
|
||||||
|
echo "mode=prod" >> $GITHUB_OUTPUT
|
||||||
|
echo "base_url=https://shinkan.jinkendo.de" >> $GITHUB_OUTPUT
|
||||||
|
echo "→ k6 gegen Prod-Basis."
|
||||||
|
else
|
||||||
|
echo "mode=dev" >> $GITHUB_OUTPUT
|
||||||
|
echo "base_url=${DEV_BASE}" >> $GITHUB_OUTPUT
|
||||||
|
echo "→ k6 gegen Dev (${DEV_BASE})."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Dev /health abwarten
|
||||||
|
if: ${{ steps.e2e.outputs.mode == 'dev' }}
|
||||||
|
run: |
|
||||||
|
BASE="${{ steps.e2e.outputs.base_url }}"
|
||||||
|
echo "Warte auf $BASE/health …"
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
if curl -sf "$BASE/health" >/dev/null 2>&1; then
|
||||||
|
echo "Health OK (Versuch $i)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Timeout: Dev /health nicht erreichbar — Deploy / DNS / Firewall prüfen."
|
||||||
|
curl -v "$BASE/health" || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Prod /health abwarten
|
||||||
|
if: ${{ steps.e2e.outputs.mode == 'prod' }}
|
||||||
|
run: |
|
||||||
|
BASE="${{ steps.e2e.outputs.base_url }}"
|
||||||
|
echo "Warte auf $BASE/health …"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf "$BASE/health" >/dev/null 2>&1; then
|
||||||
|
echo "Health OK (Versuch $i)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "Timeout: Prod /health nicht erreichbar"
|
||||||
|
curl -v "$BASE/health" || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Install k6
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
K6_VER="v0.55.0"
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64) K6_ARCH=amd64 ;;
|
||||||
|
aarch64|arm64) K6_ARCH=arm64 ;;
|
||||||
|
*) echo "k6: unbekannte Architektur: $ARCH"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "Installing k6 ${K6_VER} linux-${K6_ARCH}"
|
||||||
|
curl -sSL "https://github.com/grafana/k6/releases/download/${K6_VER}/k6-${K6_VER}-linux-${K6_ARCH}.tar.gz" -o /tmp/k6.tgz
|
||||||
|
tar -xzf /tmp/k6.tgz -C /tmp
|
||||||
|
sudo mv "/tmp/k6-${K6_VER}-linux-${K6_ARCH}/k6" /usr/local/bin/k6
|
||||||
|
k6 version
|
||||||
|
|
||||||
|
- name: k6 Health-Baseline (parallele /health)
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ steps.e2e.outputs.base_url }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
echo "k6 gegen BASE_URL=$BASE_URL"
|
||||||
|
k6 run scripts/load/k6-health-baseline.js
|
||||||
|
echo "✓ k6 Health-Baseline passed"
|
||||||
|
|
||||||
playwright-tests:
|
playwright-tests:
|
||||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,13 @@
|
||||||
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
|
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
|
||||||
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
|
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
|
||||||
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
||||||
|
> | Fähigkeiten-Scoring (Planungs-Bausteine) | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
|
||||||
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
|
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
|
||||||
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
||||||
|
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
|
||||||
|
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
|
||||||
|
> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
|
||||||
|
> | Planungs-KI Progressionsgraph (Ist-Stand) | **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** · Spec **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
|
||||||
|
|
||||||
## Projekt-Übersicht
|
## Projekt-Übersicht
|
||||||
|
|
||||||
|
|
@ -84,10 +89,11 @@ frontend/src/
|
||||||
|
|
||||||
**Siehe:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) und `.claude/docs/PROJECT_STATUS.md`.
|
**Siehe:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) und `.claude/docs/PROJECT_STATUS.md`.
|
||||||
|
|
||||||
Kurz (Stand 2026-05-12): App **0.8.96**, DB‑Schema‑Version siehe **`backend/version.py`**; Kern: Übungen, Varianten, **Medien-Archiv & Bibliothek (`/media`)**, **Inline-Medien im Rich-Text**, **Inhaltsmeldungen (P-13)** im Posteingang, Mandanten-Sync aktiver Verein, Planung mit Sektionen, **Trainingsrahmen Bibliothek + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — Details `PROJECT_STATUS.md`, `docs/HANDOVER.md`, Nutzerüberblick **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**, `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` (Abschnitt 11 umgesetzt).
|
Kurz (Stand 2026-05-14): App- und DB-Version siehe **`backend/version.py`**; Kern: Übungen, Varianten, **Medien-Archiv & Bibliothek (`/media`)**, **Inline-Medien im Rich-Text**, **Inhaltsmeldungen (P-13)** im Posteingang, Mandanten-Sync aktiver Verein, Planung mit **Phasen & parallelen Streams (Breakout, 063)**, **Trainingsrahmen Bibliothek + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — Details `PROJECT_STATUS.md`, `docs/HANDOVER.md`, Nutzerüberblick **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**, `PARALLEL_TRAINING_STREAMS_SPEC.md`, `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` (Abschnitt 11 umgesetzt).
|
||||||
|
|
||||||
### Log (Auszug)
|
### Log (Auszug)
|
||||||
|
|
||||||
|
- 2026-05-20: **Fähigkeiten-Scoring Phase 3** — gewichtete Profile für Module/Rahmen/Pfade; Peer-Vergleich getrennt nach Artefakttyp; Listen-Filter + Discovery — siehe `SKILL_SCORING_SPEC.md`, `docs/HANDOVER.md` §2.6, `FEATURES_DELIVERED_2026-Q2.md` §15.
|
||||||
- 2026-05-07: **Medien** — zentrales Archiv (`media_assets`), Bibliothek-UI, Lifecycle/Papierkorb, `from-asset`, Speicherpfade `library/…`, Governance `official`/Copyright; **0.8.59** aktiver Verein UI/API-Sync — siehe `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12, `docs/HANDOVER.md`.
|
- 2026-05-07: **Medien** — zentrales Archiv (`media_assets`), Bibliothek-UI, Lifecycle/Papierkorb, `from-asset`, Speicherpfade `library/…`, Governance `official`/Copyright; **0.8.59** aktiver Verein UI/API-Sync — siehe `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12, `docs/HANDOVER.md`.
|
||||||
- 2026-05-05: Rahmen nur Bibliothek (**036**), Slot‑Ablauf = `training_units` + Sektionen (**037**), `POST /api/training-units/from-framework-slot`, keine `training_framework_slot_exercises` mehr — siehe `DATABASE_SCHEMA.md` / `FEATURES_DELIVERED_2026-Q2.md`.
|
- 2026-05-05: Rahmen nur Bibliothek (**036**), Slot‑Ablauf = `training_units` + Sektionen (**037**), `POST /api/training-units/from-framework-slot`, keine `training_framework_slot_exercises` mehr — siehe `DATABASE_SCHEMA.md` / `FEATURES_DELIVERED_2026-Q2.md`.
|
||||||
- 2026-04-27: Übungsvarianten API/UI, Migration 030, Listen-UX-Suche, Admin-Upload-Limits — siehe `PROJECT_STATUS.md` und `docs/library/FEATURES_DELIVERED_2026-Q2.md`.
|
- 2026-04-27: Übungsvarianten API/UI, Migration 030, Listen-UX-Suche, Admin-Upload-Limits — siehe `PROJECT_STATUS.md` und `docs/library/FEATURES_DELIVERED_2026-Q2.md`.
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,16 @@ FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies (tzdata für zoneinfo/ZoneInfo unter Linux)
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
|
tzdata \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements and install dependencies
|
# Copy requirements and install dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
ENV PIP_DEFAULT_TIMEOUT=120
|
||||||
|
RUN pip install --no-cache-dir --retries 5 -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
|
||||||
77
backend/account_lifecycle.py
Normal file
77
backend/account_lifecycle.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""
|
||||||
|
Account-Lifecycle (CAPABILITY_CATALOG.v1.md §3, M3 C0).
|
||||||
|
|
||||||
|
Zustände: unverified → verified_pending_club → active_member; platform_admin separat.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from club_tenancy import is_platform_admin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
_ACCOUNT_STATE_RANK = {
|
||||||
|
"unverified": 1,
|
||||||
|
"verified_pending_club": 2,
|
||||||
|
"active_member": 3,
|
||||||
|
"platform_admin": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_account_state(
|
||||||
|
*,
|
||||||
|
email_verified: bool,
|
||||||
|
global_role: str,
|
||||||
|
has_active_membership: bool,
|
||||||
|
) -> str:
|
||||||
|
"""Ermittelt account_state für ein Profil."""
|
||||||
|
if is_platform_admin(global_role):
|
||||||
|
return "platform_admin"
|
||||||
|
if not email_verified:
|
||||||
|
return "unverified"
|
||||||
|
if not has_active_membership:
|
||||||
|
return "verified_pending_club"
|
||||||
|
return "active_member"
|
||||||
|
|
||||||
|
|
||||||
|
def account_state_satisfies(current: str, required: str) -> bool:
|
||||||
|
"""True wenn current mindestens required ist."""
|
||||||
|
cur = _ACCOUNT_STATE_RANK.get(current, 0)
|
||||||
|
req = _ACCOUNT_STATE_RANK.get(required, 99)
|
||||||
|
if current == "platform_admin":
|
||||||
|
return True
|
||||||
|
return cur >= req
|
||||||
|
|
||||||
|
|
||||||
|
def account_gate_enforcement_enabled() -> bool:
|
||||||
|
"""Account-Gates aktiv (Default an — nur wenige Endpoints in M3)."""
|
||||||
|
return os.getenv("ACCOUNT_GATE_ENFORCE", "1").strip() == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def assert_min_account_state(
|
||||||
|
tenant: "TenantContext",
|
||||||
|
min_state: str,
|
||||||
|
*,
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Prüft Mindest-Account-Status. Wirft 403 wenn ACCOUNT_GATE_ENFORCE=1 (Default).
|
||||||
|
"""
|
||||||
|
current = getattr(tenant, "account_state", "active_member")
|
||||||
|
ok = account_state_satisfies(current, min_state)
|
||||||
|
if ok:
|
||||||
|
return
|
||||||
|
if not account_gate_enforcement_enabled():
|
||||||
|
return
|
||||||
|
detail = (
|
||||||
|
f"Account-Status „{current}“ reicht nicht für diese Aktion "
|
||||||
|
f"(erforderlich: {min_state})."
|
||||||
|
)
|
||||||
|
if endpoint:
|
||||||
|
detail = f"{detail} ({endpoint})"
|
||||||
|
raise HTTPException(status_code=403, detail=detail)
|
||||||
178
backend/account_onboarding_gate.py
Normal file
178
backend/account_onboarding_gate.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""
|
||||||
|
API-Gates für Onboarding (Phase A — MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1).
|
||||||
|
|
||||||
|
Blockiert Domänen-APIs für unverified / verified_pending_club vor dem Router.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from account_lifecycle import resolve_account_state
|
||||||
|
from club_tenancy import memberships_with_roles
|
||||||
|
|
||||||
|
# Öffentlich ohne Session
|
||||||
|
PUBLIC_API_PREFIXES = (
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/register",
|
||||||
|
"/api/auth/forgot-password",
|
||||||
|
"/api/auth/reset-password",
|
||||||
|
"/api/auth/verify/",
|
||||||
|
"/api/legal-documents/",
|
||||||
|
"/api/clubs/public-directory",
|
||||||
|
"/api/version",
|
||||||
|
"/api/health/",
|
||||||
|
"/health",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mit Session, unabhängig vom account_state (Logout, Profil lesen, …)
|
||||||
|
AUTH_INFRA_PREFIXES = (
|
||||||
|
"/api/auth/logout",
|
||||||
|
"/api/auth/me",
|
||||||
|
"/api/auth/status",
|
||||||
|
"/api/auth/pin",
|
||||||
|
"/api/auth/resend-verification",
|
||||||
|
"/api/profiles/me",
|
||||||
|
"/api/me/entitlements",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zusätzlich für verified_pending_club (Verein bewerben)
|
||||||
|
PENDING_CLUB_PREFIXES = (
|
||||||
|
"/api/me/club-join-requests",
|
||||||
|
"/api/me/club-creation-requests",
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def api_onboarding_gate_enabled() -> bool:
|
||||||
|
"""Produktions-Gate aktiv (ACCOUNT_GATE_API_ENFORCE=0 zum Abschalten)."""
|
||||||
|
return os.getenv("ACCOUNT_GATE_API_ENFORCE", "1").strip() == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _middleware_db_lookup_enabled() -> bool:
|
||||||
|
"""
|
||||||
|
Middleware-Session-Lookup nur mit echter DB (nicht in pytest TestClient ohne Postgres).
|
||||||
|
"""
|
||||||
|
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
|
||||||
|
return False
|
||||||
|
if os.getenv("PYTEST_CURRENT_TEST"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_api_path(path: str) -> str:
|
||||||
|
p = (path or "").split("?", 1)[0].strip()
|
||||||
|
if not p.startswith("/"):
|
||||||
|
p = "/" + p
|
||||||
|
if len(p) > 1 and p.endswith("/"):
|
||||||
|
p = p[:-1]
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_api_path(path: str) -> bool:
|
||||||
|
p = normalize_api_path(path)
|
||||||
|
return any(p == pref or p.startswith(pref) for pref in PUBLIC_API_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_allowed_for_state(path: str, method: str, account_state: str, profile_id: int) -> bool:
|
||||||
|
p = normalize_api_path(path)
|
||||||
|
m = (method or "GET").upper()
|
||||||
|
|
||||||
|
for pref in AUTH_INFRA_PREFIXES:
|
||||||
|
if p == pref or p.startswith(pref + "/"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
match = _PROFILE_MUTATION_RE.match(p)
|
||||||
|
if match and m in ("PUT", "PATCH") and int(match.group(1)) == int(profile_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if account_state == "unverified":
|
||||||
|
return False
|
||||||
|
|
||||||
|
if account_state == "verified_pending_club":
|
||||||
|
for pref in PENDING_CLUB_PREFIXES:
|
||||||
|
if p == pref or p.startswith(pref + "/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_account_state_for_token(cur, session_row: dict) -> str:
|
||||||
|
profile_id = int(session_row["profile_id"])
|
||||||
|
role = (session_row.get("role") or "").lower()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
prof = cur.fetchone()
|
||||||
|
email_verified = bool(prof.get("email_verified")) if prof else False
|
||||||
|
memberships = memberships_with_roles(cur, profile_id, active_only=True)
|
||||||
|
has_active = len(memberships) > 0
|
||||||
|
return resolve_account_state(
|
||||||
|
email_verified=email_verified,
|
||||||
|
global_role=role,
|
||||||
|
has_active_membership=has_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_api_onboarding_gate(
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
method: str,
|
||||||
|
profile_id: int,
|
||||||
|
account_state: str,
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Returns (allowed, reason).
|
||||||
|
active_member / platform_admin → immer erlaubt (Domain).
|
||||||
|
"""
|
||||||
|
if not api_onboarding_gate_enabled():
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if account_state in ("active_member", "platform_admin"):
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if _path_allowed_for_state(path, method, account_state, profile_id):
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
return False, f"account_state_{account_state}"
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_request_gate(token: Optional[str], path: str, method: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Vollständige Prüfung inkl. Session-Lookup.
|
||||||
|
Returns: allowed, reason, account_state (für Logging)
|
||||||
|
"""
|
||||||
|
if not api_onboarding_gate_enabled() or not _middleware_db_lookup_enabled():
|
||||||
|
return True, None, None
|
||||||
|
|
||||||
|
p = normalize_api_path(path)
|
||||||
|
if not p.startswith("/api/"):
|
||||||
|
return True, None, None
|
||||||
|
if is_public_api_path(p):
|
||||||
|
return True, None, None
|
||||||
|
if not token:
|
||||||
|
return True, None, None
|
||||||
|
|
||||||
|
from auth import get_session
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
|
||||||
|
session = get_session(token)
|
||||||
|
if not session:
|
||||||
|
return True, None, None
|
||||||
|
|
||||||
|
profile_id = int(session["profile_id"])
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
account_state = resolve_account_state_for_token(cur, session)
|
||||||
|
|
||||||
|
allowed, reason = check_api_onboarding_gate(
|
||||||
|
path=p,
|
||||||
|
method=method,
|
||||||
|
profile_id=profile_id,
|
||||||
|
account_state=account_state,
|
||||||
|
)
|
||||||
|
return allowed, reason, account_state
|
||||||
108
backend/ai_prompt_context.py
Normal file
108
backend/ai_prompt_context.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""
|
||||||
|
Gemeinsame Pydantic-Modelle fuer Uebungs-KI-Kontext (Formularfelder → Prompt-Platzhalter).
|
||||||
|
|
||||||
|
Keine Imports aus exercise_ai — vermeidet Zirkelimporte mit ai_prompt_job / exercise_ai.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseFormAiFocusRow(BaseModel):
|
||||||
|
"""Fokusbereich fuer Skill-Retrieval (ai_skill_retrieval_profiles)."""
|
||||||
|
|
||||||
|
focus_area_id: int = Field(..., ge=1)
|
||||||
|
is_primary: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseFormAiPromptContext(BaseModel):
|
||||||
|
"""
|
||||||
|
Inhaltliche Eingabe fuer Uebungs-Prompts (Kurzfassung / Skills / Anleitung).
|
||||||
|
|
||||||
|
Wird genutzt von Admin-Prompt-Vorschau und POST /exercises/ai/suggest (via Mapping).
|
||||||
|
"""
|
||||||
|
|
||||||
|
title: Optional[str] = ""
|
||||||
|
goal: Optional[str] = None
|
||||||
|
execution: Optional[str] = None
|
||||||
|
preparation: Optional[str] = None
|
||||||
|
trainer_notes: Optional[str] = None
|
||||||
|
focus_hint: Optional[str] = None
|
||||||
|
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None
|
||||||
|
planning_context: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]:
|
||||||
|
if not self.focus_areas_context:
|
||||||
|
return None
|
||||||
|
return [(int(x.focus_area_id), bool(x.is_primary)) for x in self.focus_areas_context]
|
||||||
|
|
||||||
|
def has_instruction_source_text(self) -> bool:
|
||||||
|
"""Mindestens ein Anleitungsfeld oder Titel fuer instruction_rewrite."""
|
||||||
|
if (self.title or "").strip():
|
||||||
|
return True
|
||||||
|
for val in (self.goal, self.execution, self.preparation, self.trainer_notes):
|
||||||
|
if val and str(val).strip():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_suggest(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
goal: Optional[str] = None,
|
||||||
|
execution: Optional[str] = None,
|
||||||
|
preparation: Optional[str] = None,
|
||||||
|
trainer_notes: Optional[str] = None,
|
||||||
|
focus_area_hint: Optional[str] = None,
|
||||||
|
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
|
||||||
|
planning_context: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> ExerciseFormAiPromptContext:
|
||||||
|
"""Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint)."""
|
||||||
|
hint = (focus_area_hint or "").strip() or None
|
||||||
|
return cls(
|
||||||
|
title=(title or "").strip(),
|
||||||
|
goal=goal,
|
||||||
|
execution=execution,
|
||||||
|
preparation=preparation,
|
||||||
|
trainer_notes=trainer_notes,
|
||||||
|
focus_hint=hint,
|
||||||
|
focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
|
||||||
|
planning_context=dict(planning_context) if planning_context else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_focus_tuples(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
goal: Optional[str] = None,
|
||||||
|
execution: Optional[str] = None,
|
||||||
|
preparation: Optional[str] = None,
|
||||||
|
trainer_notes: Optional[str] = None,
|
||||||
|
focus_hint: Optional[str] = None,
|
||||||
|
focus_tuples: Optional[Sequence[Tuple[int, bool]]] = None,
|
||||||
|
) -> ExerciseFormAiPromptContext:
|
||||||
|
rows = None
|
||||||
|
if focus_tuples:
|
||||||
|
rows = [
|
||||||
|
ExerciseFormAiFocusRow(focus_area_id=int(fid), is_primary=bool(prim))
|
||||||
|
for fid, prim in focus_tuples
|
||||||
|
]
|
||||||
|
return cls(
|
||||||
|
title=(title or "").strip(),
|
||||||
|
goal=goal,
|
||||||
|
execution=execution,
|
||||||
|
preparation=preparation,
|
||||||
|
trainer_notes=trainer_notes,
|
||||||
|
focus_hint=(focus_hint or "").strip() or None,
|
||||||
|
focus_areas_context=rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExerciseFormAiFocusRow",
|
||||||
|
"ExerciseFormAiPromptContext",
|
||||||
|
]
|
||||||
59
backend/ai_prompt_job.py
Normal file
59
backend/ai_prompt_job.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""
|
||||||
|
KI-Prompt Jobs: Resolver + oeffentliche Fassade fuer Uebungs-KI-Aufrufe.
|
||||||
|
|
||||||
|
Importiert exercise_ai fuer Platzhalter-Builder und OpenRouter-Orchestrierung.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
|
||||||
|
from exercise_ai import build_exercise_placeholder_variables
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptContext) -> Dict[str, str]:
|
||||||
|
"""Baut die Mustache-Map fuer exercise_summary / exercise_skill_suggestions."""
|
||||||
|
return build_exercise_placeholder_variables(
|
||||||
|
cur,
|
||||||
|
slug=slug,
|
||||||
|
title=(ctx.title or "").strip(),
|
||||||
|
goal=ctx.goal,
|
||||||
|
execution=ctx.execution,
|
||||||
|
focus_area_hint=ctx.focus_hint,
|
||||||
|
focus_areas_context=ctx.focus_area_tuples(),
|
||||||
|
preparation=ctx.preparation,
|
||||||
|
trainer_notes=ctx.trainer_notes,
|
||||||
|
planning_context=ctx.planning_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_exercise_form_ai_suggestion(
|
||||||
|
cur,
|
||||||
|
ctx: ExerciseFormAiPromptContext,
|
||||||
|
*,
|
||||||
|
want_summary: bool,
|
||||||
|
want_skills: bool,
|
||||||
|
want_instructions: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fuehrt Uebungs-KI aus (OpenRouter) — ein Einstieg fuer Router und kuenftige Jobs.
|
||||||
|
|
||||||
|
``ctx`` = Formularinhalt; ``want_*`` = welche Prompt-Slugs angefragt werden.
|
||||||
|
"""
|
||||||
|
from exercise_ai import run_exercise_ai_suggestion
|
||||||
|
|
||||||
|
return run_exercise_ai_suggestion(
|
||||||
|
cur,
|
||||||
|
form_ctx=ctx,
|
||||||
|
want_summary=want_summary,
|
||||||
|
want_skills=want_skills,
|
||||||
|
want_instructions=want_instructions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExerciseFormAiFocusRow",
|
||||||
|
"ExerciseFormAiPromptContext",
|
||||||
|
"resolve_exercise_form_variables",
|
||||||
|
"run_exercise_form_ai_suggestion",
|
||||||
|
]
|
||||||
125
backend/ai_prompt_runtime.py
Normal file
125
backend/ai_prompt_runtime.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""
|
||||||
|
Gemeinsame KI-Prompt-Laufzeit (Shinkan): DB-Lesezugriff ai_prompts + Kontext-Arten.
|
||||||
|
|
||||||
|
Bleibt ohne Import von exercise_ai (kein Zirkel). Domänen wie exercise_ai nutzen
|
||||||
|
load_ai_prompt_row und die Enum; Platzhalter bauen sie selbst oder über geteilte Builder.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
from prompt_resolver import MustacheRenderResult, render_mustache_template
|
||||||
|
|
||||||
|
_PLANNING_AI_SLUGS = frozenset(
|
||||||
|
{
|
||||||
|
"planning_exercise_search_rank",
|
||||||
|
"planning_exercise_search_intent",
|
||||||
|
"planning_exercise_expectation_profile",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_EXERCISE_AI_SLUGS = frozenset(
|
||||||
|
{
|
||||||
|
"exercise_summary",
|
||||||
|
"exercise_skill_suggestions",
|
||||||
|
"exercise_instruction_rewrite",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AiPromptContextKind(str, Enum):
|
||||||
|
"""
|
||||||
|
Logischer Kontext fuer Platzhalter/Builder — erweiterbar fuer Planung/Rahmen
|
||||||
|
ohne bestehende Slugs zu invalidieren.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLANNING_EXERCISE_SEARCH = "planning_exercise_search"
|
||||||
|
EXERCISE_FORM_AI = "exercise_form_ai"
|
||||||
|
|
||||||
|
|
||||||
|
def context_kind_for_slug(slug: str) -> Optional[AiPromptContextKind]:
|
||||||
|
"""Ordnet einen DB-Slug einer Kontext-Art zu, sofern registriert."""
|
||||||
|
s = (slug or "").strip().lower()
|
||||||
|
if s in _PLANNING_AI_SLUGS:
|
||||||
|
return AiPromptContextKind.PLANNING_EXERCISE_SEARCH
|
||||||
|
if s in _EXERCISE_AI_SLUGS:
|
||||||
|
return AiPromptContextKind.EXERCISE_FORM_AI
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Laedt eine Zeile ai_prompts fuer Laufzeit-Orchestrierung.
|
||||||
|
|
||||||
|
active_only=True: inaktive Prompts werden wie fehlend behandelt (503 im Aufrufer).
|
||||||
|
"""
|
||||||
|
if active_only:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||||
|
FROM ai_prompts
|
||||||
|
WHERE slug = %s AND active = true
|
||||||
|
""",
|
||||||
|
(slug,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||||
|
FROM ai_prompts
|
||||||
|
WHERE slug = %s
|
||||||
|
""",
|
||||||
|
(slug,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
if active_only and not d.get("active", True):
|
||||||
|
return None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class AiPromptUnavailableError(LookupError):
|
||||||
|
"""Kein aktiver Prompt fuer slug (oder Zeile fehlt)."""
|
||||||
|
|
||||||
|
def __init__(self, slug: str) -> None:
|
||||||
|
self.slug = (slug or "").strip()
|
||||||
|
super().__init__(self.slug)
|
||||||
|
|
||||||
|
|
||||||
|
def render_ai_prompt_template_for_row(
|
||||||
|
row: Mapping[str, Any],
|
||||||
|
variables: Mapping[str, str],
|
||||||
|
) -> MustacheRenderResult:
|
||||||
|
"""Ersetzt Platzhalter anhand einer bereits geladenen ai_prompts-Zeile (z. B. Admin-Vorschauch, inkl. inaktiv)."""
|
||||||
|
return render_mustache_template(str(row.get("template") or ""), variables)
|
||||||
|
|
||||||
|
|
||||||
|
def load_and_render_ai_prompt(
|
||||||
|
cur,
|
||||||
|
slug: str,
|
||||||
|
variables: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> Tuple[Dict[str, Any], MustacheRenderResult]:
|
||||||
|
"""
|
||||||
|
Laedt einen aktiven Prompt und wendet Mustache-Variablen an.
|
||||||
|
Wirft AiPromptUnavailableError, wenn die Zeile fehlt oder (bei active_only) inaktiv ist.
|
||||||
|
"""
|
||||||
|
row = load_ai_prompt_row(cur, slug, active_only=active_only)
|
||||||
|
if not row:
|
||||||
|
raise AiPromptUnavailableError(slug)
|
||||||
|
rr = render_ai_prompt_template_for_row(row, variables)
|
||||||
|
return dict(row), rr
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AiPromptContextKind",
|
||||||
|
"AiPromptUnavailableError",
|
||||||
|
"context_kind_for_slug",
|
||||||
|
"load_ai_prompt_row",
|
||||||
|
"load_and_render_ai_prompt",
|
||||||
|
"render_ai_prompt_template_for_row",
|
||||||
|
]
|
||||||
|
|
@ -170,6 +170,10 @@ def get_effective_tier(profile_id: str, conn=None) -> str:
|
||||||
|
|
||||||
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
||||||
"""
|
"""
|
||||||
|
DEPRECATED für Shinkan: Mitai-v9c-Profil-Limits — Schema 001 ist archiviert (Migration 078).
|
||||||
|
|
||||||
|
Für Vereins-Kontingente: club_features.check_club_feature_access(club_id, feature_id).
|
||||||
|
|
||||||
Check if a profile has access to a feature.
|
Check if a profile has access to a feature.
|
||||||
|
|
||||||
Access hierarchy:
|
Access hierarchy:
|
||||||
|
|
@ -315,6 +319,8 @@ def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
|
||||||
|
|
||||||
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
DEPRECATED für Shinkan — siehe club_features.increment_club_feature_usage.
|
||||||
|
|
||||||
Increment usage counter for a feature.
|
Increment usage counter for a feature.
|
||||||
|
|
||||||
Creates usage record if it doesn't exist, with reset_at based on
|
Creates usage record if it doesn't exist, with reset_at based on
|
||||||
|
|
|
||||||
285
backend/capabilities.py
Normal file
285
backend/capabilities.py
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
"""
|
||||||
|
Capability-Auflösung (CAPABILITY_CATALOG.v1.md, M3 C1).
|
||||||
|
|
||||||
|
Phase 2: probe_capability — JSON-Log, kein Block (CAPABILITY_ENFORCE=0).
|
||||||
|
Phase 3+: CAPABILITY_ENFORCE=1 — HTTP 403 bei fehlender Capability.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from account_lifecycle import account_state_satisfies
|
||||||
|
from club_tenancy import is_platform_admin
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def capability_enforcement_enabled() -> bool:
|
||||||
|
v = os.getenv("CAPABILITY_ENFORCE", "0").strip().lower()
|
||||||
|
return v in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]:
|
||||||
|
if club_id is None:
|
||||||
|
return []
|
||||||
|
for m in tenant.memberships or []:
|
||||||
|
if int(m.get("id") or 0) == int(club_id):
|
||||||
|
roles = m.get("roles") or []
|
||||||
|
if hasattr(roles, "tolist"):
|
||||||
|
roles = roles.tolist()
|
||||||
|
return list(roles)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def check_capability(
|
||||||
|
cur,
|
||||||
|
tenant: "TenantContext",
|
||||||
|
capability_id: str,
|
||||||
|
*,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Prüft eine Capability für Tenant + optionalen Vereinskontext.
|
||||||
|
|
||||||
|
Returns: allowed, reason, account_state, club_roles, linked_feature_id
|
||||||
|
"""
|
||||||
|
account_state = getattr(tenant, "account_state", "active_member")
|
||||||
|
eff_club = club_id if club_id is not None else tenant.effective_club_id
|
||||||
|
club_roles = club_roles_in_club(tenant, eff_club) if eff_club is not None else []
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, min_account_state, linked_feature_id, active, domain
|
||||||
|
FROM capabilities
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(capability_id,),
|
||||||
|
)
|
||||||
|
cap = cur.fetchone()
|
||||||
|
if not cap or not cap.get("active"):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "capability_not_found",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
min_state = cap.get("min_account_state") or "active_member"
|
||||||
|
if not account_state_satisfies(account_state, min_state):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "account_state_insufficient",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
domain = (cap.get("domain") or "").strip().lower()
|
||||||
|
|
||||||
|
# Kontingent-Bypass (konfigurierbar per portal_role / profile grants, ohne Plattform-Admin-Pflicht)
|
||||||
|
if domain == "quota_bypass":
|
||||||
|
role_lc = (tenant.global_role or "").lower()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(role_lc, capability_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "quota_bypass_portal_grant",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(tenant.profile_id, capability_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "quota_bypass_profile_grant",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "quota_bypass_denied",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plattform-Capabilities
|
||||||
|
if domain == "platform" or capability_id.startswith("platform."):
|
||||||
|
role_lc = (tenant.global_role or "").lower()
|
||||||
|
if not is_platform_admin(role_lc):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "portal_role_required",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(role_lc, capability_id),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(tenant.profile_id, capability_id),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "portal_capability_denied",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "portal_granted",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plattform-Admin-Bypass für Mandanten-Funktionen (Audit-Pflicht, s. Katalog §9)
|
||||||
|
if is_platform_admin(tenant.global_role):
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "platform_admin_bypass",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vereins-Capabilities: aktive Mitgliedschaft im Zielverein
|
||||||
|
if min_state == "active_member":
|
||||||
|
if eff_club is None:
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "no_club_context",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
if eff_club not in tenant.club_ids:
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "not_club_member",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT role_code FROM club_role_capability_grants
|
||||||
|
WHERE capability_id = %s
|
||||||
|
""",
|
||||||
|
(capability_id,),
|
||||||
|
)
|
||||||
|
required_roles = [r["role_code"] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
if required_roles:
|
||||||
|
if not any(r in required_roles for r in club_roles):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "club_role_denied",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
elif min_state == "active_member" and eff_club is not None:
|
||||||
|
# Offene Capability für alle aktiven Mitglieder — Mitgliedschaft reicht
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "granted",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_capabilities_map(
|
||||||
|
cur,
|
||||||
|
tenant: "TenantContext",
|
||||||
|
*,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, bool]:
|
||||||
|
"""Alle aktiven Capabilities → bool (für späteres /me/entitlements)."""
|
||||||
|
cur.execute("SELECT id FROM capabilities WHERE active = true ORDER BY id")
|
||||||
|
ids = [r["id"] for r in cur.fetchall()]
|
||||||
|
out: Dict[str, bool] = {}
|
||||||
|
for cid in ids:
|
||||||
|
res = check_capability(cur, tenant, cid, club_id=club_id)
|
||||||
|
out[cid] = bool(res.get("allowed"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def probe_capability(
|
||||||
|
tenant: "TenantContext",
|
||||||
|
capability_id: str,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Phase 2: Capability prüfen + JSON-Log; blockiert nur bei CAPABILITY_ENFORCE=1."""
|
||||||
|
from capability_logger import log_capability_check
|
||||||
|
|
||||||
|
def _run(c):
|
||||||
|
cur = get_cursor(c)
|
||||||
|
result = check_capability(cur, tenant, capability_id, club_id=club_id)
|
||||||
|
log_capability_check(
|
||||||
|
club_id=club_id if club_id is not None else tenant.effective_club_id,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
capability_id=capability_id,
|
||||||
|
action=action,
|
||||||
|
result=result,
|
||||||
|
endpoint=endpoint,
|
||||||
|
phase="enforce" if capability_enforcement_enabled() else "probe",
|
||||||
|
)
|
||||||
|
if capability_enforcement_enabled() and not result.get("allowed"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=(
|
||||||
|
f"Keine Berechtigung für {capability_id} "
|
||||||
|
f"({result.get('reason', 'denied')})."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
return _run(conn)
|
||||||
|
with get_db() as c:
|
||||||
|
return _run(c)
|
||||||
94
backend/capability_enforcement_audit.py
Normal file
94
backend/capability_enforcement_audit.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""
|
||||||
|
Audit: Welche Capabilities sind an Endpoints angebunden?
|
||||||
|
|
||||||
|
Für Admin-Matrix (Rollen & Rechte) und Roadmap — bei neuem probe_capability hier eintragen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
# Endpoints rufen probe_capability auf (Log; Block nur bei CAPABILITY_ENFORCE=1)
|
||||||
|
WIRED_PROBE = frozenset(
|
||||||
|
{
|
||||||
|
"exercises.ai.suggest",
|
||||||
|
"exercises.ai.regenerate",
|
||||||
|
"exercises.create",
|
||||||
|
"exercises.media.upload",
|
||||||
|
"planning.ai.suggest",
|
||||||
|
"planning.ai.progression_path",
|
||||||
|
"club.creation_request.read_own",
|
||||||
|
"club.creation_request.create",
|
||||||
|
"club.creation_request.withdraw",
|
||||||
|
"platform.club_creation.approve",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kontingent-Verbrauch nach Erfolg (consume_club_feature_with_usage)
|
||||||
|
FEATURE_CONSUME_WIRED = frozenset(
|
||||||
|
{
|
||||||
|
"ai_calls",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enforcement_status_for_capability(capability_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Anzeige-Status für Superadmin-Matrix.
|
||||||
|
|
||||||
|
level: probe | legacy | platform | open | none
|
||||||
|
"""
|
||||||
|
cid = (capability_id or "").strip()
|
||||||
|
if cid in WIRED_PROBE:
|
||||||
|
return {
|
||||||
|
"level": "probe",
|
||||||
|
"label": "API vorbereitet (Log)",
|
||||||
|
"detail": "probe_capability am Endpoint; Hard-Block erst mit CAPABILITY_ENFORCE=1",
|
||||||
|
"implemented": True,
|
||||||
|
}
|
||||||
|
if cid.startswith("platform."):
|
||||||
|
if cid == "platform.admin.access":
|
||||||
|
return {
|
||||||
|
"level": "platform",
|
||||||
|
"label": "Plattform (Router-Guard)",
|
||||||
|
"detail": "RequireAdmin / Superadmin-Checks",
|
||||||
|
"implemented": True,
|
||||||
|
}
|
||||||
|
if cid in WIRED_PROBE:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"level": "platform",
|
||||||
|
"label": "Plattform (teilweise)",
|
||||||
|
"detail": "Meist Router-Guard; Capability-Probe nur wo eingetragen",
|
||||||
|
"implemented": cid in WIRED_PROBE,
|
||||||
|
}
|
||||||
|
if cid.startswith("club."):
|
||||||
|
return {
|
||||||
|
"level": "open",
|
||||||
|
"label": "Onboarding",
|
||||||
|
"detail": "Account-State / eigene Flows",
|
||||||
|
"implemented": cid in WIRED_PROBE,
|
||||||
|
}
|
||||||
|
# Vereins-Capabilities ohne Probe: Legacy club_tenancy (can_plan_in_club, has_club_role, …)
|
||||||
|
return {
|
||||||
|
"level": "legacy",
|
||||||
|
"label": "Nur Legacy-Rollen",
|
||||||
|
"detail": "Noch kein probe_capability — prüft can_plan_in_club / club_admin im Code",
|
||||||
|
"implemented": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def feature_consume_status(feature_id: str) -> Dict[str, Any]:
|
||||||
|
fid = (feature_id or "").strip()
|
||||||
|
if fid in FEATURE_CONSUME_WIRED:
|
||||||
|
return {
|
||||||
|
"level": "consume",
|
||||||
|
"label": "Verbrauch aktiv",
|
||||||
|
"detail": "consume_club_feature_with_usage + feature_usage in Response",
|
||||||
|
"implemented": True,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"level": "inventory",
|
||||||
|
"label": "Bestand / Probe",
|
||||||
|
"detail": "Probe oder Live-Zählung; kein Consume nach Aktion",
|
||||||
|
"implemented": False,
|
||||||
|
}
|
||||||
64
backend/capability_logger.py
Normal file
64
backend/capability_logger.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""
|
||||||
|
JSON-Log für Capability-Checks (M3 Phase 2 — analog club_feature_logger).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _log_dir() -> Path:
|
||||||
|
custom = (os.getenv("CAPABILITY_LOG_DIR") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return Path(custom)
|
||||||
|
return Path("/app/logs")
|
||||||
|
|
||||||
|
|
||||||
|
capability_logger = logging.getLogger("shinkan.capability_usage")
|
||||||
|
capability_logger.setLevel(logging.INFO)
|
||||||
|
capability_logger.propagate = False
|
||||||
|
|
||||||
|
if not capability_logger.handlers:
|
||||||
|
log_dir = _log_dir()
|
||||||
|
try:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = log_dir / "capability-usage.log"
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
capability_logger.addHandler(file_handler)
|
||||||
|
except OSError:
|
||||||
|
stream_handler = logging.StreamHandler()
|
||||||
|
stream_handler.setFormatter(logging.Formatter("[capability-usage] %(message)s"))
|
||||||
|
capability_logger.addHandler(stream_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def log_capability_check(
|
||||||
|
*,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int],
|
||||||
|
capability_id: str,
|
||||||
|
action: str,
|
||||||
|
result: Dict[str, Any],
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
phase: str = "probe",
|
||||||
|
) -> None:
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"club_id": club_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"capability": capability_id,
|
||||||
|
"action": action,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"phase": phase,
|
||||||
|
"allowed": result.get("allowed", True),
|
||||||
|
"reason": result.get("reason", "unknown"),
|
||||||
|
"account_state": result.get("account_state"),
|
||||||
|
"club_roles": result.get("club_roles"),
|
||||||
|
"enforcement": os.getenv("CAPABILITY_ENFORCE", "0") == "1",
|
||||||
|
}
|
||||||
|
capability_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||||
74
backend/club_feature_logger.py
Normal file
74
backend/club_feature_logger.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""
|
||||||
|
JSON-Log für Vereins-Feature-Zugriffe (Phase 2: nur Monitoring, kein Block).
|
||||||
|
|
||||||
|
Spez: CLUB_MEMBERSHIP_AND_FEATURES.v1.md §9 Phase 2 — analog Mitai feature_logger.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _log_dir() -> Path:
|
||||||
|
custom = (os.getenv("CLUB_FEATURE_LOG_DIR") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return Path(custom)
|
||||||
|
return Path("/app/logs")
|
||||||
|
|
||||||
|
|
||||||
|
feature_usage_logger = logging.getLogger("shinkan.club_feature_usage")
|
||||||
|
feature_usage_logger.setLevel(logging.INFO)
|
||||||
|
feature_usage_logger.propagate = False
|
||||||
|
|
||||||
|
if not feature_usage_logger.handlers:
|
||||||
|
log_dir = _log_dir()
|
||||||
|
try:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = log_dir / "club-feature-usage.log"
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
feature_usage_logger.addHandler(file_handler)
|
||||||
|
except OSError:
|
||||||
|
# Dev ohne /app/logs: Fallback stderr
|
||||||
|
stream_handler = logging.StreamHandler()
|
||||||
|
stream_handler.setFormatter(logging.Formatter("[club-feature-usage] %(message)s"))
|
||||||
|
feature_usage_logger.addHandler(stream_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def log_club_feature_usage(
|
||||||
|
*,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int],
|
||||||
|
feature_id: str,
|
||||||
|
action: str,
|
||||||
|
access: Dict[str, Any],
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
phase: str = "probe",
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Strukturiertes JSON-Log eines Feature-Checks.
|
||||||
|
|
||||||
|
phase: probe (Phase 2, non-blocking) | enforce (Phase 4, nach Block-Entscheid)
|
||||||
|
"""
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"club_id": club_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"feature": feature_id,
|
||||||
|
"action": action,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"phase": phase,
|
||||||
|
"plan_id": access.get("plan_id"),
|
||||||
|
"used": access.get("used", 0),
|
||||||
|
"limit": access.get("limit"),
|
||||||
|
"remaining": access.get("remaining"),
|
||||||
|
"allowed": access.get("allowed", True),
|
||||||
|
"reason": access.get("reason", "unknown"),
|
||||||
|
"enforcement": os.getenv("CLUB_FEATURE_ENFORCE", "0") == "1",
|
||||||
|
}
|
||||||
|
feature_usage_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||||
713
backend/club_features.py
Normal file
713
backend/club_features.py
Normal file
|
|
@ -0,0 +1,713 @@
|
||||||
|
"""
|
||||||
|
Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id).
|
||||||
|
|
||||||
|
Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
|
||||||
|
Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block.
|
||||||
|
Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment.
|
||||||
|
|
||||||
|
Verbrauch-Standard für Router:
|
||||||
|
probe_club_feature_access → Business-Logik → consume_club_feature_with_usage → merge_feature_usage_into_response
|
||||||
|
|
||||||
|
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
# Bestands-Features: Verbrauch = Live-Zählung in DB (nicht club_feature_usage)
|
||||||
|
_INVENTORY_FEATURES = frozenset(
|
||||||
|
{"exercises", "training_groups", "active_members", "training_programs"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_next_reset(reset_period: str, *, now: Optional[datetime] = None) -> Optional[datetime]:
|
||||||
|
"""Nächster Reset-Zeitpunkt; None bei 'never'."""
|
||||||
|
ref = now or datetime.now(timezone.utc)
|
||||||
|
if reset_period == "never":
|
||||||
|
return None
|
||||||
|
if reset_period == "daily":
|
||||||
|
tomorrow = ref.date() + timedelta(days=1)
|
||||||
|
return datetime.combine(tomorrow, datetime.min.time(), tzinfo=timezone.utc)
|
||||||
|
if reset_period == "monthly":
|
||||||
|
if ref.month == 12:
|
||||||
|
return datetime(ref.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||||
|
return datetime(ref.year, ref.month + 1, 1, tzinfo=timezone.utc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_limit(raw: Any) -> Optional[int]:
|
||||||
|
"""NULL = unbegrenzt; -1 (Legacy 001) wird als unbegrenzt behandelt."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
v = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if v < 0:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_club_plan(cur, club_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Effektiver Plan für einen Verein.
|
||||||
|
|
||||||
|
1. Aktiver club_access_grants mit plan_id (Zeitfenster, neueste ends_at)
|
||||||
|
2. club_subscriptions.status = 'active' → plan_id
|
||||||
|
3. Fallback 'free'
|
||||||
|
"""
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT plan_id
|
||||||
|
FROM club_access_grants
|
||||||
|
WHERE club_id = %s
|
||||||
|
AND plan_id IS NOT NULL
|
||||||
|
AND starts_at <= NOW()
|
||||||
|
AND ends_at > NOW()
|
||||||
|
ORDER BY ends_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
grant = cur.fetchone()
|
||||||
|
if grant and grant.get("plan_id"):
|
||||||
|
return str(grant["plan_id"])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT plan_id
|
||||||
|
FROM club_subscriptions
|
||||||
|
WHERE club_id = %s AND status = 'active'
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
sub = cur.fetchone()
|
||||||
|
if sub and sub.get("plan_id"):
|
||||||
|
return str(sub["plan_id"])
|
||||||
|
|
||||||
|
return "free"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) -> Optional[int]:
|
||||||
|
"""Limit-Wert: Override > Plan > Feature-Default."""
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT limit_value
|
||||||
|
FROM club_feature_overrides
|
||||||
|
WHERE club_id = %s AND feature_id = %s
|
||||||
|
""",
|
||||||
|
(club_id, feature_id),
|
||||||
|
)
|
||||||
|
override = cur.fetchone()
|
||||||
|
if override is not None:
|
||||||
|
return _normalize_limit(override.get("limit_value"))
|
||||||
|
|
||||||
|
plan_id = get_effective_club_plan(cur, club_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT limit_value
|
||||||
|
FROM club_plan_limits
|
||||||
|
WHERE plan_id = %s AND feature_id = %s
|
||||||
|
""",
|
||||||
|
(plan_id, feature_id),
|
||||||
|
)
|
||||||
|
plan_lim = cur.fetchone()
|
||||||
|
if plan_lim is not None:
|
||||||
|
return _normalize_limit(plan_lim.get("limit_value"))
|
||||||
|
|
||||||
|
return _normalize_limit(feature_row.get("default_limit"))
|
||||||
|
|
||||||
|
|
||||||
|
def _live_inventory_count(cur, club_id: int, feature_id: str) -> Optional[int]:
|
||||||
|
"""Aktueller Bestand für reset_period=never Features."""
|
||||||
|
if feature_id == "exercises":
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS c
|
||||||
|
FROM exercises
|
||||||
|
WHERE club_id = %s AND status != 'archived'
|
||||||
|
""",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
elif feature_id == "training_groups":
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*)::int AS c FROM training_groups WHERE club_id = %s",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
elif feature_id == "active_members":
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS c
|
||||||
|
FROM club_members
|
||||||
|
WHERE club_id = %s AND status = 'active'
|
||||||
|
""",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
elif feature_id == "training_programs":
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS c FROM (
|
||||||
|
SELECT id FROM training_framework_programs WHERE club_id = %s
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM training_modules WHERE club_id = %s
|
||||||
|
) t
|
||||||
|
""",
|
||||||
|
(club_id, club_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row["c"] or 0) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_club_id_for_probe(
|
||||||
|
tenant: "TenantContext",
|
||||||
|
*,
|
||||||
|
object_club_id: Optional[int] = None,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Verein für Feature-Probe: explizites Objekt > effective_club_id."""
|
||||||
|
if object_club_id is not None:
|
||||||
|
return int(object_club_id)
|
||||||
|
eff = getattr(tenant, "effective_club_id", None)
|
||||||
|
return int(eff) if eff is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_reset_usage(cur, conn, club_id: int, feature_id: str, feature_row: dict, usage_row: Optional[dict]) -> int:
|
||||||
|
"""Setzt Zähler zurück wenn reset_at überschritten; gibt aktuellen used zurück."""
|
||||||
|
used = int(usage_row.get("usage_count") or 0) if usage_row else 0
|
||||||
|
reset_at = usage_row.get("reset_at") if usage_row else None
|
||||||
|
period = (feature_row.get("reset_period") or "never").strip().lower()
|
||||||
|
|
||||||
|
if not usage_row or not reset_at or period == "never":
|
||||||
|
return used
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
ra = reset_at
|
||||||
|
if hasattr(ra, "tzinfo") and ra.tzinfo is None:
|
||||||
|
ra = ra.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if ra and now > ra:
|
||||||
|
next_reset = _calculate_next_reset(period, now=now)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE club_feature_usage
|
||||||
|
SET usage_count = 0, reset_at = %s, updated_at = NOW()
|
||||||
|
WHERE club_id = %s AND feature_id = %s
|
||||||
|
""",
|
||||||
|
(next_reset, club_id, feature_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
def check_club_feature_access(
|
||||||
|
club_id: int,
|
||||||
|
feature_id: str,
|
||||||
|
*,
|
||||||
|
conn=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Prüft Vereins-Kontingent für ein Feature.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
allowed, limit, used, remaining, reason, plan_id, reset_at (optional)
|
||||||
|
"""
|
||||||
|
if conn is not None:
|
||||||
|
return _check_club_impl(club_id, feature_id, conn)
|
||||||
|
|
||||||
|
with get_db() as c:
|
||||||
|
return _check_club_impl(club_id, feature_id, c)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, limit_type, reset_period, default_limit, active, enforcement_subject
|
||||||
|
FROM features
|
||||||
|
WHERE id = %s AND app = 'shinkan'
|
||||||
|
""",
|
||||||
|
(feature_id,),
|
||||||
|
)
|
||||||
|
feature = cur.fetchone()
|
||||||
|
if not feature or not feature.get("active"):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"limit": None,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "feature_not_found",
|
||||||
|
"plan_id": get_effective_club_plan(cur, club_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan_id = get_effective_club_plan(cur, club_id)
|
||||||
|
limit = _resolve_club_limit(cur, club_id, feature_id, feature)
|
||||||
|
limit_type = (feature.get("limit_type") or "count").strip().lower()
|
||||||
|
|
||||||
|
if limit_type == "boolean":
|
||||||
|
allowed = limit == 1
|
||||||
|
return {
|
||||||
|
"allowed": allowed,
|
||||||
|
"limit": limit,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "enabled" if allowed else "feature_disabled",
|
||||||
|
"plan_id": plan_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT usage_count, reset_at
|
||||||
|
FROM club_feature_usage
|
||||||
|
WHERE club_id = %s AND feature_id = %s
|
||||||
|
""",
|
||||||
|
(club_id, feature_id),
|
||||||
|
)
|
||||||
|
usage = cur.fetchone()
|
||||||
|
used = _maybe_reset_usage(cur, conn, club_id, feature_id, feature, usage)
|
||||||
|
|
||||||
|
period = (feature.get("reset_period") or "never").strip().lower()
|
||||||
|
if period == "never" and feature_id in _INVENTORY_FEATURES:
|
||||||
|
inv = _live_inventory_count(cur, club_id, feature_id)
|
||||||
|
if inv is not None:
|
||||||
|
used = inv
|
||||||
|
|
||||||
|
if limit is None:
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"limit": None,
|
||||||
|
"used": used,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "unlimited",
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"reset_at": usage.get("reset_at") if usage else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit == 0:
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"limit": 0,
|
||||||
|
"used": used,
|
||||||
|
"remaining": 0,
|
||||||
|
"reason": "feature_disabled",
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"reset_at": usage.get("reset_at") if usage else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed = used < limit
|
||||||
|
return {
|
||||||
|
"allowed": allowed,
|
||||||
|
"limit": limit,
|
||||||
|
"used": used,
|
||||||
|
"remaining": max(0, limit - used),
|
||||||
|
"reason": "within_limit" if allowed else "limit_exceeded",
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"reset_at": usage.get("reset_at") if usage else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def club_feature_enforcement_enabled() -> bool:
|
||||||
|
"""Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1|true|yes)."""
|
||||||
|
v = os.getenv("CLUB_FEATURE_ENFORCE", "0").strip().lower()
|
||||||
|
return v in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def probe_club_feature_access(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
action: str,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
tenant: Optional["TenantContext"] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Phase 2: Prüft Vereins-Kontingent, schreibt JSON-Log, blockiert standardmäßig nicht.
|
||||||
|
|
||||||
|
Bei CLUB_FEATURE_ENFORCE=1: HTTP 403 wenn nicht allowed.
|
||||||
|
"""
|
||||||
|
from club_feature_logger import log_club_feature_usage
|
||||||
|
|
||||||
|
if club_id is None:
|
||||||
|
access = {
|
||||||
|
"allowed": not club_feature_enforcement_enabled(),
|
||||||
|
"limit": None,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "no_club_context",
|
||||||
|
"plan_id": None,
|
||||||
|
}
|
||||||
|
log_club_feature_usage(
|
||||||
|
club_id=None,
|
||||||
|
profile_id=profile_id,
|
||||||
|
feature_id=feature_id,
|
||||||
|
action=action,
|
||||||
|
access=access,
|
||||||
|
endpoint=endpoint,
|
||||||
|
phase="enforce" if club_feature_enforcement_enabled() else "probe",
|
||||||
|
)
|
||||||
|
if club_feature_enforcement_enabled() and not access.get("allowed"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=(
|
||||||
|
f"Kein Vereinskontext für {feature_id} — "
|
||||||
|
"aktiven Verein wählen (X-Active-Club-Id)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return access
|
||||||
|
|
||||||
|
def _resolve_access(connection):
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||||
|
|
||||||
|
cur = get_cursor(connection)
|
||||||
|
if is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
feature_id=feature_id,
|
||||||
|
tenant=tenant,
|
||||||
|
):
|
||||||
|
plan_id = get_effective_club_plan(cur, int(club_id))
|
||||||
|
return quota_bypass_access(
|
||||||
|
feature_id=feature_id,
|
||||||
|
club_id=int(club_id),
|
||||||
|
plan_id=plan_id,
|
||||||
|
)
|
||||||
|
return check_club_feature_access(club_id, feature_id, conn=connection)
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
access = _resolve_access(conn)
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
access = _resolve_access(c)
|
||||||
|
|
||||||
|
log_club_feature_usage(
|
||||||
|
club_id=club_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
feature_id=feature_id,
|
||||||
|
action=action,
|
||||||
|
access=access,
|
||||||
|
endpoint=endpoint,
|
||||||
|
phase="enforce" if club_feature_enforcement_enabled() else "probe",
|
||||||
|
)
|
||||||
|
|
||||||
|
if club_feature_enforcement_enabled() and not access.get("allowed"):
|
||||||
|
limit = access.get("limit")
|
||||||
|
used = access.get("used", 0)
|
||||||
|
detail = (
|
||||||
|
f"Kontingent überschritten für {feature_id} "
|
||||||
|
f"({used}/{limit if limit is not None else '∞'}). "
|
||||||
|
f"Grund: {access.get('reason', 'limit_exceeded')}."
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=403, detail=detail)
|
||||||
|
|
||||||
|
return access
|
||||||
|
|
||||||
|
|
||||||
|
def consume_club_feature(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
|
action: Optional[str] = None,
|
||||||
|
amount: int = 1,
|
||||||
|
conn=None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Phase 4 (M5): Zähler nach erfolgreichem Verbrauch erhöhen.
|
||||||
|
Nur wenn club_id gesetzt (Vereins-Kontingent); amount = Anzahl LLM/API-Verbrauchseinheiten.
|
||||||
|
Plattform-Ausnahmen (superadmin, konfigurierte Rollen/Profile) werden nicht gezählt.
|
||||||
|
"""
|
||||||
|
if club_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _is_exempt(connection) -> bool:
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed
|
||||||
|
|
||||||
|
cur = get_cursor(connection)
|
||||||
|
return is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
feature_id=feature_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
if _is_exempt(conn):
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
if _is_exempt(c):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
n = int(amount)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 1
|
||||||
|
if n < 1:
|
||||||
|
return
|
||||||
|
for _ in range(n):
|
||||||
|
increment_club_feature_usage(
|
||||||
|
int(club_id),
|
||||||
|
feature_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
action=action,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _log_consume(connection) -> None:
|
||||||
|
from club_feature_logger import log_club_feature_usage
|
||||||
|
|
||||||
|
access = check_club_feature_access(int(club_id), feature_id, conn=connection)
|
||||||
|
log_club_feature_usage(
|
||||||
|
club_id=int(club_id),
|
||||||
|
profile_id=profile_id,
|
||||||
|
feature_id=feature_id,
|
||||||
|
action=action or "consume",
|
||||||
|
access=access,
|
||||||
|
phase="consume",
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
_log_consume(conn)
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
_log_consume(c)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_club_feature_with_usage(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
|
action: Optional[str] = None,
|
||||||
|
amount: int = 1,
|
||||||
|
cur,
|
||||||
|
tenant: Optional["TenantContext"] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Standard nach erfolgreichem Verbrauch: zählen, protokollieren, Snapshot für Response.
|
||||||
|
|
||||||
|
Alle Endpoints mit Vereins-Kontingent-Verbrauch nutzen diese Funktion und
|
||||||
|
``merge_feature_usage_into_response`` — kein duplizierter Einzelcode pro Route.
|
||||||
|
"""
|
||||||
|
consume_club_feature(
|
||||||
|
feature_id=feature_id,
|
||||||
|
club_id=club_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
action=action,
|
||||||
|
amount=amount,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
if club_id is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
feature_id: club_feature_usage_for_api(
|
||||||
|
cur,
|
||||||
|
club_id=int(club_id),
|
||||||
|
feature_id=feature_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
tenant=tenant,
|
||||||
|
conn=conn,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_feature_usage_into_response(
|
||||||
|
payload: Any,
|
||||||
|
feature_usage: Optional[Dict[str, Dict[str, Any]]],
|
||||||
|
) -> Any:
|
||||||
|
"""Standard-Einbettung ``feature_usage`` in JSON-Responses."""
|
||||||
|
if not feature_usage or not isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
return {**payload, "feature_usage": feature_usage}
|
||||||
|
|
||||||
|
|
||||||
|
def club_feature_usage_for_api(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
club_id: int,
|
||||||
|
feature_id: str,
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
|
tenant: Optional["TenantContext"] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Feature-Zustand wie GET /me/entitlements → features[feature_id] (nach Verbrauch)."""
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||||
|
|
||||||
|
db_conn = conn if conn is not None else cur.connection
|
||||||
|
access = check_club_feature_access(int(club_id), feature_id, conn=db_conn)
|
||||||
|
plan_id = access.get("plan_id") or get_effective_club_plan(cur, int(club_id))
|
||||||
|
|
||||||
|
if is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
feature_id=feature_id,
|
||||||
|
tenant=tenant,
|
||||||
|
):
|
||||||
|
ex = quota_bypass_access(
|
||||||
|
feature_id=feature_id,
|
||||||
|
club_id=int(club_id),
|
||||||
|
plan_id=plan_id,
|
||||||
|
)
|
||||||
|
reset_at = access.get("reset_at")
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"used": access.get("used"),
|
||||||
|
"limit": None,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": ex.get("reason"),
|
||||||
|
"platform_exempt": True,
|
||||||
|
"reset_at": reset_at.isoformat() if hasattr(reset_at, "isoformat") else reset_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": access.get("allowed"),
|
||||||
|
"used": access.get("used"),
|
||||||
|
"limit": access.get("limit"),
|
||||||
|
"remaining": access.get("remaining"),
|
||||||
|
"reason": access.get("reason"),
|
||||||
|
"platform_exempt": False,
|
||||||
|
"reset_at": access.get("reset_at").isoformat()
|
||||||
|
if access.get("reset_at") is not None and hasattr(access.get("reset_at"), "isoformat")
|
||||||
|
else access.get("reset_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def increment_club_feature_usage(
|
||||||
|
club_id: int,
|
||||||
|
feature_id: str,
|
||||||
|
*,
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
action: Optional[str] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> None:
|
||||||
|
"""Erhöht Vereins-Zähler (nur bei neuem Verbrauch / INSERT-Pfad aufrufen)."""
|
||||||
|
def _run(c):
|
||||||
|
cur = get_cursor(c)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT reset_period, limit_type
|
||||||
|
FROM features
|
||||||
|
WHERE id = %s AND app = 'shinkan' AND active = true
|
||||||
|
""",
|
||||||
|
(feature_id,),
|
||||||
|
)
|
||||||
|
feature = cur.fetchone()
|
||||||
|
if not feature:
|
||||||
|
return
|
||||||
|
if (feature.get("limit_type") or "count").strip().lower() == "boolean":
|
||||||
|
return
|
||||||
|
|
||||||
|
period = (feature.get("reset_period") or "never").strip().lower()
|
||||||
|
next_reset = _calculate_next_reset(period)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO club_feature_usage (club_id, feature_id, usage_count, reset_at, last_used_at)
|
||||||
|
VALUES (%s, %s, 1, %s, NOW())
|
||||||
|
ON CONFLICT (club_id, feature_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
usage_count = club_feature_usage.usage_count + 1,
|
||||||
|
last_used_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(club_id, feature_id, next_reset),
|
||||||
|
)
|
||||||
|
|
||||||
|
if profile_id is not None or action:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO club_feature_usage_events (club_id, feature_id, profile_id, action)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(club_id, feature_id, profile_id, action or feature_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
_run(conn)
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
_run(c)
|
||||||
|
|
||||||
|
|
||||||
|
def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||||
|
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern)."""
|
||||||
|
db_conn = conn if conn is not None else cur.connection
|
||||||
|
plan_id = get_effective_club_plan(cur, club_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, category, limit_type, reset_period
|
||||||
|
FROM features
|
||||||
|
WHERE app = 'shinkan' AND active = true
|
||||||
|
ORDER BY category, id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
features_out = []
|
||||||
|
for row in rows:
|
||||||
|
fid = row["id"]
|
||||||
|
access = _check_club_impl(club_id, fid, db_conn)
|
||||||
|
features_out.append(
|
||||||
|
{
|
||||||
|
"id": fid,
|
||||||
|
"name": row.get("name"),
|
||||||
|
"category": row.get("category"),
|
||||||
|
"limit_type": row.get("limit_type"),
|
||||||
|
"reset_period": row.get("reset_period"),
|
||||||
|
"allowed": access.get("allowed"),
|
||||||
|
"limit": access.get("limit"),
|
||||||
|
"used": access.get("used"),
|
||||||
|
"remaining": access.get("remaining"),
|
||||||
|
"reason": access.get("reason"),
|
||||||
|
"reset_at": access.get("reset_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"club_id": club_id, "plan_id": plan_id, "features": features_out}
|
||||||
|
|
||||||
|
|
||||||
|
def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||||
|
"""Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements)."""
|
||||||
|
raw = list_club_entitlements(cur, club_id, conn=conn)
|
||||||
|
features_dict: Dict[str, Any] = {}
|
||||||
|
for row in raw.get("features") or []:
|
||||||
|
fid = row["id"]
|
||||||
|
features_dict[fid] = {
|
||||||
|
"name": row.get("name"),
|
||||||
|
"category": row.get("category"),
|
||||||
|
"limit_type": row.get("limit_type"),
|
||||||
|
"reset_period": row.get("reset_period"),
|
||||||
|
"allowed": row.get("allowed"),
|
||||||
|
"limit": row.get("limit"),
|
||||||
|
"used": row.get("used"),
|
||||||
|
"remaining": row.get("remaining"),
|
||||||
|
"reason": row.get("reason"),
|
||||||
|
"reset_at": row.get("reset_at"),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"club_id": raw.get("club_id"),
|
||||||
|
"plan_id": raw.get("plan_id"),
|
||||||
|
"features": features_dict,
|
||||||
|
}
|
||||||
180
backend/club_quota_bypass.py
Normal file
180
backend/club_quota_bypass.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""
|
||||||
|
Vereins-Kontingent-Bypass über das Capability-System (kein Parallel-Rechtemodell).
|
||||||
|
|
||||||
|
Capabilities:
|
||||||
|
- platform.club_quota.bypass — alle Vereins-Features (Portal-Admin, Grant via portal_role)
|
||||||
|
- platform.club_quota.bypass.{feature_id} — ein Feature (domain quota_bypass, auch für Nicht-Admins per Grant)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
QUOTA_BYPASS_ALL = "platform.club_quota.bypass"
|
||||||
|
QUOTA_BYPASS_FEATURE_PREFIX = "platform.club_quota.bypass."
|
||||||
|
|
||||||
|
|
||||||
|
def quota_bypass_capability_id_for_feature(feature_id: str) -> str:
|
||||||
|
return f"{QUOTA_BYPASS_FEATURE_PREFIX}{feature_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_quota_bypass_capability(cur, feature_id: str) -> str:
|
||||||
|
"""Legt feature-spezifische Bypass-Capability an falls nötig."""
|
||||||
|
cap_id = quota_bypass_capability_id_for_feature(feature_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES (%s, %s, 'quota_bypass', 'active_member', %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(cap_id, f"Vereins-Kontingent umgehen: {feature_id}", feature_id),
|
||||||
|
)
|
||||||
|
return cap_id
|
||||||
|
|
||||||
|
|
||||||
|
def _bypass_capability_ids(cur, feature_id: str) -> List[str]:
|
||||||
|
ids: List[str] = [QUOTA_BYPASS_ALL, quota_bypass_capability_id_for_feature(feature_id)]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM capabilities
|
||||||
|
WHERE active = true
|
||||||
|
AND domain = 'quota_bypass'
|
||||||
|
AND linked_feature_id = %s
|
||||||
|
AND id <> %s
|
||||||
|
""",
|
||||||
|
(feature_id, quota_bypass_capability_id_for_feature(feature_id)),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
cid = row.get("id")
|
||||||
|
if cid and cid not in ids:
|
||||||
|
ids.append(str(cid))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _portal_role_has_grant(cur, portal_role: str, capability_id: str) -> bool:
|
||||||
|
role = (portal_role or "").strip().lower()
|
||||||
|
if not role:
|
||||||
|
return False
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(role, capability_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_has_grant(cur, profile_id: int, capability_id: str) -> bool:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(int(profile_id), capability_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
profile_id: Optional[int],
|
||||||
|
portal_role: Optional[str],
|
||||||
|
feature_id: str,
|
||||||
|
tenant: Optional["TenantContext"] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
True wenn ein konfigurierter Capability-Grant das Vereins-Kontingent für feature_id umgeht.
|
||||||
|
"""
|
||||||
|
if tenant is not None:
|
||||||
|
from capabilities import check_capability
|
||||||
|
|
||||||
|
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||||
|
if check_capability(cur, tenant, cap_id).get("allowed"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||||
|
if _portal_role_has_grant(cur, portal_role or "", cap_id):
|
||||||
|
return True
|
||||||
|
if profile_id is not None and _profile_has_grant(cur, int(profile_id), cap_id):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def quota_bypass_access(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
plan_id: Optional[str] = None,
|
||||||
|
capability_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"limit": None,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "capability_quota_bypass",
|
||||||
|
"platform_exempt": True,
|
||||||
|
"quota_bypass_capability": capability_id,
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"club_id": club_id,
|
||||||
|
"feature_id": feature_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_quota_bypass_grants(cur) -> Dict[str, Any]:
|
||||||
|
"""Admin: alle Grants zu Kontingent-Bypass-Capabilities."""
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT g.portal_role, g.capability_id, c.name AS capability_name,
|
||||||
|
c.linked_feature_id, c.domain
|
||||||
|
FROM portal_role_capability_grants g
|
||||||
|
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||||
|
WHERE g.capability_id = %s
|
||||||
|
OR g.capability_id LIKE %s
|
||||||
|
OR c.domain = 'quota_bypass'
|
||||||
|
ORDER BY g.portal_role, g.capability_id
|
||||||
|
""",
|
||||||
|
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||||
|
)
|
||||||
|
portal_grants = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT g.profile_id, p.email, p.name AS profile_name,
|
||||||
|
g.capability_id, c.name AS capability_name, c.linked_feature_id,
|
||||||
|
g.reason, g.granted_by_profile_id, g.created_at
|
||||||
|
FROM profile_capability_grants g
|
||||||
|
INNER JOIN profiles p ON p.id = g.profile_id
|
||||||
|
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||||
|
WHERE g.capability_id = %s
|
||||||
|
OR g.capability_id LIKE %s
|
||||||
|
OR c.domain = 'quota_bypass'
|
||||||
|
ORDER BY g.profile_id, g.capability_id
|
||||||
|
""",
|
||||||
|
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||||
|
)
|
||||||
|
profile_grants = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, domain, linked_feature_id
|
||||||
|
FROM capabilities
|
||||||
|
WHERE id = %s OR id LIKE %s OR domain = 'quota_bypass'
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||||
|
)
|
||||||
|
capabilities = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"portal_role_grants": portal_grants,
|
||||||
|
"profile_grants": profile_grants,
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ Vereins-Mandanten: Mitgliedschaften, aktiver Vereinskontext, einfache Berechtigu
|
||||||
|
|
||||||
Siehe .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
|
Siehe .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
|
||||||
"""
|
"""
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Any, Dict, List, Mapping, Optional, Set, Union
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
@ -155,6 +155,165 @@ def club_ids_for_profile_with_roles(cur, profile_id: int, *role_codes: str) -> S
|
||||||
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
|
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
|
||||||
|
|
||||||
|
|
||||||
|
def _library_governance_triplet(
|
||||||
|
row: Mapping[str, Any],
|
||||||
|
) -> tuple[str, Optional[int], Optional[int]]:
|
||||||
|
"""visibility, club_id, created_by als normalisierte Werte für Bibliotheks-/Planungsartefakte."""
|
||||||
|
vis = str(row.get("visibility") or "private").strip().lower()
|
||||||
|
if vis not in _GOVERNANCE_VISIBILITY:
|
||||||
|
vis = "private"
|
||||||
|
cid_raw = row.get("club_id")
|
||||||
|
try:
|
||||||
|
ex_cid = int(cid_raw) if cid_raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ex_cid = None
|
||||||
|
cr_raw = row.get("created_by")
|
||||||
|
try:
|
||||||
|
creator = int(cr_raw) if cr_raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
creator = None
|
||||||
|
return vis, ex_cid, creator
|
||||||
|
|
||||||
|
|
||||||
|
def assert_library_content_editable(
|
||||||
|
cur,
|
||||||
|
profile_id: int,
|
||||||
|
role: Optional[str],
|
||||||
|
row: Union[Dict[str, Any], Mapping[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Inhalt bearbeiten: wie Übungen — Ersteller, Plattform-Admin oder Planungsberechtigter im Verein."""
|
||||||
|
pid = int(profile_id)
|
||||||
|
ex_vis, ex_cid, creator = _library_governance_triplet(row)
|
||||||
|
if creator is not None and creator == pid:
|
||||||
|
return
|
||||||
|
if is_platform_admin(role):
|
||||||
|
return
|
||||||
|
if ex_vis == "club" and ex_cid is not None and can_plan_in_club(cur, pid, ex_cid, role):
|
||||||
|
return
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Bearbeiten dieses Inhalts")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_library_content_deletable(
|
||||||
|
cur,
|
||||||
|
profile_id: int,
|
||||||
|
role: Optional[str],
|
||||||
|
row: Union[Dict[str, Any], Mapping[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Löschen: wie Übungen — privat Eigentümer/Vereins-Admin-Kontext, Verein nur Vereinsadmin, offiziell nur Plattform."""
|
||||||
|
pid = int(profile_id)
|
||||||
|
if is_platform_admin(role):
|
||||||
|
return
|
||||||
|
vis, cid, creator = _library_governance_triplet(row)
|
||||||
|
try:
|
||||||
|
creator_int = int(creator) if creator is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
creator_int = None
|
||||||
|
|
||||||
|
if vis == "official":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Offizielle Inhalte dürfen nur von Plattform-Admins gelöscht werden.",
|
||||||
|
)
|
||||||
|
if vis == "club":
|
||||||
|
try:
|
||||||
|
ex_club = int(cid) if cid is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ex_club = None
|
||||||
|
if ex_club is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Vereinsinhalt ohne gültige Vereinszuordnung")
|
||||||
|
if not has_club_role(cur, pid, ex_club, "club_admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Nur Vereins-Admins dürfen Vereins-Inhalte löschen.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if creator_int is not None and creator_int == pid:
|
||||||
|
return
|
||||||
|
if creator_int is not None and club_admin_shares_club_with_creator(cur, pid, creator_int):
|
||||||
|
return
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Löschen dieses Inhalts")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_library_content_governance_transition(
|
||||||
|
cur,
|
||||||
|
profile_id: int,
|
||||||
|
role: Optional[str],
|
||||||
|
prev_row: Union[Dict[str, Any], Mapping[str, Any]],
|
||||||
|
next_visibility: str,
|
||||||
|
next_club_id: Optional[int],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Zusätzliche Regeln beim Ändern von visibility/club_id (Zielzustand vor assert_valid_governance_visibility prüfen).
|
||||||
|
|
||||||
|
- Abwahl „official“: nur Plattform-Admin.
|
||||||
|
- private → club: nur Ersteller (oder Plattform-Admin).
|
||||||
|
- club → private: Ersteller, Vereinsadmin im bisherigen Verein oder Plattform-Admin.
|
||||||
|
- club → club mit Wechsel club_id: Vereinsadmin im alten oder neuen Verein oder Plattform-Admin.
|
||||||
|
"""
|
||||||
|
nv = str(next_visibility or "").strip().lower()
|
||||||
|
if nv not in _GOVERNANCE_VISIBILITY:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültige visibility")
|
||||||
|
|
||||||
|
old_vis, old_cid, creator = _library_governance_triplet(prev_row)
|
||||||
|
new_cid: Optional[int]
|
||||||
|
try:
|
||||||
|
new_cid = int(next_club_id) if next_club_id is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
new_cid = None
|
||||||
|
|
||||||
|
pid = int(profile_id)
|
||||||
|
try:
|
||||||
|
creator_int = int(creator) if creator is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
creator_int = None
|
||||||
|
|
||||||
|
if old_vis == nv and (nv != "club" or old_cid == new_cid):
|
||||||
|
return
|
||||||
|
|
||||||
|
if old_vis == "official" and nv != "official":
|
||||||
|
if not is_platform_admin(role):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Nur Plattform-Admins dürfen offizielle Inhalte auf Verein oder privat setzen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if nv == "official":
|
||||||
|
return
|
||||||
|
|
||||||
|
if old_vis == "private" and nv == "club":
|
||||||
|
if creator_int is not None and creator_int != pid and not is_platform_admin(role):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Nur der Ersteller darf private Inhalte für den Verein freigeben.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if old_vis == "club" and nv == "private":
|
||||||
|
if is_platform_admin(role):
|
||||||
|
return
|
||||||
|
if creator_int is not None and creator_int == pid:
|
||||||
|
return
|
||||||
|
if old_cid is not None and has_club_role(cur, pid, old_cid, "club_admin"):
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Nur Ersteller, Vereins-Admins oder Plattform-Admins dürfen Vereins-Inhalte auf privat setzen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_vis == "club" and nv == "club" and old_cid != new_cid:
|
||||||
|
if is_platform_admin(role):
|
||||||
|
return
|
||||||
|
ok_old = old_cid is not None and has_club_role(cur, pid, old_cid, "club_admin")
|
||||||
|
ok_new = new_cid is not None and has_club_role(cur, pid, new_cid, "club_admin")
|
||||||
|
if ok_old or ok_new:
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Nur Vereins-Admins oder Plattform-Admins dürfen die Vereinszuordnung ändern.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assert_valid_governance_visibility(
|
def assert_valid_governance_visibility(
|
||||||
cur,
|
cur,
|
||||||
profile_id: int,
|
profile_id: int,
|
||||||
|
|
|
||||||
|
|
@ -180,12 +180,17 @@ def init_db():
|
||||||
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
|
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
|
||||||
if cur.fetchone()['count'] == 0:
|
if cur.fetchone()['count'] == 0:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO ai_prompts (slug, name, description, template, active, sort_order)
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, active, sort_order
|
||||||
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
'pipeline',
|
'pipeline',
|
||||||
'Mehrstufige Gesamtanalyse',
|
'Mehrstufige Gesamtanalyse',
|
||||||
'Master-Schalter für die gesamte Pipeline. Deaktiviere diese Analyse, um die Pipeline komplett zu verstecken.',
|
'Master-Schalter fuer die gesamte Pipeline. Deaktiviere diese Zeile um die Pipeline zu verstecken.',
|
||||||
'PIPELINE_MASTER',
|
'PIPELINE_MASTER',
|
||||||
|
'admin',
|
||||||
|
'text',
|
||||||
true,
|
true,
|
||||||
-10
|
-10
|
||||||
)
|
)
|
||||||
|
|
|
||||||
113
backend/entitlements.py
Normal file
113
backend/entitlements.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""
|
||||||
|
Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4).
|
||||||
|
|
||||||
|
Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from capabilities import club_roles_in_club, resolve_capabilities_map
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||||
|
from club_features import club_features_map
|
||||||
|
from club_tenancy import is_platform_admin
|
||||||
|
from tenant_context import _club_exists
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_reset_at(value: Any) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=None).isoformat() + "Z"
|
||||||
|
return value.isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target_club_id(
|
||||||
|
cur,
|
||||||
|
tenant: "TenantContext",
|
||||||
|
club_id: Optional[int],
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Effektiver Verein für Entitlements (Query > Tenant)."""
|
||||||
|
target = int(club_id) if club_id is not None else tenant.effective_club_id
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if is_platform_admin(tenant.global_role):
|
||||||
|
if not _club_exists(cur, target):
|
||||||
|
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
|
||||||
|
return target
|
||||||
|
|
||||||
|
if target not in tenant.club_ids:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def build_me_entitlements(
|
||||||
|
cur,
|
||||||
|
tenant: "TenantContext",
|
||||||
|
*,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Kombiniert Account-Status, Capabilities und Feature-Kontingente.
|
||||||
|
"""
|
||||||
|
target_club = _resolve_target_club_id(cur, tenant, club_id)
|
||||||
|
club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else []
|
||||||
|
|
||||||
|
capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club)
|
||||||
|
|
||||||
|
features: Dict[str, Any] = {}
|
||||||
|
plan_id = None
|
||||||
|
if target_club is not None:
|
||||||
|
raw = club_features_map(cur, target_club)
|
||||||
|
plan_id = raw.get("plan_id")
|
||||||
|
for fid, row in (raw.get("features") or {}).items():
|
||||||
|
if is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
|
feature_id=fid,
|
||||||
|
tenant=tenant,
|
||||||
|
):
|
||||||
|
ex = quota_bypass_access(
|
||||||
|
feature_id=fid,
|
||||||
|
club_id=target_club,
|
||||||
|
plan_id=plan_id,
|
||||||
|
)
|
||||||
|
features[fid] = {
|
||||||
|
"allowed": True,
|
||||||
|
"used": row.get("used"),
|
||||||
|
"limit": None,
|
||||||
|
"remaining": None,
|
||||||
|
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||||
|
"reason": ex.get("reason"),
|
||||||
|
"platform_exempt": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
features[fid] = {
|
||||||
|
"allowed": row.get("allowed"),
|
||||||
|
"used": row.get("used"),
|
||||||
|
"limit": row.get("limit"),
|
||||||
|
"remaining": row.get("remaining"),
|
||||||
|
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||||
|
"reason": row.get("reason"),
|
||||||
|
"platform_exempt": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"account_state": tenant.account_state,
|
||||||
|
"portal_role": tenant.global_role,
|
||||||
|
"club_id": target_club,
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"features": features,
|
||||||
|
}
|
||||||
1122
backend/exercise_ai.py
Normal file
1122
backend/exercise_ai.py
Normal file
File diff suppressed because it is too large
Load Diff
536
backend/exercise_enrichment.py
Normal file
536
backend/exercise_enrichment.py
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
"""
|
||||||
|
Superadmin-Werkzeug: Übungs-Anreicherung per KI (Skills + optional Metadaten).
|
||||||
|
|
||||||
|
Wiederverwendet run_exercise_form_ai_suggestion / exercise_ai — keine neue OpenRouter-Pipeline.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
|
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||||
|
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||||
|
from exercise_ai import strip_html_to_plain
|
||||||
|
from exercise_rich_text import normalize_inline_exercise_media_markup
|
||||||
|
|
||||||
|
from routers.exercises import (
|
||||||
|
enrich_exercise_detail,
|
||||||
|
normalize_exercise_skill_intensity,
|
||||||
|
normalize_exercise_skill_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
SkillMergeMode = Literal["additive", "replace_ai_only", "replace_all"]
|
||||||
|
|
||||||
|
SKILL_MERGE_MODES = frozenset({"additive", "replace_ai_only", "replace_all"})
|
||||||
|
DEFAULT_SET_STATUS = "in_review"
|
||||||
|
# Max. IDs pro Apply-HTTP-Anfrage (kein LLM).
|
||||||
|
MAX_BATCH_EXERCISES = 50
|
||||||
|
# Preview: pro Request nur wenige Übungen — sonst Gateway-504 (Fritz!Box o.ä. ~60s).
|
||||||
|
MAX_PREVIEW_BATCH_EXERCISES = 3
|
||||||
|
|
||||||
|
_INSTRUCTION_FIELDS = ("goal", "execution", "preparation", "trainer_notes")
|
||||||
|
_SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary")
|
||||||
|
|
||||||
|
|
||||||
|
def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]:
|
||||||
|
rows: list[tuple[int, bool]] = []
|
||||||
|
for row in exercise.get("focus_areas") or []:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fid = int(row.get("focus_area_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if fid < 1:
|
||||||
|
continue
|
||||||
|
rows.append((fid, bool(row.get("is_primary"))))
|
||||||
|
rows.sort(key=lambda x: (not x[1], x[0]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str:
|
||||||
|
parts: List[str] = []
|
||||||
|
for row in exercise.get("focus_areas") or []:
|
||||||
|
if isinstance(row, dict):
|
||||||
|
nm = (row.get("name") or "").strip()
|
||||||
|
if nm:
|
||||||
|
parts.append(nm)
|
||||||
|
txt = ", ".join(parts).strip()
|
||||||
|
if len(txt) > 900:
|
||||||
|
return txt[:899] + "…"
|
||||||
|
return txt
|
||||||
|
|
||||||
|
|
||||||
|
def build_form_context_from_exercise(exercise: Dict[str, Any]) -> ExerciseFormAiPromptContext:
|
||||||
|
focus = _focus_area_hint_from_detail(exercise)
|
||||||
|
fctx = _focus_areas_ai_ctx_from_detail(exercise)
|
||||||
|
return ExerciseFormAiPromptContext.from_focus_tuples(
|
||||||
|
title=str(exercise.get("title") or "").strip(),
|
||||||
|
goal=exercise.get("goal"),
|
||||||
|
execution=exercise.get("execution"),
|
||||||
|
preparation=exercise.get("preparation"),
|
||||||
|
trainer_notes=exercise.get("trainer_notes"),
|
||||||
|
focus_hint=focus or None,
|
||||||
|
focus_tuples=fctx or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_exercise_for_enrichment(
|
||||||
|
exercise: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
want_skills: bool = False,
|
||||||
|
want_summary: bool = False,
|
||||||
|
want_instructions: bool = False,
|
||||||
|
) -> Optional[str]:
|
||||||
|
title = str(exercise.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
return "Titel fehlt"
|
||||||
|
|
||||||
|
ctx = build_form_context_from_exercise(exercise)
|
||||||
|
g_plain = strip_html_to_plain(exercise.get("goal"))
|
||||||
|
e_plain = strip_html_to_plain(exercise.get("execution"))
|
||||||
|
|
||||||
|
if want_skills or want_summary:
|
||||||
|
if not (g_plain.strip() or e_plain.strip()):
|
||||||
|
return "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)"
|
||||||
|
|
||||||
|
if want_instructions and not ctx.has_instruction_source_text():
|
||||||
|
return "Für Anleitungs-Überarbeitung fehlt Ausgangstext (Titel oder Anleitungsfeld)"
|
||||||
|
|
||||||
|
if not (want_skills or want_summary or want_instructions):
|
||||||
|
return "Kein Anreicherungsmodus aktiv"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_skill_row(raw: Dict[str, Any], *, ai_suggested: bool) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"skill_id": int(raw["skill_id"]),
|
||||||
|
"skill_name": (raw.get("skill_name") or "").strip() or f"Skill #{raw['skill_id']}",
|
||||||
|
"skill_category": raw.get("skill_category"),
|
||||||
|
"is_primary": bool(raw.get("is_primary")),
|
||||||
|
"intensity": normalize_exercise_skill_intensity(raw.get("intensity")),
|
||||||
|
"required_level": normalize_exercise_skill_level(raw.get("required_level")),
|
||||||
|
"target_level": normalize_exercise_skill_level(raw.get("target_level")),
|
||||||
|
"ai_suggested": ai_suggested,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_meta_differs(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
||||||
|
for k in _SKILL_COMPARE_KEYS:
|
||||||
|
av = a.get(k)
|
||||||
|
bv = b.get(k)
|
||||||
|
if k in ("required_level", "target_level"):
|
||||||
|
av = normalize_exercise_skill_level(av)
|
||||||
|
bv = normalize_exercise_skill_level(bv)
|
||||||
|
elif k == "intensity":
|
||||||
|
av = normalize_exercise_skill_intensity(av)
|
||||||
|
bv = normalize_exercise_skill_intensity(bv)
|
||||||
|
elif k == "is_primary":
|
||||||
|
av = bool(av)
|
||||||
|
bv = bool(bv)
|
||||||
|
if av != bv:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def merge_skills(
|
||||||
|
existing: List[Dict[str, Any]],
|
||||||
|
suggested: List[Dict[str, Any]],
|
||||||
|
mode: SkillMergeMode,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Merge-Modi: additive | replace_ai_only | replace_all (alle KI-Skills mit ai_suggested=true)."""
|
||||||
|
existing_norm = [_normalize_skill_row(s, ai_suggested=bool(s.get("ai_suggested"))) for s in existing]
|
||||||
|
suggested_norm = [_normalize_skill_row(s, ai_suggested=True) for s in suggested]
|
||||||
|
|
||||||
|
suggested_by_id = {int(s["skill_id"]): s for s in suggested_norm}
|
||||||
|
|
||||||
|
if mode == "replace_all":
|
||||||
|
return list(suggested_norm)
|
||||||
|
|
||||||
|
if mode == "replace_ai_only":
|
||||||
|
manual = [s for s in existing_norm if not s.get("ai_suggested")]
|
||||||
|
manual_ids = {int(s["skill_id"]) for s in manual}
|
||||||
|
result = list(manual)
|
||||||
|
for s in suggested_norm:
|
||||||
|
sid = int(s["skill_id"])
|
||||||
|
if sid in manual_ids:
|
||||||
|
continue
|
||||||
|
result.append(s)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# additive
|
||||||
|
result: List[Dict[str, Any]] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for s in existing_norm:
|
||||||
|
sid = int(s["skill_id"])
|
||||||
|
seen.add(sid)
|
||||||
|
if sid in suggested_by_id and s.get("ai_suggested"):
|
||||||
|
merged = {**s, **suggested_by_id[sid], "ai_suggested": True}
|
||||||
|
result.append(merged)
|
||||||
|
else:
|
||||||
|
result.append(dict(s))
|
||||||
|
for s in suggested_norm:
|
||||||
|
sid = int(s["skill_id"])
|
||||||
|
if sid not in seen:
|
||||||
|
result.append(s)
|
||||||
|
seen.add(sid)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compute_skill_diff(
|
||||||
|
before: List[Dict[str, Any]],
|
||||||
|
after: List[Dict[str, Any]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
before_ids = {int(s["skill_id"]): s for s in before}
|
||||||
|
after_ids = {int(s["skill_id"]): s for s in after}
|
||||||
|
added = [after_ids[i] for i in sorted(after_ids) if i not in before_ids]
|
||||||
|
removed = [before_ids[i] for i in sorted(before_ids) if i not in after_ids]
|
||||||
|
changed: List[Dict[str, Any]] = []
|
||||||
|
for sid in before_ids:
|
||||||
|
if sid in after_ids and _skill_meta_differs(before_ids[sid], after_ids[sid]):
|
||||||
|
changed.append(
|
||||||
|
{
|
||||||
|
"skill_id": sid,
|
||||||
|
"skill_name": after_ids[sid].get("skill_name") or before_ids[sid].get("skill_name"),
|
||||||
|
"before": before_ids[sid],
|
||||||
|
"after": after_ids[sid],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
kept = [
|
||||||
|
before_ids[i]
|
||||||
|
for i in sorted(before_ids)
|
||||||
|
if i in after_ids and i not in {c["skill_id"] for c in changed}
|
||||||
|
]
|
||||||
|
return {"added": added, "removed": removed, "changed": changed, "kept": kept}
|
||||||
|
|
||||||
|
|
||||||
|
def _skills_from_ai_payload(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
rows = payload.get("skills")
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
return [_normalize_skill_row(r, ai_suggested=True) for r in rows if isinstance(r, dict) and r.get("skill_id")]
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_from_ai_payload(payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
block = payload.get("summary")
|
||||||
|
if isinstance(block, dict):
|
||||||
|
text = (block.get("text") or "").strip()
|
||||||
|
return text or None
|
||||||
|
if isinstance(block, str) and block.strip():
|
||||||
|
return block.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _instructions_from_ai_payload(payload: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
block = payload.get("instructions")
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
return {}
|
||||||
|
fields = block.get("fields")
|
||||||
|
if not isinstance(fields, dict):
|
||||||
|
return {}
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for key in _INSTRUCTION_FIELDS:
|
||||||
|
val = fields.get(key)
|
||||||
|
if val is not None and str(val).strip():
|
||||||
|
out[key] = str(val).strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _instruction_snapshot(exercise: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for key in _INSTRUCTION_FIELDS:
|
||||||
|
raw = exercise.get(key)
|
||||||
|
plain = strip_html_to_plain(raw, max_len=400) if raw else ""
|
||||||
|
if plain.strip():
|
||||||
|
out[key] = plain.strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compute_instruction_diff(
|
||||||
|
before: Dict[str, str],
|
||||||
|
after: Dict[str, str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
changed: List[Dict[str, Any]] = []
|
||||||
|
added: List[str] = []
|
||||||
|
for key in _INSTRUCTION_FIELDS:
|
||||||
|
b = (before.get(key) or "").strip()
|
||||||
|
a = (after.get(key) or "").strip()
|
||||||
|
if not a:
|
||||||
|
continue
|
||||||
|
if not b:
|
||||||
|
added.append(key)
|
||||||
|
elif b != strip_html_to_plain(a, max_len=400).strip() and b != a:
|
||||||
|
changed.append({"field": key, "before_plain": b, "after_html": a})
|
||||||
|
return {"changed_fields": changed, "added_fields": added}
|
||||||
|
|
||||||
|
|
||||||
|
def preview_exercise_enrichment(
|
||||||
|
cur,
|
||||||
|
exercise_id: int,
|
||||||
|
*,
|
||||||
|
want_skills: bool = True,
|
||||||
|
want_summary: bool = False,
|
||||||
|
want_instructions: bool = False,
|
||||||
|
merge_mode: SkillMergeMode = "additive",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||||
|
if not exercise:
|
||||||
|
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
||||||
|
|
||||||
|
skip_reason = validate_exercise_for_enrichment(
|
||||||
|
exercise,
|
||||||
|
want_skills=want_skills,
|
||||||
|
want_summary=want_summary,
|
||||||
|
want_instructions=want_instructions,
|
||||||
|
)
|
||||||
|
if skip_reason:
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": False,
|
||||||
|
"skipped": True,
|
||||||
|
"error": skip_reason,
|
||||||
|
"title": exercise.get("title"),
|
||||||
|
"status": exercise.get("status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = exercise.get("skills") or []
|
||||||
|
suggested: List[Dict[str, Any]] = []
|
||||||
|
ai_meta: Dict[str, Any] = {}
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
suggested_summary: Optional[str] = None
|
||||||
|
suggested_instructions: Dict[str, str] = {}
|
||||||
|
|
||||||
|
if want_skills or want_summary or want_instructions:
|
||||||
|
ctx = build_form_context_from_exercise(exercise)
|
||||||
|
payload = run_exercise_form_ai_suggestion(
|
||||||
|
cur,
|
||||||
|
ctx,
|
||||||
|
want_summary=want_summary,
|
||||||
|
want_skills=want_skills,
|
||||||
|
want_instructions=want_instructions,
|
||||||
|
)
|
||||||
|
if want_skills:
|
||||||
|
suggested = _skills_from_ai_payload(payload)
|
||||||
|
if want_summary:
|
||||||
|
suggested_summary = _summary_from_ai_payload(payload)
|
||||||
|
if want_instructions:
|
||||||
|
suggested_instructions = _instructions_from_ai_payload(payload)
|
||||||
|
ai_meta = {
|
||||||
|
"models": payload.get("models_by_slug") or {},
|
||||||
|
"llm_calls": sum([want_skills, want_summary, want_instructions]),
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = merge_skills(existing, suggested, merge_mode) if want_skills else list(existing)
|
||||||
|
diff = compute_skill_diff(existing, merged) if want_skills else None
|
||||||
|
|
||||||
|
existing_summary = (exercise.get("summary") or "").strip() or None
|
||||||
|
instr_before = _instruction_snapshot(exercise)
|
||||||
|
instr_after_plain = {
|
||||||
|
k: strip_html_to_plain(v, max_len=400) for k, v in suggested_instructions.items()
|
||||||
|
}
|
||||||
|
instruction_diff = (
|
||||||
|
compute_instruction_diff(instr_before, instr_after_plain) if want_instructions else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": True,
|
||||||
|
"title": exercise.get("title"),
|
||||||
|
"status": exercise.get("status"),
|
||||||
|
"visibility": exercise.get("visibility"),
|
||||||
|
"primary_focus_name": _primary_focus_from_exercise(exercise),
|
||||||
|
"existing_skills": existing,
|
||||||
|
"suggested_skills": suggested,
|
||||||
|
"merged_skills": merged,
|
||||||
|
"diff": diff,
|
||||||
|
"existing_summary": existing_summary,
|
||||||
|
"suggested_summary": suggested_summary,
|
||||||
|
"existing_instructions": instr_before,
|
||||||
|
"suggested_instructions": suggested_instructions,
|
||||||
|
"instruction_diff": instruction_diff,
|
||||||
|
"ai_meta": ai_meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_focus_from_exercise(exercise: Dict[str, Any]) -> Optional[str]:
|
||||||
|
for row in exercise.get("focus_areas") or []:
|
||||||
|
if isinstance(row, dict) and row.get("is_primary"):
|
||||||
|
return (row.get("name") or "").strip() or None
|
||||||
|
for row in exercise.get("focus_areas") or []:
|
||||||
|
if isinstance(row, dict):
|
||||||
|
nm = (row.get("name") or "").strip()
|
||||||
|
if nm:
|
||||||
|
return nm
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def persist_merged_skills(cur, exercise_id: int, merged: List[Dict[str, Any]], merge_mode: SkillMergeMode) -> None:
|
||||||
|
if merge_mode == "replace_all":
|
||||||
|
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
|
||||||
|
elif merge_mode == "replace_ai_only":
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM exercise_skills WHERE exercise_id = %s AND ai_suggested = true",
|
||||||
|
(exercise_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
for sk in merged:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO exercise_skills
|
||||||
|
(exercise_id, skill_id, is_primary, intensity, required_level, target_level, ai_suggested)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
|
||||||
|
intensity = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.intensity ELSE EXCLUDED.intensity END,
|
||||||
|
required_level = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.required_level ELSE EXCLUDED.required_level END,
|
||||||
|
target_level = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.target_level ELSE EXCLUDED.target_level END,
|
||||||
|
is_primary = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.is_primary ELSE EXCLUDED.is_primary END,
|
||||||
|
ai_suggested = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.ai_suggested ELSE EXCLUDED.ai_suggested END
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
exercise_id,
|
||||||
|
int(sk["skill_id"]),
|
||||||
|
bool(sk.get("is_primary")),
|
||||||
|
normalize_exercise_skill_intensity(sk.get("intensity")),
|
||||||
|
normalize_exercise_skill_level(sk.get("required_level")),
|
||||||
|
normalize_exercise_skill_level(sk.get("target_level")),
|
||||||
|
bool(sk.get("ai_suggested")),
|
||||||
|
merge_mode,
|
||||||
|
merge_mode,
|
||||||
|
merge_mode,
|
||||||
|
merge_mode,
|
||||||
|
merge_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_instruction_fields(fields: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||||
|
if not fields:
|
||||||
|
return {}
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for key in _INSTRUCTION_FIELDS:
|
||||||
|
if key not in fields:
|
||||||
|
continue
|
||||||
|
raw = fields.get(key)
|
||||||
|
if raw is None or not str(raw).strip():
|
||||||
|
continue
|
||||||
|
out[key] = normalize_inline_exercise_media_markup(str(raw).strip())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_exercise_enrichment(
|
||||||
|
cur,
|
||||||
|
exercise_id: int,
|
||||||
|
*,
|
||||||
|
merged_skills: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
merge_mode: SkillMergeMode = "additive",
|
||||||
|
set_status: Optional[str] = DEFAULT_SET_STATUS,
|
||||||
|
apply_skills: bool = False,
|
||||||
|
summary_text: Optional[str] = None,
|
||||||
|
apply_summary: bool = False,
|
||||||
|
instruction_fields: Optional[Dict[str, Any]] = None,
|
||||||
|
apply_instructions: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||||
|
if not exercise:
|
||||||
|
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
||||||
|
|
||||||
|
skip_reason = validate_exercise_for_enrichment(
|
||||||
|
exercise,
|
||||||
|
want_skills=apply_skills,
|
||||||
|
want_summary=apply_summary,
|
||||||
|
want_instructions=apply_instructions,
|
||||||
|
)
|
||||||
|
if skip_reason:
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": False,
|
||||||
|
"skipped": True,
|
||||||
|
"error": skip_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
skills_list = merged_skills or []
|
||||||
|
if apply_skills:
|
||||||
|
if not skills_list and merge_mode != "replace_all":
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": False,
|
||||||
|
"error": "Keine Skills zum Anwenden",
|
||||||
|
}
|
||||||
|
persist_merged_skills(cur, exercise_id, skills_list, merge_mode)
|
||||||
|
|
||||||
|
sets: List[str] = []
|
||||||
|
vals: List[Any] = []
|
||||||
|
|
||||||
|
if apply_summary and summary_text is not None:
|
||||||
|
text = str(summary_text).strip()
|
||||||
|
if text:
|
||||||
|
sets.extend(["summary = %s", "summary_ai_generated = true"])
|
||||||
|
vals.append(text[:220])
|
||||||
|
|
||||||
|
if apply_instructions:
|
||||||
|
norm = _normalize_instruction_fields(instruction_fields)
|
||||||
|
for key, val in norm.items():
|
||||||
|
sets.append(f"{key} = %s")
|
||||||
|
vals.append(val)
|
||||||
|
|
||||||
|
new_status = (set_status or "").strip().lower() or None
|
||||||
|
if new_status:
|
||||||
|
if new_status == "approved":
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": False,
|
||||||
|
"error": "Automatisches Freigeben (approved) ist nicht erlaubt",
|
||||||
|
}
|
||||||
|
if new_status not in ("draft", "in_review", "archived"):
|
||||||
|
return {"exercise_id": exercise_id, "ok": False, "error": "Ungültiger Ziel-Status"}
|
||||||
|
sets.append("status = %s")
|
||||||
|
vals.append(new_status)
|
||||||
|
|
||||||
|
if sets:
|
||||||
|
sets.append("updated_at = NOW()")
|
||||||
|
vals.append(exercise_id)
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
|
||||||
|
tuple(vals),
|
||||||
|
)
|
||||||
|
elif not apply_skills:
|
||||||
|
return {"exercise_id": exercise_id, "ok": False, "error": "Nichts anzuwenden"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": True,
|
||||||
|
"status": new_status or exercise.get("status"),
|
||||||
|
"skills_applied": len(skills_list) if apply_skills else 0,
|
||||||
|
"summary_applied": apply_summary and bool(summary_text and str(summary_text).strip()),
|
||||||
|
"instructions_applied": apply_instructions and bool(_normalize_instruction_fields(instruction_fields)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_llm_calls(
|
||||||
|
*,
|
||||||
|
exercise_count: int,
|
||||||
|
want_skills: bool,
|
||||||
|
want_summary: bool,
|
||||||
|
want_instructions: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
per_skills = exercise_count if want_skills else 0
|
||||||
|
per_summary = exercise_count if want_summary else 0
|
||||||
|
per_instructions = exercise_count if want_instructions else 0
|
||||||
|
total = per_skills + per_summary + per_instructions
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"per_exercise": sum([want_skills, want_summary, want_instructions]),
|
||||||
|
"skills": per_skills,
|
||||||
|
"summary": per_summary,
|
||||||
|
"instructions": per_instructions,
|
||||||
|
}
|
||||||
16
backend/fastapi_param_unwrap.py
Normal file
16
backend/fastapi_param_unwrap.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""Hilfen für direkte Python-Aufrufe von FastAPI-Route-Handlern (ohne Request-Kontext)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_query_default(value: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Parameter mit Annotation ``= Query(default=…)`` sind im Funktionskörper ``fastapi.params.Query``-Instanzen,
|
||||||
|
solange FastAPI sie nicht durch echte Werte ersetzt hat (interne Aufrufe, Aggregat-Endpunkte).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from fastapi.params import Query
|
||||||
|
except ImportError:
|
||||||
|
return value
|
||||||
|
return value.default if isinstance(value, Query) else value
|
||||||
|
|
@ -52,6 +52,28 @@ else:
|
||||||
print(f"[FAIL] Migration-Laufzeitfehler: {e}")
|
print(f"[FAIL] Migration-Laufzeitfehler: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Registry-first: Module → DB (nur registrierte Rechte/Kontingente in Admin-Matrix)
|
||||||
|
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
|
||||||
|
try:
|
||||||
|
from rights_registry import sync_rights_registry_to_db
|
||||||
|
|
||||||
|
counts = sync_rights_registry_to_db()
|
||||||
|
print(
|
||||||
|
f"[OK] Rights registry sync: {counts['capabilities']} capabilities, "
|
||||||
|
f"{counts['features']} features"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[FAIL] Rights registry sync: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
from club_features import club_feature_enforcement_enabled
|
||||||
|
|
||||||
|
_cfe = os.getenv("CLUB_FEATURE_ENFORCE", "0")
|
||||||
|
print(
|
||||||
|
f"[OK] CLUB_FEATURE_ENFORCE raw={_cfe!r} "
|
||||||
|
f"active={club_feature_enforcement_enabled()}"
|
||||||
|
)
|
||||||
|
|
||||||
from routers.auth import limiter as auth_rate_limiter
|
from routers.auth import limiter as auth_rate_limiter
|
||||||
|
|
||||||
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
|
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
|
||||||
|
|
@ -87,6 +109,34 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def account_onboarding_api_gate(request: Request, call_next):
|
||||||
|
"""
|
||||||
|
Phase A: Domänen-APIs für unverified / verified_pending_club sperren.
|
||||||
|
Siehe account_onboarding_gate.py und MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1
|
||||||
|
"""
|
||||||
|
from account_onboarding_gate import evaluate_request_gate
|
||||||
|
|
||||||
|
token = request.headers.get("x-auth-token") or request.headers.get("X-Auth-Token")
|
||||||
|
allowed, reason, _state = evaluate_request_gate(
|
||||||
|
token,
|
||||||
|
request.url.path,
|
||||||
|
request.method,
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={
|
||||||
|
"detail": (
|
||||||
|
"Zugriff erst nach E-Mail-Bestätigung und Vereinsmitgliedschaft möglich. "
|
||||||
|
"Du kannst einen Beitrittsantrag stellen oder dein Konto in den Einstellungen verwalten."
|
||||||
|
),
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def add_api_security_headers(request: Request, call_next):
|
async def add_api_security_headers(request: Request, call_next):
|
||||||
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
|
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
|
||||||
|
|
@ -193,7 +243,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# 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, training_planning, 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, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -202,22 +252,33 @@ app.include_router(exercise_progression_graphs.router)
|
||||||
app.include_router(clubs.router)
|
app.include_router(clubs.router)
|
||||||
app.include_router(club_memberships.router)
|
app.include_router(club_memberships.router)
|
||||||
app.include_router(club_join_requests.router)
|
app.include_router(club_join_requests.router)
|
||||||
|
app.include_router(club_creation_requests.router)
|
||||||
app.include_router(admin_users.router)
|
app.include_router(admin_users.router)
|
||||||
|
app.include_router(admin_user_content.router)
|
||||||
|
app.include_router(admin_rights.router)
|
||||||
|
app.include_router(me_entitlements.router)
|
||||||
app.include_router(platform_media_storage.router)
|
app.include_router(platform_media_storage.router)
|
||||||
app.include_router(media_assets.router)
|
app.include_router(media_assets.router)
|
||||||
app.include_router(media_assets.admin_rights_router)
|
app.include_router(media_assets.admin_rights_router)
|
||||||
app.include_router(media_assets.admin_legal_hold_router)
|
app.include_router(media_assets.admin_legal_hold_router)
|
||||||
app.include_router(skills.router)
|
app.include_router(skills.router)
|
||||||
|
app.include_router(skill_profiles.router)
|
||||||
app.include_router(training_planning.router)
|
app.include_router(training_planning.router)
|
||||||
|
app.include_router(planning_exercise_suggest.router)
|
||||||
|
app.include_router(dashboard.router)
|
||||||
app.include_router(training_modules.router)
|
app.include_router(training_modules.router)
|
||||||
app.include_router(training_framework_programs.router)
|
app.include_router(training_framework_programs.router)
|
||||||
app.include_router(catalogs.router)
|
app.include_router(catalogs.router)
|
||||||
app.include_router(maturity_models.router)
|
app.include_router(maturity_models.router)
|
||||||
app.include_router(matrix_stack_bundle.router)
|
app.include_router(matrix_stack_bundle.router)
|
||||||
|
app.include_router(matrix_editor.router)
|
||||||
app.include_router(import_wiki.router)
|
app.include_router(import_wiki.router)
|
||||||
app.include_router(import_wiki_admin.router)
|
app.include_router(import_wiki_admin.router)
|
||||||
app.include_router(legal_documents.router)
|
app.include_router(legal_documents.router)
|
||||||
app.include_router(content_reports.router)
|
app.include_router(content_reports.router)
|
||||||
|
app.include_router(ai_prompts_admin.router)
|
||||||
|
app.include_router(ai_skill_retrieval_admin.router)
|
||||||
|
app.include_router(exercise_enrichment_admin.router)
|
||||||
|
|
||||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Unterstützung für GET /api/exercises: ORDER BY e.updated_at DESC
|
||||||
|
-- und häufiger Pfad created_by_me (= e.created_by = Profil) mit derselben Sortierung.
|
||||||
|
-- Hinweis: idx_exercises_created_at (014) betrifft created_at, nicht updated_at.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercises_updated_at_desc ON exercises (updated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercises_created_by_updated_at_desc ON exercises (created_by, updated_at DESC);
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- GET /api/training-units: Liste nutzt immer tu.framework_slot_id IS NULL (keine Rahmen-Blueprints)
|
||||||
|
-- und sortiert nach planned_date, planned_time_start (ASC/DESC mit NULLS LAST).
|
||||||
|
-- Teilindex verkleinert die Menge und unterstützt die Sortierung.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_units_scheduled_order
|
||||||
|
ON training_units (planned_date DESC, planned_time_start DESC NULLS LAST)
|
||||||
|
WHERE framework_slot_id IS NULL;
|
||||||
33
backend/migrations/060_exercises_list_scale_indexes.sql
Normal file
33
backend/migrations/060_exercises_list_scale_indexes.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- Migration 060: Übungslisten bei großem Bestand (Ziel: Tausende Übungen, viele Filterkombinationen).
|
||||||
|
-- Ergänzt 058 (globale Sortierung / created_by): kleinere Partial-Indizes für häufige
|
||||||
|
-- Sichtbarkeits-Pfade der Bibliothek sowie Junction-Indizes für die List-Subqueries
|
||||||
|
-- (primary_focus_name / JSON-Aggregate mit is_primary).
|
||||||
|
--
|
||||||
|
-- Bereits vorhanden und sinnvoll: UNIQUE(exercise_id, …) auf den M:N-Tabellen für EXISTS-Joins;
|
||||||
|
-- GIN auf exercises.search_vector (014); idx_exercises_exercise_kind (056).
|
||||||
|
|
||||||
|
-- Official: OR-Zweig der Bibliothek — kompakter als Full-Table-Scan bei BitmapOr mit anderen Partial-Indizes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercises_list_official_updated
|
||||||
|
ON exercises (updated_at DESC)
|
||||||
|
WHERE visibility = 'official'
|
||||||
|
AND COALESCE(status, '') <> 'archived';
|
||||||
|
|
||||||
|
-- Club: häufig club_id + Sortierung nach updated_at (Mandanten-Bibliothek)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercises_list_club_updated
|
||||||
|
ON exercises (club_id, updated_at DESC)
|
||||||
|
WHERE visibility = 'club'
|
||||||
|
AND club_id IS NOT NULL
|
||||||
|
AND COALESCE(status, '') <> 'archived';
|
||||||
|
|
||||||
|
-- List-SELECT: Subqueries / json_agg sortieren zuerst nach is_primary (siehe exercises.py)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercise_focus_areas_exercise_primary
|
||||||
|
ON exercise_focus_areas (exercise_id, is_primary DESC NULLS LAST, focus_area_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercise_style_directions_exercise_primary
|
||||||
|
ON exercise_style_directions (exercise_id, is_primary DESC NULLS LAST, style_direction_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercise_training_types_exercise_primary
|
||||||
|
ON exercise_training_types (exercise_id, is_primary DESC NULLS LAST, training_type_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercise_target_groups_exercise_primary
|
||||||
|
ON exercise_target_groups (exercise_id, is_primary DESC NULLS LAST, target_group_id);
|
||||||
22
backend/migrations/061_training_units_keyset_indexes.sql
Normal file
22
backend/migrations/061_training_units_keyset_indexes.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- GET /api/training-units: Keyset über (planned_date, planned_time_start NULLS LAST per Sort, id)
|
||||||
|
-- Ersetzt den reinen Datum/Uhrzeit-Teilindex 059 durch zwei Richtungen mit Tie-Break id.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_training_units_scheduled_order;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_units_list_keyset_desc
|
||||||
|
ON training_units (
|
||||||
|
planned_date DESC,
|
||||||
|
(planned_time_start IS NULL) ASC,
|
||||||
|
planned_time_start DESC NULLS LAST,
|
||||||
|
id DESC
|
||||||
|
)
|
||||||
|
WHERE framework_slot_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_units_list_keyset_asc
|
||||||
|
ON training_units (
|
||||||
|
planned_date ASC,
|
||||||
|
(planned_time_start IS NULL) ASC,
|
||||||
|
planned_time_start ASC NULLS LAST,
|
||||||
|
id ASC
|
||||||
|
)
|
||||||
|
WHERE framework_slot_id IS NULL;
|
||||||
41
backend/migrations/062_exercise_skills_level_rank_index.sql
Normal file
41
backend/migrations/062_exercise_skills_level_rank_index.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- list_exercises mit skill_min_level / skill_max_level: EXISTS auf exercise_skills mit numerischem Stufen-Rang.
|
||||||
|
-- Ausdruck muss mit backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL (Alias „es“) übereinstimmen.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercise_skills_exercise_level_rank
|
||||||
|
ON exercise_skills (
|
||||||
|
exercise_id,
|
||||||
|
(CASE COALESCE(
|
||||||
|
NULLIF(TRIM(LOWER(target_level::text)), ''),
|
||||||
|
NULLIF(TRIM(LOWER(required_level::text)), '')
|
||||||
|
)
|
||||||
|
WHEN 'basis' THEN 1
|
||||||
|
WHEN 'grundlagen' THEN 2
|
||||||
|
WHEN 'aufbau' THEN 3
|
||||||
|
WHEN 'fortgeschritten' THEN 4
|
||||||
|
WHEN 'optimierung' THEN 5
|
||||||
|
WHEN 'einsteiger' THEN 1
|
||||||
|
WHEN 'experte' THEN 5
|
||||||
|
WHEN '1' THEN 1
|
||||||
|
WHEN '2' THEN 2
|
||||||
|
WHEN '3' THEN 3
|
||||||
|
WHEN '4' THEN 4
|
||||||
|
WHEN '5' THEN 5
|
||||||
|
ELSE NULL END)
|
||||||
|
)
|
||||||
|
WHERE (CASE COALESCE(
|
||||||
|
NULLIF(TRIM(LOWER(target_level::text)), ''),
|
||||||
|
NULLIF(TRIM(LOWER(required_level::text)), '')
|
||||||
|
)
|
||||||
|
WHEN 'basis' THEN 1
|
||||||
|
WHEN 'grundlagen' THEN 2
|
||||||
|
WHEN 'aufbau' THEN 3
|
||||||
|
WHEN 'fortgeschritten' THEN 4
|
||||||
|
WHEN 'optimierung' THEN 5
|
||||||
|
WHEN 'einsteiger' THEN 1
|
||||||
|
WHEN 'experte' THEN 5
|
||||||
|
WHEN '1' THEN 1
|
||||||
|
WHEN '2' THEN 2
|
||||||
|
WHEN '3' THEN 3
|
||||||
|
WHEN '4' THEN 4
|
||||||
|
WHEN '5' THEN 5
|
||||||
|
ELSE NULL END) IS NOT NULL;
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
-- Migration 063: Phasen und parallele Streams pro Trainingseinheit (Grundlage Breakout).
|
||||||
|
-- Bestehende Sektionen werden einer Default-whole_group-Phase zugeordnet.
|
||||||
|
-- UNIQUE (training_unit_id, order_index) auf Sektionen entfällt zugunsten
|
||||||
|
-- eindeutiger order_index je Phase bzw. je parallel_stream.
|
||||||
|
|
||||||
|
-- ── Phasen ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS training_unit_phases (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
training_unit_id INT NOT NULL REFERENCES training_units(id) ON DELETE CASCADE,
|
||||||
|
order_index INT NOT NULL,
|
||||||
|
phase_kind VARCHAR(20) NOT NULL CHECK (phase_kind IN ('whole_group', 'parallel')),
|
||||||
|
title VARCHAR(200),
|
||||||
|
guidance_notes TEXT,
|
||||||
|
UNIQUE (training_unit_id, order_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_unit_phases_unit ON training_unit_phases(training_unit_id);
|
||||||
|
|
||||||
|
-- ── Streams innerhalb einer Parallelphase ──────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS training_unit_parallel_streams (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
phase_id INT NOT NULL REFERENCES training_unit_phases(id) ON DELETE CASCADE,
|
||||||
|
order_index INT NOT NULL,
|
||||||
|
title VARCHAR(200),
|
||||||
|
notes TEXT,
|
||||||
|
assigned_trainer_profile_ids JSONB,
|
||||||
|
UNIQUE (phase_id, order_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_unit_parallel_streams_phase
|
||||||
|
ON training_unit_parallel_streams(phase_id);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN training_unit_parallel_streams.assigned_trainer_profile_ids IS
|
||||||
|
'Optionale Co-Trainer-IDs (JSON-Array von Profil-IDs) für diese Teilstrecke; MVP+';
|
||||||
|
|
||||||
|
-- ── Sektionen: Zuordnung zu Phase (gemeinsam) oder Stream (parallel) ─────
|
||||||
|
ALTER TABLE training_unit_sections
|
||||||
|
ADD COLUMN IF NOT EXISTS phase_id INT REFERENCES training_unit_phases(id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN IF NOT EXISTS parallel_stream_id INT REFERENCES training_unit_parallel_streams(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill: je Einheit mit Sektionen eine whole_group-Phase, alle Sektionen dorthin
|
||||||
|
INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title)
|
||||||
|
SELECT tu.id, 0, 'whole_group', NULL
|
||||||
|
FROM training_units tu
|
||||||
|
WHERE EXISTS (SELECT 1 FROM training_unit_sections s WHERE s.training_unit_id = tu.id)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM training_unit_phases p
|
||||||
|
WHERE p.training_unit_id = tu.id AND p.order_index = 0 AND p.phase_kind = 'whole_group'
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE training_unit_sections tus
|
||||||
|
SET phase_id = p.id
|
||||||
|
FROM training_unit_phases p
|
||||||
|
WHERE tus.phase_id IS NULL
|
||||||
|
AND p.training_unit_id = tus.training_unit_id
|
||||||
|
AND p.order_index = 0
|
||||||
|
AND p.phase_kind = 'whole_group';
|
||||||
|
|
||||||
|
-- Alte globale Reihenfolge-Eindeutigkeit pro Einheit entfernen
|
||||||
|
ALTER TABLE training_unit_sections
|
||||||
|
DROP CONSTRAINT IF EXISTS training_unit_sections_training_unit_id_order_index_key;
|
||||||
|
|
||||||
|
-- Genau eine Zielspalte gesetzt: gemeinsame Phase ODER paralleler Stream
|
||||||
|
ALTER TABLE training_unit_sections
|
||||||
|
DROP CONSTRAINT IF EXISTS training_unit_sections_phase_or_stream_chk;
|
||||||
|
|
||||||
|
ALTER TABLE training_unit_sections
|
||||||
|
ADD CONSTRAINT training_unit_sections_phase_or_stream_chk CHECK (
|
||||||
|
(phase_id IS NOT NULL AND parallel_stream_id IS NULL)
|
||||||
|
OR (phase_id IS NULL AND parallel_stream_id IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_phase_order
|
||||||
|
ON training_unit_sections (phase_id, order_index)
|
||||||
|
WHERE phase_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_stream_order
|
||||||
|
ON training_unit_sections (parallel_stream_id, order_index)
|
||||||
|
WHERE parallel_stream_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_unit_sections_phase
|
||||||
|
ON training_unit_sections(phase_id) WHERE phase_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_unit_sections_parallel_stream
|
||||||
|
ON training_unit_sections(parallel_stream_id) WHERE parallel_stream_id IS NOT NULL;
|
||||||
8
backend/migrations/064_training_plan_template_phases.sql
Normal file
8
backend/migrations/064_training_plan_template_phases.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Vorlagen: Phasen/Parallel-Streams wie im Einheiten-Editor (planLoc-Abbild)
|
||||||
|
ALTER TABLE training_plan_template_sections
|
||||||
|
ADD COLUMN IF NOT EXISTS phase_kind VARCHAR(20) NOT NULL DEFAULT 'whole_group',
|
||||||
|
ADD COLUMN IF NOT EXISTS phase_order_index INT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS parallel_stream_order_index INT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN training_plan_template_sections.parallel_stream_order_index IS
|
||||||
|
'NULL = Ganzgruppen-Abschnitt; 0..n = Stream innerhalb paralleler Phase';
|
||||||
11
backend/migrations/065_skills_wiki_karate_relevance.sql
Normal file
11
backend/migrations/065_skills_wiki_karate_relevance.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Migration 065: Wiki-spezifische Felder fuer Fähigkeiten (KarateRelevanz, RelevanzLevel)
|
||||||
|
-- SMW karatetrainer.net; Import mappt in strukturierte Spalten statt nur Freitext in description
|
||||||
|
|
||||||
|
ALTER TABLE skills
|
||||||
|
ADD COLUMN IF NOT EXISTS karate_relevance TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE skills
|
||||||
|
ADD COLUMN IF NOT EXISTS relevance_level SMALLINT CHECK (relevance_level IS NULL OR relevance_level BETWEEN 1 AND 3);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN skills.karate_relevance IS 'Wiki Karate-Relevanz (Plaintext aus SMW Property KarateRelevanz)';
|
||||||
|
COMMENT ON COLUMN skills.relevance_level IS 'Wiki-RelevanzLevel 1–3 (Semantic MediaWiki)';
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- Geplante Gesamt- und Abschnittsdauer; Rahmenprogramm: Fokus/Stil als M:N (wie Trainingsarten/Zielgruppen)
|
||||||
|
|
||||||
|
ALTER TABLE training_units
|
||||||
|
ADD COLUMN IF NOT EXISTS planned_duration_min INT;
|
||||||
|
|
||||||
|
ALTER TABLE training_unit_sections
|
||||||
|
ADD COLUMN IF NOT EXISTS planned_duration_min INT;
|
||||||
|
|
||||||
|
ALTER TABLE training_plan_template_sections
|
||||||
|
ADD COLUMN IF NOT EXISTS planned_duration_min INT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS training_framework_program_focus_areas (
|
||||||
|
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||||
|
focus_area_id INT NOT NULL REFERENCES focus_areas(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (framework_program_id, focus_area_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tfpfa_focus ON training_framework_program_focus_areas(focus_area_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS training_framework_program_style_directions (
|
||||||
|
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||||
|
style_direction_id INT NOT NULL REFERENCES style_directions(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (framework_program_id, style_direction_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tfpsd_style ON training_framework_program_style_directions(style_direction_id);
|
||||||
|
|
||||||
|
INSERT INTO training_framework_program_focus_areas (framework_program_id, focus_area_id)
|
||||||
|
SELECT id, focus_area_id FROM training_framework_programs
|
||||||
|
WHERE focus_area_id IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO training_framework_program_style_directions (framework_program_id, style_direction_id)
|
||||||
|
SELECT id, style_direction_id FROM training_framework_programs
|
||||||
|
WHERE style_direction_id IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
141
backend/migrations/067_ai_prompts_exercise_assistant.sql
Normal file
141
backend/migrations/067_ai_prompts_exercise_assistant.sql
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
-- Migration 067: Konfigurierbare KI-Prompts + Tracking-Feld fuer Uebungs-Zusammenfassung
|
||||||
|
-- Datum: 2026-05-22
|
||||||
|
-- Spec: technical/KI_FEATURES_SPEC.md, AI_PROMPT_SYSTEM_SPEC.md
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- AI PROMPTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_prompts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
display_name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
template TEXT NOT NULL,
|
||||||
|
|
||||||
|
category VARCHAR(50) DEFAULT 'exercise'
|
||||||
|
CHECK (category IN ('exercise', 'training', 'matrix', 'import', 'admin')),
|
||||||
|
|
||||||
|
output_format VARCHAR(10) DEFAULT 'text'
|
||||||
|
CHECK (output_format IN ('text', 'json')),
|
||||||
|
|
||||||
|
output_schema JSONB,
|
||||||
|
is_system_default BOOLEAN DEFAULT false,
|
||||||
|
default_template TEXT,
|
||||||
|
|
||||||
|
active BOOLEAN DEFAULT true,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
|
||||||
|
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompts_slug ON ai_prompts(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompts_category ON ai_prompts(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompts_active ON ai_prompts(active, sort_order);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS ai_prompts_update ON ai_prompts;
|
||||||
|
CREATE TRIGGER ai_prompts_update
|
||||||
|
BEFORE UPDATE ON ai_prompts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRACKING SUMMARY (KI)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE exercises ADD COLUMN IF NOT EXISTS summary_ai_generated BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN exercises.summary_ai_generated IS 'TRUE wenn Kurzbeschreibung zuletzt von KI vorgeschlagen und uebernommen (UI setzt bei manueller Aenderung false)';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SEED PROMPTS (idempotent)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'pipeline',
|
||||||
|
'Mehrstufige Gesamtanalyse',
|
||||||
|
'Master-Schalter fuer die Pipeline-Anzeige.',
|
||||||
|
'PIPELINE_MASTER',
|
||||||
|
'admin',
|
||||||
|
'text',
|
||||||
|
false,
|
||||||
|
'PIPELINE_MASTER',
|
||||||
|
true,
|
||||||
|
-10
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'pipeline');
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'exercise_summary',
|
||||||
|
'Uebungs-Zusammenfassung',
|
||||||
|
'Erzeugt eine kurze Kurzbeschreibung fuer Listen/Galerie.',
|
||||||
|
$s$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
|
||||||
|
|
||||||
|
Anforderungen:
|
||||||
|
- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
|
||||||
|
- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
|
||||||
|
- Sachlich, auf Deutsch
|
||||||
|
|
||||||
|
Uebung: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
|
||||||
|
|
||||||
|
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$,
|
||||||
|
'exercise',
|
||||||
|
'text',
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_summary');
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'exercise_skill_suggestions',
|
||||||
|
'Faehigkeiten-Empfehlungen',
|
||||||
|
'Schlaegt passende Skills mit Stufen/Intensitaet vor (JSON-Ausgabe-Prompt).',
|
||||||
|
$j$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ordne diese Uebung dem globalen Skill-Katalog zu.
|
||||||
|
|
||||||
|
Daten zur Uebung:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext (optional): {{exercise_focus_area}}
|
||||||
|
Ziel (gekuerzt_plain): {{exercise_goal}}
|
||||||
|
Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
|
||||||
|
|
||||||
|
Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden):
|
||||||
|
{{skills_catalog}}
|
||||||
|
|
||||||
|
Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
|
||||||
|
- skill_id: ganze Zahl aus der Liste
|
||||||
|
- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
|
||||||
|
- target_level: derselbe Wertvorrat
|
||||||
|
- intensity: eines von niedrig, mittel, hoch
|
||||||
|
- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
|
||||||
|
|
||||||
|
Beispielformat:
|
||||||
|
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
||||||
|
|
||||||
|
Wenn nichts gut passt, antworte mit [].$j$,
|
||||||
|
'exercise',
|
||||||
|
'json',
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
2
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_skill_suggestions');
|
||||||
125
backend/migrations/068_ai_skill_retrieval_profiles.sql
Normal file
125
backend/migrations/068_ai_skill_retrieval_profiles.sql
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
-- Migration 068: KI Skill-Retrieval-Profile pro Fokusbereich (+ Standardprofil)
|
||||||
|
-- Purpose: Gewichtungen/Quota fuer exercise_ai Skill-Katalog (OpenRouter Kontext)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_skill_retrieval_profiles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_focus_area
|
||||||
|
ON ai_skill_retrieval_profiles (focus_area_id)
|
||||||
|
WHERE focus_area_id IS NOT NULL AND active = TRUE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_default_only
|
||||||
|
ON ai_skill_retrieval_profiles (is_default)
|
||||||
|
WHERE is_default IS TRUE AND active = TRUE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE ai_skill_retrieval_profiles IS
|
||||||
|
'Gewichte/Quota fuer Skill-Katalog in exercise_ai; optional gebunden an focus_areas, genau eine is_default=TRUE';
|
||||||
|
|
||||||
|
INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config)
|
||||||
|
VALUES (
|
||||||
|
NULL,
|
||||||
|
TRUE,
|
||||||
|
'Standard',
|
||||||
|
'Kein/Undefinierter Fokusbereich: neutrale Gewichte mit sanften Caps auf sehr breite Unterkategorien.',
|
||||||
|
TRUE,
|
||||||
|
'{
|
||||||
|
"version": 1,
|
||||||
|
"importance_multiplier": 1,
|
||||||
|
"text_overlap_bonus": 2,
|
||||||
|
"main_slug_weights": { "karate": 1, "allgemeine": 1 },
|
||||||
|
"category_slug_weights": {},
|
||||||
|
"category_max_share": {
|
||||||
|
"kondition": 0.38,
|
||||||
|
"koordination": 0.35
|
||||||
|
},
|
||||||
|
"main_min_share": {},
|
||||||
|
"description_plain_max_len": 160,
|
||||||
|
"karate_relevance_max_len": 72,
|
||||||
|
"keyword_overrides": [
|
||||||
|
{
|
||||||
|
"keywords_any": ["rollenspiel", "szenario", "deesk", "diskussion"],
|
||||||
|
"case_insensitive": true,
|
||||||
|
"patch": {
|
||||||
|
"category_slug_weights": {
|
||||||
|
"psychische_faehigkeiten": 1.65,
|
||||||
|
"soziale_faehigkeiten": 1.65,
|
||||||
|
"kognition": 1.4
|
||||||
|
},
|
||||||
|
"category_max_share": {
|
||||||
|
"kondition": 0.08,
|
||||||
|
"koordination": 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keywords_any": ["befreiung", "haltegriff", "greifer", "umklammer"],
|
||||||
|
"case_insensitive": true,
|
||||||
|
"patch": {
|
||||||
|
"category_slug_weights": {
|
||||||
|
"selbstverteidigung": 2.2,
|
||||||
|
"koordination": 0.9
|
||||||
|
},
|
||||||
|
"main_slug_weights": { "karate": 1.35 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config)
|
||||||
|
SELECT
|
||||||
|
fa.id,
|
||||||
|
FALSE,
|
||||||
|
'Gewaltschutz',
|
||||||
|
'Kaum klassische Sportfaehigkeit; Gewicht auf Deeskalation, Kognition/Soziales; SV-Schwerpunkt per Keywords verstaerken.',
|
||||||
|
TRUE,
|
||||||
|
'{
|
||||||
|
"version": 1,
|
||||||
|
"importance_multiplier": 1,
|
||||||
|
"text_overlap_bonus": 2.25,
|
||||||
|
"main_slug_weights": { "karate": 1.08, "allgemeine": 1.06 },
|
||||||
|
"category_slug_weights": {
|
||||||
|
"kognition": 1.72,
|
||||||
|
"psychische_faehigkeiten": 1.78,
|
||||||
|
"soziale_faehigkeiten": 1.78,
|
||||||
|
"selbstverteidigung": 1.82,
|
||||||
|
"kondition": 0.32,
|
||||||
|
"koordination": 0.4
|
||||||
|
},
|
||||||
|
"category_max_share": {
|
||||||
|
"kondition": 0.12,
|
||||||
|
"koordination": 0.16
|
||||||
|
},
|
||||||
|
"main_min_share": {},
|
||||||
|
"description_plain_max_len": 150,
|
||||||
|
"karate_relevance_max_len": 0,
|
||||||
|
"keyword_overrides": [
|
||||||
|
{
|
||||||
|
"keywords_any": ["befreiung", "haltegriff", "greifer"],
|
||||||
|
"case_insensitive": true,
|
||||||
|
"patch": {
|
||||||
|
"category_slug_weights": {
|
||||||
|
"selbstverteidigung": 3.25,
|
||||||
|
"koordination": 1.08
|
||||||
|
},
|
||||||
|
"main_slug_weights": { "karate": 1.5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'::jsonb
|
||||||
|
FROM focus_areas fa
|
||||||
|
WHERE fa.name = 'Gewaltschutz'
|
||||||
|
AND (fa.status IS NULL OR fa.status = 'active')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ai_skill_retrieval_profiles p
|
||||||
|
WHERE p.focus_area_id = fa.id AND p.active = TRUE
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
10
backend/migrations/069_ai_prompts_default_template.sql
Normal file
10
backend/migrations/069_ai_prompts_default_template.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Migration 069: ai_prompts default_template fuer Ruecksetzen & Transparenz
|
||||||
|
-- Setzt fuer bestehende System-Prompt-Zeilen default_template aus dem aktuellen template,
|
||||||
|
-- sofern noch kein Referenzinhalt gespeichert war (Migration 067 hatte NULL fuer exercise_*).
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE default_template IS NULL
|
||||||
|
AND template IS NOT NULL
|
||||||
|
AND LENGTH(TRIM(template)) > 0;
|
||||||
7
backend/migrations/070_ai_prompts_openrouter_model.sql
Normal file
7
backend/migrations/070_ai_prompts_openrouter_model.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Migration 070: optionales OpenRouter-Modell pro Prompt-Zeile
|
||||||
|
-- Leer/NULL → Umgebungsvariable OPENROUTER_MODEL (wie bisher).
|
||||||
|
|
||||||
|
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS openrouter_model VARCHAR(200);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN ai_prompts.openrouter_model IS
|
||||||
|
'Optional: OpenRouter model id (z.B. anthropic/claude-3.5-haiku); NULL = OPENROUTER_MODEL aus Env';
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
-- Migration 071: KI-Prompt fuer Anleitungs-Ueberarbeitung (Ziel, Durchfuehrung, Vorbereitung, Trainer-Hinweise)
|
||||||
|
-- JSON-Ausgabe; praezise HTML-Fragmente fuer RichTextEditor.
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'exercise_instruction_rewrite',
|
||||||
|
'Anleitung ueberarbeiten',
|
||||||
|
'Ueberarbeitet Ziel, Durchfuehrung, Vorbereitung und Trainer-Hinweise — praezise, strukturiert, ohne Aufblaehen.',
|
||||||
|
$t$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
|
||||||
|
Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
|
||||||
|
|
||||||
|
Stil:
|
||||||
|
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
|
||||||
|
- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
|
||||||
|
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
|
||||||
|
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
|
||||||
|
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
|
||||||
|
|
||||||
|
Format (HTML fuer Rich-Text-Editor):
|
||||||
|
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||||
|
- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
|
||||||
|
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
|
||||||
|
Ziel (Plaintext, Ausgang): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
|
||||||
|
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
|
||||||
|
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
|
||||||
|
{
|
||||||
|
"goal": "<p>…</p>",
|
||||||
|
"execution": "<ol><li>…</li></ol>",
|
||||||
|
"preparation": "<p>…</p> oder \"\"",
|
||||||
|
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$,
|
||||||
|
'exercise',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["goal","execution","preparation","trainer_notes"],"properties":{"goal":{"type":"string"},"execution":{"type":"string"},"preparation":{"type":"string"},"trainer_notes":{"type":"string"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
3
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_instruction_rewrite');
|
||||||
|
|
||||||
|
-- Referenztext fuer Admin-Ruecksetzen (wie 069)
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template
|
||||||
|
WHERE slug = 'exercise_instruction_rewrite'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
-- Migration 072: KI-Prompt Planungs-Übungssuche — LLM-Rerank (Phase 2)
|
||||||
|
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §14
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_search_rank',
|
||||||
|
'Planungs-Übungssuche Rerank',
|
||||||
|
'Ordnet Kandidaten für die Trainingsplanung nach Intent und Kontext; nur IDs aus candidates_json.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer bei der Trainingsplanung.
|
||||||
|
Ordne die vorgegebenen Übungs-Kandidaten nach Eignung für die aktuelle Planungssituation.
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Verwende NUR exercise_id-Werte aus candidates_json (keine erfundenen IDs).
|
||||||
|
- Berücksichtige search_query, intent, planning_context_json und target_profile_json.
|
||||||
|
- Bewerte anhand von Titel, summary, goal und skills jedes Kandidaten.
|
||||||
|
- Gib maximal {{result_limit}} IDs in sinnvoller Reihenfolge zurück (beste zuerst).
|
||||||
|
- Kurze Begründung pro Top-Treffer auf Deutsch (1 Satz, sachlich).
|
||||||
|
|
||||||
|
Intent-Hinweise:
|
||||||
|
- suggest_next / progression_next: logische Fortsetzung, Progression, passende Skills
|
||||||
|
- deepen_exercise: Vertiefung zum Anker, ähnlicher Fokus
|
||||||
|
- continue_plan_goal: schließt an bisherigen Plan und Skill-Lücken an
|
||||||
|
- free_search: Freitext-Relevanz
|
||||||
|
|
||||||
|
Kontext:
|
||||||
|
Intent: {{intent}}
|
||||||
|
Suchanfrage: {{search_query}}
|
||||||
|
Planung: {{planning_context_json}}
|
||||||
|
Zielprofil: {{target_profile_json}}
|
||||||
|
|
||||||
|
Kandidaten (JSON):
|
||||||
|
{{candidates_json}}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON (kein Text davor/danach):
|
||||||
|
{
|
||||||
|
"ranked_ids": [123, 456],
|
||||||
|
"reasons": { "123": "…", "456": "…" }
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["ranked_ids"],"properties":{"ranked_ids":{"type":"array","items":{"type":"integer"}},"reasons":{"type":"object"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
10
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_rank');
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template
|
||||||
|
WHERE slug = 'planning_exercise_search_rank'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Migration 073: KI-Prompt Planungs-Übungssuche — Intent/Query-Overlay (P1)
|
||||||
|
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_search_intent',
|
||||||
|
'Planungs-Übungssuche Intent',
|
||||||
|
'Strukturiert Freitext-Anfrage in Intent, Szenario und Katalog-Hints für Erwartungsprofil-Overlay.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung.
|
||||||
|
Analysiere die Suchanfrage im Kontext der Einheit und des bisherigen Plans.
|
||||||
|
|
||||||
|
Ziel: JSON für ein Erwartungsprofil-Overlay (Fähigkeiten, Fokus, Stil …) — NICHT Übungs-IDs erfinden.
|
||||||
|
|
||||||
|
Szenario-Klassen (scenario):
|
||||||
|
- preset_next: nur „nächste Übung“ ohne Zusatz — selten bei Freitext
|
||||||
|
- progression: Progressionsgraph / Pfad / Folgeübung im Graph
|
||||||
|
- deepen: Vertiefung zur Anker-Übung
|
||||||
|
- continue_plan: baut auf bisherigem Plan der Einheit auf
|
||||||
|
- additive_constraint: Plan beibehalten UND zusätzliche Anforderung (z. B. „außerdem Schnellkraft“)
|
||||||
|
- free_search: offene Stichwortsuche / neues Thema
|
||||||
|
|
||||||
|
Intent (intent): suggest_next | progression_next | deepen_exercise | continue_plan_goal | free_search
|
||||||
|
|
||||||
|
emphasis:
|
||||||
|
- additive: Zusatz zur bestehenden Planung (Default bei „zusätzlich/auch/dazu“)
|
||||||
|
- replace: Suchanfrage soll Schwerpunkt eher ersetzen
|
||||||
|
- neutral: nur leichte Gewichtung
|
||||||
|
|
||||||
|
Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung).
|
||||||
|
Bei requires_partner: true/false/null wenn Partnerbezug erkennbar.
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Suchanfrage: {{search_query}}
|
||||||
|
Heuristik-Intent: {{heuristic_intent}}
|
||||||
|
Szenario-Hinweis (Server): {{scenario_hint}}
|
||||||
|
Planungskontext: {{planning_context_json}}
|
||||||
|
Basis-Zielprofil (deterministisch): {{target_profile_json}}
|
||||||
|
|
||||||
|
Kataloge (Auszug — nur diese Namen/IDs verwenden):
|
||||||
|
Skills: {{skills_catalog_json}}
|
||||||
|
Fokus: {{focus_areas_catalog_json}}
|
||||||
|
Trainingsstil: {{training_types_catalog_json}}
|
||||||
|
Stilrichtung: {{style_directions_catalog_json}}
|
||||||
|
Zielgruppe: {{target_groups_catalog_json}}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"intent": "continue_plan_goal",
|
||||||
|
"scenario": "additive_constraint",
|
||||||
|
"skill_hints": [{"name": "Schnellkraft", "weight": 1.0}],
|
||||||
|
"focus_hints": [],
|
||||||
|
"style_hints": [],
|
||||||
|
"training_type_hints": [],
|
||||||
|
"target_group_hints": [],
|
||||||
|
"requires_partner": null,
|
||||||
|
"emphasis": "additive",
|
||||||
|
"rationale": "Kurz auf Deutsch, 1 Satz"
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["intent","scenario"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
11
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_intent');
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template
|
||||||
|
WHERE slug = 'planning_exercise_search_intent'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
-- Migration 074: KI-Prompt Planungs-Übungssuche — Erwartungsprofil aus Planungskontext (Preset)
|
||||||
|
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_expectation_profile',
|
||||||
|
'Planungs-Übungssuche Erwartungsprofil',
|
||||||
|
'Leitet aus Einheit, Abschnitt, Anker und bisherigem Plan ein Erwartungsprofil für die nächste Übung ab (ohne Freitext-Anfrage).',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung.
|
||||||
|
Der Trainer wählt „nächste Übung aus Kontext“ — es gibt KEINE zusätzliche Freitext-Suchanfrage.
|
||||||
|
|
||||||
|
Deine Aufgabe: Aus dem Planungskontext und dem deterministischen Basis-Zielprofil ein präzises Erwartungsprofil ableiten:
|
||||||
|
- Was soll die nächste Übung fachlich leisten (Fortsetzen, Vertiefen, Lücke schließen, Abwechslung)?
|
||||||
|
- Welche Fähigkeiten, Fokus-Bereiche, Trainingsstile passen dazu?
|
||||||
|
- Berücksichtige: Rahmen/Einheit, Abschnittsziel (guidance_notes), letzte Übung im Abschnitt, Anker-Übung, Skill-Profile Einheit vs. Abschnitt, Skill-Lücken im Basisprofil.
|
||||||
|
|
||||||
|
Intent (intent): meist suggest_next oder continue_plan_goal; progression_next nur wenn Progressionsgraph/Anker klar nahelegt; deepen_exercise nur bei klarer Vertiefungslage.
|
||||||
|
|
||||||
|
continuation (optional, Kurzlabel):
|
||||||
|
- build_on_section: nahtlos an Abschnitt/letzte Übung anknüpfen
|
||||||
|
- close_skill_gap: fehlende Fähigkeiten aus Plan/Rahmen nachziehen
|
||||||
|
- deepen_anchor: Anker-Übung vertiefen
|
||||||
|
- variety: bewusst variieren nach bisherigem Block
|
||||||
|
- balance_load: Belastung ausgleichen / Tempo wechseln
|
||||||
|
|
||||||
|
Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung).
|
||||||
|
emphasis: fast immer additive (baut auf Basisprofil auf), nur replace wenn Kontext eindeutig neuen Schwerpunkt verlangt.
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Heuristik-Intent: {{heuristic_intent}}
|
||||||
|
Planungskontext: {{planning_context_json}}
|
||||||
|
Basis-Zielprofil (deterministisch): {{target_profile_json}}
|
||||||
|
|
||||||
|
Kataloge (Auszug — nur diese Namen/IDs verwenden):
|
||||||
|
Skills: {{skills_catalog_json}}
|
||||||
|
Fokus: {{focus_areas_catalog_json}}
|
||||||
|
Trainingsstil: {{training_types_catalog_json}}
|
||||||
|
Stilrichtung: {{style_directions_catalog_json}}
|
||||||
|
Zielgruppe: {{target_groups_catalog_json}}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"intent": "suggest_next",
|
||||||
|
"scenario": "preset_next",
|
||||||
|
"continuation": "build_on_section",
|
||||||
|
"skill_hints": [{"name": "Kime", "weight": 0.9}],
|
||||||
|
"focus_hints": [],
|
||||||
|
"style_hints": [],
|
||||||
|
"training_type_hints": [],
|
||||||
|
"target_group_hints": [],
|
||||||
|
"requires_partner": null,
|
||||||
|
"emphasis": "additive",
|
||||||
|
"rationale": "Kurz auf Deutsch, 1–2 Sätze: warum diese nächste Übung sinnvoll ist"
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["intent","scenario","rationale"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"continuation":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
12
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_expectation_profile');
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template
|
||||||
|
WHERE slug = 'planning_exercise_expectation_profile'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
-- Migration 075: Planungs-KI Phase E — Semantik-Enrichment + Pfad-QA Prompts
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_query_semantics',
|
||||||
|
'Planungs-Übungssuche Semantik',
|
||||||
|
'Erweitert deterministisches Semantic Brief um must/exclude phrases und Entwicklungsbogen.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer bei der semantischen Analyse von Planungs-Anfragen.
|
||||||
|
|
||||||
|
Ziel: JSON für ein Semantic Brief — präzise Kernbegriffe, Ausschlüsse, Entwicklungsbogen.
|
||||||
|
Nutze das bestehende Brief als Basis; ergänze/verfeinere, ersetze aber keine eindeutige Technik-Identität.
|
||||||
|
|
||||||
|
Anfrage: {{search_query}}
|
||||||
|
Bestehendes Brief (deterministisch): {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- must_phrases: konkrete Technik-/Themen-Phrasen aus der Anfrage (z. B. "mae geri", nicht nur "geri")
|
||||||
|
- exclude_phrases: konkurrierende Techniken/Themen, die NICHT gemeint sind
|
||||||
|
- development_arc: geordnete Phasen aus: einstieg, grundlage, vertiefung, anwendung, perfektion
|
||||||
|
- semantic_strength: 0.0–1.0 (höher bei spezifischer Technik/Thema)
|
||||||
|
- primary_topic: Hauptthema in wenigen Worten
|
||||||
|
- topic_type: technique | focus | method | skill | general
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"topic_type": "technique",
|
||||||
|
"must_phrases": ["mae geri"],
|
||||||
|
"exclude_phrases": ["mawashi geri", "sakuto geri"],
|
||||||
|
"development_arc": ["einstieg", "grundlage", "vertiefung", "perfektion"],
|
||||||
|
"semantic_strength": 0.9,
|
||||||
|
"rationale": "Kurz auf Deutsch"
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"must_phrases":{"type":"array"},"exclude_phrases":{"type":"array"},"development_arc":{"type":"array"},"semantic_strength":{"type":"number"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
12
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_query_semantics');
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_path_qa',
|
||||||
|
'Planungs-Pfad QA',
|
||||||
|
'Semantische Qualitätsprüfung eines vorgeschlagenen Übungspfads inkl. Lücken und Brücken.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte?
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"]
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["overall_ok"],"properties":{"overall_ok":{"type":"boolean"},"quality_score":{"type":"number"},"issues":{"type":"array"},"sequence_notes":{"type":"array"},"recommendations":{"type":"array"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
13
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_path_qa');
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET default_template = template
|
||||||
|
WHERE slug IN ('planning_exercise_query_semantics', 'planning_exercise_path_qa')
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- Migration 076: Planungs-Pfad-QA — Neuordnung + KI-Lückenfüller (Phase E2)
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"]
|
||||||
|
}$t$,
|
||||||
|
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"]
|
||||||
|
}$t$
|
||||||
|
WHERE slug = 'planning_exercise_path_qa';
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
-- Migration 077: Planungs-Pfad-QA — strukturierte Neuanlage-Vorschläge (Phase E3)
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"],
|
||||||
|
"suggested_new_exercises": [
|
||||||
|
{
|
||||||
|
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||||
|
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||||
|
"phase": "vertiefung",
|
||||||
|
"insert_after_step_index": 2,
|
||||||
|
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"],
|
||||||
|
"suggested_new_exercises": [
|
||||||
|
{
|
||||||
|
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||||
|
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||||
|
"phase": "vertiefung",
|
||||||
|
"insert_after_step_index": 2,
|
||||||
|
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
output_schema = '{"type":"object","required":["overall_ok"],"properties":{"overall_ok":{"type":"boolean"},"quality_score":{"type":"number"},"issues":{"type":"array"},"sequence_notes":{"type":"array"},"recommendations":{"type":"array"},"ordered_step_indices":{"type":"array"},"suggested_new_exercises":{"type":"array"}}}'::jsonb
|
||||||
|
WHERE slug = 'planning_exercise_path_qa';
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Migration 078: Planungs-KI Phase F — Progressions-Roadmap Prompts (Zielanalyse + Roadmap)
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_progression_goal_analysis',
|
||||||
|
'Progressions-Roadmap Zielanalyse',
|
||||||
|
'Phase A: Ist-/Soll-Zustand und Erfolgskriterien für einen Progressionsgraphen (ohne Gruppenkontext).',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Wichtig: Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
|
||||||
|
"target_state": "Konkreter Zielzustand der Progression",
|
||||||
|
"success_criteria": ["messbare Kriterien"],
|
||||||
|
"constraints": { "partner_required": false }
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"primary_topic":{"type":"string"},"target_state":{"type":"string"},"success_criteria":{"type":"array"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
14
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_goal_analysis');
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_progression_roadmap',
|
||||||
|
'Progressions-Roadmap Major Steps',
|
||||||
|
'Phase B: 8–12 micro_objectives, Konsolidierung auf N major_steps.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Anzahl Major Steps (N): {{max_steps}}
|
||||||
|
|
||||||
|
Erzeuge zuerst 8–12 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps.
|
||||||
|
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion — in sinnvoller Reihenfolge (Grundlagen vor Perfektion).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"micro_objectives": [
|
||||||
|
{ "id": "m1", "phase": "grundlage", "title": "…", "weight": 0.9, "depends_on": [] }
|
||||||
|
],
|
||||||
|
"major_steps": [
|
||||||
|
{ "index": 0, "phase": "grundlage", "learning_goal": "…", "consolidates": ["m1","m2"], "rationale": "…" }
|
||||||
|
],
|
||||||
|
"consolidation_notes": ["…"]
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"micro_objectives":{"type":"array"},"major_steps":{"type":"array"},"consolidation_notes":{"type":"array"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
15
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_roadmap');
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET default_template = template
|
||||||
|
WHERE slug IN ('planning_progression_goal_analysis', 'planning_progression_roadmap')
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
286
backend/migrations/078_club_features_and_plans.sql
Normal file
286
backend/migrations/078_club_features_and_plans.sql
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
-- Migration 078: Vereins-Feature-Registry (Mitai-v9c-Pattern) + club_plans/subscriptions
|
||||||
|
-- Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md (M1)
|
||||||
|
-- Legacy 001 (SERIAL features, profile tier_limits) wird archiviert, nicht gelöscht.
|
||||||
|
|
||||||
|
-- ── 1. Legacy-Tabellen archivieren (nur alte Struktur) ─────────────────────
|
||||||
|
DO $migration$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features'
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'name'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type'
|
||||||
|
) THEN
|
||||||
|
-- Nach abgebrochenem Erstversuch kann features_legacy_001 schon existieren
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features_legacy_001'
|
||||||
|
) THEN
|
||||||
|
DROP TABLE features;
|
||||||
|
ELSE
|
||||||
|
ALTER TABLE features RENAME TO features_legacy_001;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'tier_limits'
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'tier_limits' AND column_name = 'tier'
|
||||||
|
) THEN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'tier_limits_legacy_001'
|
||||||
|
) THEN
|
||||||
|
DROP TABLE tier_limits;
|
||||||
|
ELSE
|
||||||
|
ALTER TABLE tier_limits RENAME TO tier_limits_legacy_001;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'user_feature_usage'
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'user_feature_usage' AND column_name = 'profile_id'
|
||||||
|
) THEN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'user_feature_usage_legacy_001'
|
||||||
|
) THEN
|
||||||
|
DROP TABLE user_feature_usage;
|
||||||
|
ELSE
|
||||||
|
ALTER TABLE user_feature_usage RENAME TO user_feature_usage_legacy_001;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$migration$;
|
||||||
|
|
||||||
|
-- ── 2. Feature-Registry (TEXT-PK, app=shinkan) ────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS features (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
app TEXT NOT NULL DEFAULT 'shinkan',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT NOT NULL DEFAULT 'content',
|
||||||
|
limit_type TEXT NOT NULL DEFAULT 'count'
|
||||||
|
CHECK (limit_type IN ('count', 'boolean')),
|
||||||
|
reset_period TEXT NOT NULL DEFAULT 'never'
|
||||||
|
CHECK (reset_period IN ('never', 'daily', 'monthly')),
|
||||||
|
default_limit INTEGER,
|
||||||
|
enforcement_subject TEXT NOT NULL DEFAULT 'club'
|
||||||
|
CHECK (enforcement_subject IN ('club', 'profile', 'portal')),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_features_app ON features(app) WHERE active = true;
|
||||||
|
|
||||||
|
-- ── 3. Vereins-Produkte ─────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS club_plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
price_monthly_cents INTEGER,
|
||||||
|
price_yearly_cents INTEGER,
|
||||||
|
stripe_price_id_monthly TEXT,
|
||||||
|
stripe_price_id_yearly TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_plan_limits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
limit_value INTEGER,
|
||||||
|
UNIQUE (plan_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_plan_limits_plan ON club_plan_limits(plan_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_subscriptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES club_plans(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'trial', 'past_due', 'cancelled')),
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ends_at TIMESTAMPTZ,
|
||||||
|
trial_ends_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_subscriptions_plan ON club_subscriptions(plan_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_feature_overrides (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
limit_value INTEGER NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_access_grants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT REFERENCES club_plans(id) ON DELETE SET NULL,
|
||||||
|
feature_id TEXT REFERENCES features(id) ON DELETE SET NULL,
|
||||||
|
grant_limit INTEGER,
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ends_at TIMESTAMPTZ NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_access_grants_club ON club_access_grants(club_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_access_grants_window ON club_access_grants(club_id, starts_at, ends_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_feature_usage (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reset_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_feature_usage_club ON club_feature_usage(club_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_feature_usage_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_feature_usage_events_club
|
||||||
|
ON club_feature_usage_events(club_id, created_at DESC);
|
||||||
|
|
||||||
|
-- ── 4. Seed: Features ─────────────────────────────────────────────────────
|
||||||
|
INSERT INTO features (id, app, name, description, category, limit_type, reset_period, default_limit, enforcement_subject)
|
||||||
|
VALUES
|
||||||
|
('exercises', 'shinkan', 'Übungen', 'Anzahl Übungen im Verein (Bestand)', 'content', 'count', 'never', 100, 'club'),
|
||||||
|
('exercise_media', 'shinkan', 'Medien-Uploads', 'Medien-Uploads pro Monat', 'content', 'count', 'monthly', 20, 'club'),
|
||||||
|
('training_units', 'shinkan', 'Trainingseinheiten', 'Trainingseinheiten pro Monat', 'planning', 'count', 'monthly', 40, 'club'),
|
||||||
|
('training_programs', 'shinkan', 'Trainingsprogramme', 'Module und Rahmenprogramme (Bestand)', 'planning', 'count', 'never', 5, 'club'),
|
||||||
|
('training_groups', 'shinkan', 'Trainingsgruppen', 'Anzahl Trainingsgruppen', 'org', 'count', 'never', 10, 'club'),
|
||||||
|
('active_members', 'shinkan', 'Aktive Mitglieder', 'Anzahl aktiver Vereinsmitglieder', 'org', 'count', 'never', 25, 'club'),
|
||||||
|
('ai_calls', 'shinkan', 'KI-Aufrufe', 'KI-Aufrufe pro Monat (Suggest, Regenerate, Planung)', 'ai', 'count', 'monthly', 0, 'club'),
|
||||||
|
('ai_pipeline', 'shinkan', 'KI-Pipeline', 'Erweiterte KI-Batch-Pipelines', 'ai', 'boolean', 'never', 0, 'club'),
|
||||||
|
('wiki_import', 'shinkan', 'Wiki-Import', 'MediaWiki-Import (Plattform)', 'integration', 'boolean', 'never', 0, 'portal'),
|
||||||
|
('data_export', 'shinkan', 'Daten-Export', 'Export-Funktionen', 'integration', 'boolean', 'never', 0, 'club')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 5. Seed: Pläne ──────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO club_plans (id, name, description, sort_order, active)
|
||||||
|
VALUES
|
||||||
|
('free', 'Free', 'Einstieg für Vereine', 0, true),
|
||||||
|
('verein_starter', 'Verein Starter', 'Erweiterte Kontingente', 10, true),
|
||||||
|
('verein_pro', 'Verein Pro', 'Hohe Limits und KI-Kontingent', 20, true),
|
||||||
|
('pilot', 'Pilot', 'Pilotverein mit großzügigen Limits', 5, true)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Plan-Limits: free
|
||||||
|
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||||
|
SELECT 'free', f.id,
|
||||||
|
CASE f.id
|
||||||
|
WHEN 'exercises' THEN 100
|
||||||
|
WHEN 'exercise_media' THEN 20
|
||||||
|
WHEN 'training_units' THEN 40
|
||||||
|
WHEN 'training_programs' THEN 5
|
||||||
|
WHEN 'training_groups' THEN 10
|
||||||
|
WHEN 'active_members' THEN 25
|
||||||
|
WHEN 'ai_calls' THEN 0
|
||||||
|
WHEN 'ai_pipeline' THEN 0
|
||||||
|
WHEN 'wiki_import' THEN 0
|
||||||
|
WHEN 'data_export' THEN 0
|
||||||
|
END
|
||||||
|
FROM features f
|
||||||
|
WHERE f.app = 'shinkan'
|
||||||
|
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Plan-Limits: verein_starter
|
||||||
|
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||||
|
SELECT 'verein_starter', f.id,
|
||||||
|
CASE f.id
|
||||||
|
WHEN 'exercises' THEN 500
|
||||||
|
WHEN 'exercise_media' THEN 80
|
||||||
|
WHEN 'training_units' THEN 200
|
||||||
|
WHEN 'training_programs' THEN 30
|
||||||
|
WHEN 'training_groups' THEN 30
|
||||||
|
WHEN 'active_members' THEN 80
|
||||||
|
WHEN 'ai_calls' THEN 30
|
||||||
|
WHEN 'ai_pipeline' THEN 0
|
||||||
|
WHEN 'wiki_import' THEN 0
|
||||||
|
WHEN 'data_export' THEN 1
|
||||||
|
END
|
||||||
|
FROM features f
|
||||||
|
WHERE f.app = 'shinkan'
|
||||||
|
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Plan-Limits: verein_pro (NULL = unbegrenzt wo sinnvoll)
|
||||||
|
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||||
|
SELECT 'verein_pro', f.id,
|
||||||
|
CASE f.id
|
||||||
|
WHEN 'exercises' THEN NULL
|
||||||
|
WHEN 'exercise_media' THEN 300
|
||||||
|
WHEN 'training_units' THEN NULL
|
||||||
|
WHEN 'training_programs' THEN NULL
|
||||||
|
WHEN 'training_groups' THEN NULL
|
||||||
|
WHEN 'active_members' THEN NULL
|
||||||
|
WHEN 'ai_calls' THEN 200
|
||||||
|
WHEN 'ai_pipeline' THEN 1
|
||||||
|
WHEN 'wiki_import' THEN 0
|
||||||
|
WHEN 'data_export' THEN 1
|
||||||
|
END
|
||||||
|
FROM features f
|
||||||
|
WHERE f.app = 'shinkan'
|
||||||
|
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Plan-Limits: pilot
|
||||||
|
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||||
|
SELECT 'pilot', f.id,
|
||||||
|
CASE f.id
|
||||||
|
WHEN 'exercises' THEN NULL
|
||||||
|
WHEN 'exercise_media' THEN NULL
|
||||||
|
WHEN 'training_units' THEN NULL
|
||||||
|
WHEN 'training_programs' THEN NULL
|
||||||
|
WHEN 'training_groups' THEN NULL
|
||||||
|
WHEN 'active_members' THEN NULL
|
||||||
|
WHEN 'ai_calls' THEN 100
|
||||||
|
WHEN 'ai_pipeline' THEN 1
|
||||||
|
WHEN 'wiki_import' THEN 0
|
||||||
|
WHEN 'data_export' THEN 1
|
||||||
|
END
|
||||||
|
FROM features f
|
||||||
|
WHERE f.app = 'shinkan'
|
||||||
|
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 6. Backfill: bestehende Vereine → Plan free ───────────────────────────
|
||||||
|
INSERT INTO club_subscriptions (club_id, plan_id, status)
|
||||||
|
SELECT c.id, 'free', 'active'
|
||||||
|
FROM clubs c
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM club_subscriptions cs WHERE cs.club_id = c.id
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- Migration 079: Planungs-KI Phase F — Stufenspezifikation (Prompt in ai_prompts, nicht im Code)
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_progression_stage_spec',
|
||||||
|
'Progressions-Roadmap Stufenspezifikation',
|
||||||
|
'Phase C: Belastungsprofil, Übungstyp und Erfolgskriterien je Major Step.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Major Steps: {{major_steps_json}}
|
||||||
|
|
||||||
|
Für jeden Major Step: messbares Lernziel, load_profile (z. B. koordination, präzision, kraft), exercise_type (kihon_einzel, partner_drill, kombination, kraft_auxiliary), success_criteria, anti_patterns (z. B. reine Kraft ohne Technikbezug).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"stage_specs": [
|
||||||
|
{
|
||||||
|
"major_step_index": 0,
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["koordination", "gleichgewicht"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["…"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"stage_specs":{"type":"array"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
16
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_stage_spec');
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
225
backend/migrations/079_capabilities.sql
Normal file
225
backend/migrations/079_capabilities.sql
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
-- Migration 079: Capability-Registry + Rollen-Grants (M3 / CAPABILITY_CATALOG.v1.md C1)
|
||||||
|
-- Account-Gates und Enforcement in Python (account_lifecycle.py, capabilities.py).
|
||||||
|
-- Voraussetzung: Migration 078 (features.id TEXT). Kein FK auf features — vermeidet
|
||||||
|
-- Startup-Abbruch wenn 078 noch aussteht oder features-Schema driftet (001 vs v9c).
|
||||||
|
|
||||||
|
DO $migration$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Migration 079: features-Tabelle nicht v9c (limit_type fehlt). Zuerst 078_club_features_and_plans anwenden.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$migration$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS capabilities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
min_account_state TEXT NOT NULL DEFAULT 'active_member'
|
||||||
|
CHECK (min_account_state IN (
|
||||||
|
'unverified', 'verified_pending_club', 'active_member', 'platform_admin'
|
||||||
|
)),
|
||||||
|
linked_feature_id TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_capabilities_domain ON capabilities(domain) WHERE active = true;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_role_capability_grants (
|
||||||
|
role_code TEXT NOT NULL,
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (role_code, capability_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_role_cap_grants_cap ON club_role_capability_grants(capability_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS portal_role_capability_grants (
|
||||||
|
portal_role TEXT NOT NULL,
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (portal_role, capability_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Seed: Capabilities (v1 Katalog §5) ───────────────────────────────────────
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) VALUES
|
||||||
|
('account.settings.read', 'Einstellungen lesen', 'account', 'unverified', NULL),
|
||||||
|
('account.settings.update', 'Einstellungen ändern', 'account', 'unverified', NULL),
|
||||||
|
('account.password.change', 'Passwort ändern', 'account', 'unverified', NULL),
|
||||||
|
('account.resend_verification', 'Verifizierung erneut senden', 'account', 'unverified', NULL),
|
||||||
|
('club.directory.read', 'Vereinsverzeichnis', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.join_request.create', 'Vereinsbeitritt beantragen', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.join_request.withdraw', 'Beitrittsantrag zurückziehen', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.join_request.read_own', 'Eigene Beitrittsanträge', 'club', 'verified_pending_club', NULL),
|
||||||
|
('org.club.read', 'Vereine lesen', 'org', 'active_member', NULL),
|
||||||
|
('org.club.create', 'Verein anlegen', 'org', 'platform_admin', NULL),
|
||||||
|
('org.club.update', 'Verein bearbeiten', 'org', 'active_member', NULL),
|
||||||
|
('org.club.delete', 'Verein löschen', 'org', 'platform_admin', NULL),
|
||||||
|
('org.structure.manage', 'Vereinsstruktur verwalten', 'org', 'active_member', 'training_groups'),
|
||||||
|
('org.members.read', 'Mitgliederliste', 'org', 'active_member', NULL),
|
||||||
|
('org.members.manage', 'Mitglieder verwalten', 'org', 'active_member', 'active_members'),
|
||||||
|
('org.members.directory', 'Mitglieder-Verzeichnis', 'org', 'active_member', NULL),
|
||||||
|
('org.join_request.review', 'Beitrittsanträge prüfen', 'org', 'active_member', NULL),
|
||||||
|
('org.inbox.read', 'Posteingang', 'org', 'active_member', NULL),
|
||||||
|
('exercises.read', 'Übungen lesen', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.create', 'Übung anlegen', 'exercises', 'active_member', 'exercises'),
|
||||||
|
('exercises.update', 'Übung bearbeiten', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.delete', 'Übung löschen', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.bulk_metadata', 'Übungen Stapel-Metadaten', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.ai.suggest', 'KI-Vorschlag Übung', 'exercises', 'active_member', 'ai_calls'),
|
||||||
|
('exercises.ai.regenerate', 'KI neu generieren', 'exercises', 'active_member', 'ai_calls'),
|
||||||
|
('exercises.media.read', 'Übungsmedien lesen', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.media.upload', 'Übungsmedien hochladen', 'exercises', 'active_member', 'exercise_media'),
|
||||||
|
('exercises.variants.manage', 'Übungsvarianten', 'exercises', 'active_member', NULL),
|
||||||
|
('media.library.read', 'Medienbibliothek lesen', 'media', 'active_member', NULL),
|
||||||
|
('media.library.upload', 'Medienbibliothek Upload', 'media', 'active_member', 'exercise_media'),
|
||||||
|
('media.library.update', 'Medienbibliothek bearbeiten', 'media', 'active_member', NULL),
|
||||||
|
('media.library.lifecycle', 'Medien-Lifecycle', 'media', 'active_member', NULL),
|
||||||
|
('media.rights.declare', 'Medienrechte erklären', 'media', 'active_member', NULL),
|
||||||
|
('media.admin.rights_review', 'Medienrechte Review (Plattform)', 'media', 'platform_admin', NULL),
|
||||||
|
('modules.read', 'Trainingsmodule lesen', 'modules', 'active_member', NULL),
|
||||||
|
('modules.create', 'Trainingsmodul anlegen', 'modules', 'active_member', 'training_programs'),
|
||||||
|
('modules.update', 'Trainingsmodul bearbeiten', 'modules', 'active_member', NULL),
|
||||||
|
('modules.delete', 'Trainingsmodul löschen', 'modules', 'active_member', NULL),
|
||||||
|
('framework.read', 'Rahmenprogramme lesen', 'framework', 'active_member', NULL),
|
||||||
|
('framework.create', 'Rahmenprogramm anlegen', 'framework', 'active_member', 'training_programs'),
|
||||||
|
('framework.update', 'Rahmenprogramm bearbeiten', 'framework', 'active_member', NULL),
|
||||||
|
('framework.delete', 'Rahmenprogramm löschen', 'framework', 'active_member', NULL),
|
||||||
|
('plan_templates.read', 'Planungsvorlagen lesen', 'planning', 'active_member', NULL),
|
||||||
|
('plan_templates.manage', 'Planungsvorlagen verwalten', 'planning', 'active_member', NULL),
|
||||||
|
('progression.read', 'Progressionspfade lesen', 'progression', 'active_member', NULL),
|
||||||
|
('progression.manage', 'Progressionspfade verwalten', 'progression', 'active_member', NULL),
|
||||||
|
('planning.calendar.read', 'Planungskalender lesen', 'planning', 'active_member', NULL),
|
||||||
|
('planning.units.create', 'Trainingseinheit anlegen', 'planning', 'active_member', 'training_units'),
|
||||||
|
('planning.units.update', 'Trainingseinheit bearbeiten', 'planning', 'active_member', NULL),
|
||||||
|
('planning.units.delete', 'Trainingseinheit löschen', 'planning', 'active_member', NULL),
|
||||||
|
('planning.units.run', 'Training durchführen', 'planning', 'active_member', NULL),
|
||||||
|
('planning.coach.execute', 'Coach ausführen', 'planning', 'active_member', NULL),
|
||||||
|
('planning.ai.suggest', 'Planungs-KI Suggest', 'planning', 'active_member', 'ai_calls'),
|
||||||
|
('planning.ai.progression_path', 'Planungs-KI Progressionspfad', 'planning', 'active_member', 'ai_calls'),
|
||||||
|
('skills.catalog.read', 'Fähigkeitenkatalog', 'skills', 'active_member', NULL),
|
||||||
|
('skills.discovery.read', 'Fähigkeiten-Discovery', 'skills', 'active_member', NULL),
|
||||||
|
('skill_profiles.read', 'Skill-Profile lesen', 'skills', 'active_member', NULL),
|
||||||
|
('governance.content_report.create', 'Inhalt melden', 'governance', 'active_member', NULL),
|
||||||
|
('governance.content_report.review', 'Meldungen prüfen', 'governance', 'active_member', NULL),
|
||||||
|
('platform.admin.access', 'Plattform-Admin-Bereich', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.users.manage', 'Nutzer verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.catalogs.manage', 'Kataloge verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.maturity_models.manage', 'Reifegradmodelle', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.wiki_import.execute', 'Wiki-Import', 'platform', 'platform_admin', 'wiki_import'),
|
||||||
|
('platform.ai_prompts.manage', 'KI-Prompts verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.exercise_enrichment.execute', 'Übungs-Anreicherung KI', 'platform', 'platform_admin', 'ai_calls'),
|
||||||
|
('platform.user_content.moderate', 'Nutzer-Inhalte moderieren', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.legal_documents.manage', 'Rechtstexte verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.media_storage.manage', 'Medienspeicher verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.club_creation.approve', 'Vereinsgründung freigeben', 'platform', 'platform_admin', NULL)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Vereinsrollen-Grants (§6 — nur eingeschränkte Capabilities) ─────────────
|
||||||
|
-- Konvention: keine Grant-Zeile = alle aktiven Mitglieder (min_account_state reicht).
|
||||||
|
|
||||||
|
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||||
|
SELECT r.role_code, c.id
|
||||||
|
FROM (VALUES
|
||||||
|
('club_admin', 'org.structure.manage'),
|
||||||
|
('division_lead', 'org.structure.manage'),
|
||||||
|
('club_admin', 'org.members.manage'),
|
||||||
|
('club_admin', 'org.join_request.review'),
|
||||||
|
('club_admin', 'org.inbox.read'),
|
||||||
|
('club_admin', 'exercises.create'),
|
||||||
|
('trainer', 'exercises.create'),
|
||||||
|
('content_editor', 'exercises.create'),
|
||||||
|
('division_lead', 'exercises.create'),
|
||||||
|
('club_admin', 'exercises.update'),
|
||||||
|
('trainer', 'exercises.update'),
|
||||||
|
('content_editor', 'exercises.update'),
|
||||||
|
('division_lead', 'exercises.update'),
|
||||||
|
('club_admin', 'exercises.delete'),
|
||||||
|
('club_admin', 'exercises.bulk_metadata'),
|
||||||
|
('content_editor', 'exercises.bulk_metadata'),
|
||||||
|
('club_admin', 'exercises.ai.suggest'),
|
||||||
|
('trainer', 'exercises.ai.suggest'),
|
||||||
|
('content_editor', 'exercises.ai.suggest'),
|
||||||
|
('division_lead', 'exercises.ai.suggest'),
|
||||||
|
('club_admin', 'exercises.ai.regenerate'),
|
||||||
|
('trainer', 'exercises.ai.regenerate'),
|
||||||
|
('content_editor', 'exercises.ai.regenerate'),
|
||||||
|
('division_lead', 'exercises.ai.regenerate'),
|
||||||
|
('club_admin', 'exercises.media.upload'),
|
||||||
|
('trainer', 'exercises.media.upload'),
|
||||||
|
('content_editor', 'exercises.media.upload'),
|
||||||
|
('club_admin', 'exercises.variants.manage'),
|
||||||
|
('trainer', 'exercises.variants.manage'),
|
||||||
|
('content_editor', 'exercises.variants.manage'),
|
||||||
|
('club_admin', 'media.library.upload'),
|
||||||
|
('trainer', 'media.library.upload'),
|
||||||
|
('content_editor', 'media.library.upload'),
|
||||||
|
('club_admin', 'media.library.update'),
|
||||||
|
('trainer', 'media.library.update'),
|
||||||
|
('content_editor', 'media.library.update'),
|
||||||
|
('club_admin', 'media.library.lifecycle'),
|
||||||
|
('trainer', 'media.library.lifecycle'),
|
||||||
|
('club_admin', 'media.rights.declare'),
|
||||||
|
('trainer', 'media.rights.declare'),
|
||||||
|
('club_admin', 'modules.create'),
|
||||||
|
('trainer', 'modules.create'),
|
||||||
|
('content_editor', 'modules.create'),
|
||||||
|
('club_admin', 'modules.update'),
|
||||||
|
('trainer', 'modules.update'),
|
||||||
|
('content_editor', 'modules.update'),
|
||||||
|
('club_admin', 'modules.delete'),
|
||||||
|
('club_admin', 'framework.create'),
|
||||||
|
('trainer', 'framework.create'),
|
||||||
|
('club_admin', 'framework.update'),
|
||||||
|
('trainer', 'framework.update'),
|
||||||
|
('club_admin', 'framework.delete'),
|
||||||
|
('club_admin', 'plan_templates.manage'),
|
||||||
|
('trainer', 'plan_templates.manage'),
|
||||||
|
('club_admin', 'progression.manage'),
|
||||||
|
('trainer', 'progression.manage'),
|
||||||
|
('content_editor', 'progression.manage'),
|
||||||
|
('club_admin', 'planning.units.create'),
|
||||||
|
('trainer', 'planning.units.create'),
|
||||||
|
('division_lead', 'planning.units.create'),
|
||||||
|
('club_admin', 'planning.units.update'),
|
||||||
|
('trainer', 'planning.units.update'),
|
||||||
|
('division_lead', 'planning.units.update'),
|
||||||
|
('club_admin', 'planning.units.delete'),
|
||||||
|
('trainer', 'planning.units.delete'),
|
||||||
|
('club_admin', 'planning.units.run'),
|
||||||
|
('trainer', 'planning.units.run'),
|
||||||
|
('division_lead', 'planning.units.run'),
|
||||||
|
('club_admin', 'planning.coach.execute'),
|
||||||
|
('trainer', 'planning.coach.execute'),
|
||||||
|
('club_admin', 'planning.ai.suggest'),
|
||||||
|
('trainer', 'planning.ai.suggest'),
|
||||||
|
('division_lead', 'planning.ai.suggest'),
|
||||||
|
('club_admin', 'planning.ai.progression_path'),
|
||||||
|
('trainer', 'planning.ai.progression_path'),
|
||||||
|
('division_lead', 'planning.ai.progression_path'),
|
||||||
|
('club_admin', 'skills.discovery.read'),
|
||||||
|
('trainer', 'skills.discovery.read'),
|
||||||
|
('content_editor', 'skills.discovery.read'),
|
||||||
|
('club_admin', 'governance.content_report.review')
|
||||||
|
) AS r(role_code, cap_id)
|
||||||
|
JOIN capabilities c ON c.id = r.cap_id
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- org.club.update: club_admin (zusätzlich zu platform_admin via Bypass)
|
||||||
|
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||||
|
VALUES ('club_admin', 'org.club.update')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Portal-Rollen ───────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
SELECT 'admin', id FROM capabilities WHERE id = 'platform.admin.access'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
SELECT 'superadmin', id FROM capabilities WHERE domain = 'platform'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
41
backend/migrations/080_club_creation_requests.sql
Normal file
41
backend/migrations/080_club_creation_requests.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- Migration 080: Antrag auf Vereinsgründung (M7)
|
||||||
|
-- Nutzer verified_pending_club stellt Antrag; Plattform-Admin legt Verein + Abo an.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_creation_requests (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
proposed_name VARCHAR(200) NOT NULL,
|
||||||
|
proposed_abbreviation VARCHAR(50),
|
||||||
|
proposed_description TEXT,
|
||||||
|
message TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')),
|
||||||
|
decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
decided_at TIMESTAMP,
|
||||||
|
created_club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_creation_requests_pending
|
||||||
|
ON club_creation_requests (profile_id)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_creation_requests_status
|
||||||
|
ON club_creation_requests (status, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_creation_requests_profile
|
||||||
|
ON club_creation_requests (profile_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS club_creation_requests_update ON club_creation_requests;
|
||||||
|
CREATE TRIGGER club_creation_requests_update
|
||||||
|
BEFORE UPDATE ON club_creation_requests
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||||
|
|
||||||
|
-- Capabilities (CAPABILITY_CATALOG.v1.md — club.creation_request.*)
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES
|
||||||
|
('club.creation_request.create', 'Vereinsgründung beantragen', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.creation_request.read_own', 'Eigene Gründungsanträge', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.creation_request.withdraw', 'Gründungsantrag zurückziehen', 'club', 'verified_pending_club', NULL)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
13
backend/migrations/081_club_creation_request_superseded.sql
Normal file
13
backend/migrations/081_club_creation_request_superseded.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Migration 081: Status superseded wenn freigegebener Verein gelöscht wurde
|
||||||
|
|
||||||
|
ALTER TABLE club_creation_requests
|
||||||
|
DROP CONSTRAINT IF EXISTS club_creation_requests_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE club_creation_requests
|
||||||
|
ADD CONSTRAINT club_creation_requests_status_check
|
||||||
|
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn', 'superseded'));
|
||||||
|
|
||||||
|
-- Bestehende Drift: approved ohne Verein (ON DELETE SET NULL auf created_club_id)
|
||||||
|
UPDATE club_creation_requests
|
||||||
|
SET status = 'superseded', updated_at = NOW()
|
||||||
|
WHERE status = 'approved' AND created_club_id IS NULL;
|
||||||
36
backend/migrations/082_platform_club_feature_exemptions.sql
Normal file
36
backend/migrations/082_platform_club_feature_exemptions.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- Migration 082: Plattform-/Profil-Ausnahmen vom Vereins-Kontingent (M5+)
|
||||||
|
-- Superadmin & konfigurierbare Rollen/Profile verbrauchen kein club_feature_usage.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_role_club_feature_exemptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
portal_role TEXT NOT NULL,
|
||||||
|
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_role_club_feat_exempt
|
||||||
|
ON platform_role_club_feature_exemptions (portal_role, COALESCE(feature_id, '*'));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_club_feature_exemptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT,
|
||||||
|
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_profile_club_feat_exempt
|
||||||
|
ON profile_club_feature_exemptions (profile_id, COALESCE(feature_id, '*'));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_club_feat_exempt_profile
|
||||||
|
ON profile_club_feature_exemptions (profile_id);
|
||||||
|
|
||||||
|
-- Superadmin: alle Vereins-Features ohne Kontingent-Verbrauch
|
||||||
|
INSERT INTO platform_role_club_feature_exemptions (portal_role, feature_id, note)
|
||||||
|
SELECT 'superadmin', NULL, 'Plattform-Administrator: kein Vereins-Kontingent'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM platform_role_club_feature_exemptions
|
||||||
|
WHERE portal_role = 'superadmin' AND feature_id IS NULL
|
||||||
|
);
|
||||||
103
backend/migrations/083_capability_quota_bypass.sql
Normal file
103
backend/migrations/083_capability_quota_bypass.sql
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
-- Migration 083: Vereins-Kontingent-Bypass über Capability-System (kein Parallel-Schema)
|
||||||
|
-- Ersetzt platform_role_club_feature_exemptions / profile_club_feature_exemptions aus 082.
|
||||||
|
|
||||||
|
-- Einzelprofil-Grants (ergänzt portal_role_capability_grants)
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_capability_grants (
|
||||||
|
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT,
|
||||||
|
granted_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (profile_id, capability_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_capability_grants_cap
|
||||||
|
ON profile_capability_grants(capability_id);
|
||||||
|
|
||||||
|
-- Bypass-Capabilities (CAPABILITY_CATALOG — konfigurierbar via portal/profile grants)
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'platform.club_quota.bypass',
|
||||||
|
'Vereins-Kontingent umgehen (alle Features)',
|
||||||
|
'platform',
|
||||||
|
'platform_admin',
|
||||||
|
NULL
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Superadmin: alle Plattform-Capabilities inkl. bypass (079-Seed deckt domain=platform ab)
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
SELECT 'superadmin', 'platform.club_quota.bypass'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = 'superadmin' AND capability_id = 'platform.club_quota.bypass'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Daten aus 082 übernehmen (falls vorhanden) ─────────────────────────────
|
||||||
|
DO $migrate082$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
cap_id TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'platform_role_club_feature_exemptions'
|
||||||
|
) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
FOR r IN
|
||||||
|
SELECT portal_role, feature_id, note
|
||||||
|
FROM platform_role_club_feature_exemptions
|
||||||
|
LOOP
|
||||||
|
IF r.feature_id IS NULL THEN
|
||||||
|
cap_id := 'platform.club_quota.bypass';
|
||||||
|
ELSE
|
||||||
|
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES (
|
||||||
|
cap_id,
|
||||||
|
'Vereins-Kontingent umgehen: ' || r.feature_id,
|
||||||
|
'quota_bypass',
|
||||||
|
'active_member',
|
||||||
|
r.feature_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
VALUES (lower(trim(r.portal_role)), cap_id)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
FOR r IN
|
||||||
|
SELECT profile_id, feature_id, reason, set_by_profile_id
|
||||||
|
FROM profile_club_feature_exemptions
|
||||||
|
LOOP
|
||||||
|
IF r.feature_id IS NULL THEN
|
||||||
|
cap_id := 'platform.club_quota.bypass';
|
||||||
|
ELSE
|
||||||
|
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES (
|
||||||
|
cap_id,
|
||||||
|
'Vereins-Kontingent umgehen: ' || r.feature_id,
|
||||||
|
'quota_bypass',
|
||||||
|
'active_member',
|
||||||
|
r.feature_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO profile_capability_grants (
|
||||||
|
profile_id, capability_id, reason, granted_by_profile_id
|
||||||
|
)
|
||||||
|
VALUES (r.profile_id, cap_id, r.reason, r.set_by_profile_id)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS profile_club_feature_exemptions;
|
||||||
|
DROP TABLE IF EXISTS platform_role_club_feature_exemptions;
|
||||||
|
END
|
||||||
|
$migrate082$;
|
||||||
15
backend/migrations/084_rights_registry_module.sql
Normal file
15
backend/migrations/084_rights_registry_module.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- Migration 084: Modul-Registrierung für Rechte & Kontingente (Registry-first)
|
||||||
|
-- capabilities/features mit module=NULL = Legacy-Katalog-Seed (nicht in Admin-Matrix).
|
||||||
|
-- module IS NOT NULL = vom Modul bei Implementierung registriert.
|
||||||
|
|
||||||
|
ALTER TABLE capabilities
|
||||||
|
ADD COLUMN IF NOT EXISTS module TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE features
|
||||||
|
ADD COLUMN IF NOT EXISTS module TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_capabilities_module
|
||||||
|
ON capabilities(module) WHERE module IS NOT NULL AND active = true;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_features_module
|
||||||
|
ON features(module) WHERE module IS NOT NULL AND active = true;
|
||||||
181
backend/migrations/085_ai_prompt_exercise_planning_context.sql
Normal file
181
backend/migrations/085_ai_prompt_exercise_planning_context.sql
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
-- Migration 085: Planungskontext in Übungs-KI-Prompts (Phase D)
|
||||||
|
-- Platzhalter: {{planning_context_json}}, {{#has_planning_context}} … {{/has_planning_context}}
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $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}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$,
|
||||||
|
default_template = $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}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$
|
||||||
|
WHERE slug = 'exercise_summary';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $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}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
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$,
|
||||||
|
default_template = $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}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
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$
|
||||||
|
WHERE slug = 'exercise_skill_suggestions';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
|
||||||
|
Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
|
||||||
|
|
||||||
|
Stil:
|
||||||
|
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
|
||||||
|
- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
|
||||||
|
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
|
||||||
|
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
|
||||||
|
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
|
||||||
|
|
||||||
|
Format (HTML fuer Rich-Text-Editor):
|
||||||
|
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||||
|
- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
|
||||||
|
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
|
||||||
|
Ziel (Plaintext, Ausgang): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
|
||||||
|
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
|
||||||
|
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
|
||||||
|
{
|
||||||
|
"goal": "<p>…</p>",
|
||||||
|
"execution": "<ol><li>…</li></ol>",
|
||||||
|
"preparation": "<p>…</p> oder \"\"",
|
||||||
|
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$,
|
||||||
|
default_template = $t$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
|
||||||
|
Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
|
||||||
|
|
||||||
|
Stil:
|
||||||
|
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
|
||||||
|
- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
|
||||||
|
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
|
||||||
|
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
|
||||||
|
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
|
||||||
|
|
||||||
|
Format (HTML fuer Rich-Text-Editor):
|
||||||
|
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||||
|
- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
|
||||||
|
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
|
||||||
|
Ziel (Plaintext, Ausgang): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
|
||||||
|
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
|
||||||
|
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
|
||||||
|
{
|
||||||
|
"goal": "<p>…</p>",
|
||||||
|
"execution": "<ol><li>…</li></ol>",
|
||||||
|
"preparation": "<p>…</p> oder \"\"",
|
||||||
|
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$
|
||||||
|
WHERE slug = 'exercise_instruction_rewrite';
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
-- Migration 087: Planungs-KI — LLM Start/Ziel-Extraktion aus Trainer-Anfrage (Alternative zu Regex)
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_progression_start_target',
|
||||||
|
'Progressions-Roadmap Start/Ziel-Extraktion',
|
||||||
|
'Versteht die Trainer-Anfrage und formuliert dedizierte Ausgangslage, Zielzustand und Ergänzungen (ohne Gruppen-Tracking).',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen didaktischen Progressionsgraphen.
|
||||||
|
|
||||||
|
Trainer-Anfrage (Ursprungstext):
|
||||||
|
{{goal_query}}
|
||||||
|
|
||||||
|
Semantic Brief (heuristisch): {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Bereits vom Trainer eingegebene Ergänzungen (falls vorhanden): {{user_notes}}
|
||||||
|
|
||||||
|
Aufgabe:
|
||||||
|
1. **primary_topic** — Kern-Thema/Technik in kurzer, präziser Bezeichnung (z. B. „Kumite Beinarbeit“, „Mae Geri“).
|
||||||
|
2. **start_situation** — Ausgangslage in eigenen Worten: Was kann der Athlet/die Gruppe *jetzt* (laut Anfrage oder sinnvoll ableitbar)? Konkret, beobachtbar, ohne Gruppenanalyse aus der Datenbank.
|
||||||
|
3. **target_state** — Zielzustand in eigenen Worten: Was soll am Ende der Progression erreicht sein? Konkret, didaktisch nutzbar.
|
||||||
|
4. **roadmap_notes** — Ergänzungen aus dem Ursprungstext: Fokus, Kontext (z. B. Kumite), besondere Anforderungen, Einschränkungen, die der Trainer erwähnt hat oder die für die Roadmap relevant sind. Nicht wiederholen, was bereits in start_situation/target_state steht.
|
||||||
|
5. **extraction_notes** — Kurz (1–2 Sätze): Was war explizit vs. abgeleitet? Wo war die Anfrage unklar?
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Keine Gruppenanalyse — nur das, was aus dem Text hervorgeht oder didaktisch naheliegend formuliert ist.
|
||||||
|
- Formuliere start_situation und target_state **eigenständig und verständlich**, nicht nur Textfragmente kopieren.
|
||||||
|
- Bei „von … bis …“: Start und Ziel aus diesem Bogen schärfen und präzise beschreiben.
|
||||||
|
- Bei nur einem Thema ohne Bogen: start_situation und target_state didaktisch sinnvoll formulieren oder leer lassen, wenn nicht ableitbar — dann in extraction_notes erklären.
|
||||||
|
- Antworte NUR mit JSON.
|
||||||
|
|
||||||
|
{
|
||||||
|
"primary_topic": "…",
|
||||||
|
"start_situation": "…",
|
||||||
|
"target_state": "…",
|
||||||
|
"roadmap_notes": "…",
|
||||||
|
"extraction_notes": "…"
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"primary_topic":{"type":"string"},"start_situation":{"type":"string"},"target_state":{"type":"string"},"roadmap_notes":{"type":"string"},"extraction_notes":{"type":"string"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
13
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_start_target');
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET default_template = template
|
||||||
|
WHERE slug = 'planning_progression_start_target'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Migration 088: Planungs-Roadmap-Artefakt am Progressionsgraph (JSONB, optional).
|
||||||
|
-- Speichert Ziel, Start/Ziel, progression_roadmap + stage_specs für Wiederaufnahme der KI-Planung.
|
||||||
|
|
||||||
|
ALTER TABLE exercise_progression_graphs
|
||||||
|
ADD COLUMN IF NOT EXISTS planning_roadmap JSONB;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN exercise_progression_graphs.planning_roadmap IS
|
||||||
|
'Optionales Planungs-Artefakt (goal_query, resolved_structured, progression_roadmap, stage_specs) — Schema v1 im App-Code.';
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
-- Migration 089: Planungs-Intent — Zielanalyse + Stufenspecs (anti_patterns, success_criteria)
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET
|
||||||
|
description = 'Phase A: Ist-/Soll, Erfolgskriterien und explizite Ausschlüsse (ohne Gruppenkontext).',
|
||||||
|
template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema.
|
||||||
|
- Explizite Negationen aus der Anfrage (ohne/kein/nicht …) in constraints.excluded_themes übernehmen — nicht raten.
|
||||||
|
- success_criteria: messbar, für späteres Übungs-Matching (Titel + Kurzbeschreibung + Übungsziel).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "Hauptthema",
|
||||||
|
"start_assumption": "Voraussetzungen für den Einstieg",
|
||||||
|
"target_state": "Konkreter Zielzustand der Progression",
|
||||||
|
"success_criteria": ["messbare Kriterien entlang des Pfads"],
|
||||||
|
"constraints": {
|
||||||
|
"partner_required": false,
|
||||||
|
"excluded_themes": ["wörtliche Negationen, z. B. keine Kumite-Anwendung"],
|
||||||
|
"trainer_notes": "optional: Fokus aus Ergänzungen"
|
||||||
|
}
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_goal_analysis';
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET
|
||||||
|
description = 'Phase C: Belastung, Übungstyp, Erfolgskriterien und anti_patterns je Major Step — für Retrieval-Matching.',
|
||||||
|
template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Major Steps: {{major_steps_json}}
|
||||||
|
Planungs-Intent (Pfadweite Regeln): {{intent_context_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Aufgabe je Major Step — Felder für automatisches Übungs-Matching (nicht nur Titel):
|
||||||
|
- learning_goal: messbares Stufen-Lernziel (was die Übung bringen soll)
|
||||||
|
- load_profile: z. B. koordination, präzision, kraft, athletik
|
||||||
|
- exercise_type: kihon_einzel | partner_drill | kombination | kraft_auxiliary
|
||||||
|
- success_criteria: 2–4 prüfbare Kriterien an Kurzbeschreibung + Übungsziel (nicht nur Technikname im Titel)
|
||||||
|
- anti_patterns: 2–5 Dinge, die für diese Stufe unpassend sind
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
1. Jede explicit_exclusions / excluded_themes aus intent_context und Zielanalyse MUSS in anti_patterns jeder Stufe vorkommen (umformuliert ok).
|
||||||
|
2. Keine neuen Ausschlüsse erfinden, die nicht in Anfrage/Intent/Zielanalyse stehen.
|
||||||
|
3. success_criteria Pfad-weit + stufenspezifisch kombinieren.
|
||||||
|
4. partner_drill nur wenn Partner/Kumite nicht ausgeschlossen ist.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"stage_specs": [
|
||||||
|
{
|
||||||
|
"major_step_index": 0,
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["koordination"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["…"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec';
|
||||||
43
backend/migrations/090_ai_prompt_stage_transition_states.sql
Normal file
43
backend/migrations/090_ai_prompt_stage_transition_states.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- Migration 090: Stufenspecs — start_state / target_state pro Major Step (Soll-Verkettung)
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET
|
||||||
|
description = 'Phase C: Stufenspezifikation inkl. Soll-Start und Stufen-Ziel je Major Step.',
|
||||||
|
template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Major Steps: {{major_steps_json}}
|
||||||
|
Planungs-Intent (Pfadweite Regeln): {{intent_context_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Jede Stufe ist ein Übergang im Gesamtpfad:
|
||||||
|
- start_state: Soll-Zustand zu Beginn (= Ziel der vorherigen Stufe; Stufe 0 = Pfad-Start)
|
||||||
|
- target_state: Zielzustand nach dieser Stufe (= Soll für die nächste Stufe)
|
||||||
|
- learning_goal: messbares Lernziel der Übungssuche (was die Übung bringen soll)
|
||||||
|
|
||||||
|
Felder je Major Step:
|
||||||
|
- load_profile, exercise_type, success_criteria, anti_patterns (wie bisher)
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
1. start_state/target_state aus Zielanalyse und Major Steps ableiten — konsistente Kette.
|
||||||
|
2. explicit_exclusions aus intent_context in anti_patterns jeder Stufe.
|
||||||
|
3. success_criteria: prüfbar an Kurzbeschreibung + Übungsziel.
|
||||||
|
4. Keine erfundenen Ausschlüsse.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"stage_specs": [
|
||||||
|
{
|
||||||
|
"major_step_index": 0,
|
||||||
|
"start_state": "…",
|
||||||
|
"target_state": "…",
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["koordination"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["…"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec';
|
||||||
|
|
@ -117,6 +117,8 @@ class SkillCreate(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
importance: Optional[int] = Field(None, ge=1, le=5)
|
importance: Optional[int] = Field(None, ge=1, le=5)
|
||||||
keywords: Optional[List[str]] = []
|
keywords: Optional[List[str]] = []
|
||||||
|
karate_relevance: Optional[str] = None
|
||||||
|
relevance_level: Optional[int] = Field(None, ge=1, le=3)
|
||||||
|
|
||||||
class SkillResponse(BaseModel):
|
class SkillResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|
@ -125,6 +127,8 @@ class SkillResponse(BaseModel):
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
importance: Optional[int]
|
importance: Optional[int]
|
||||||
keywords: Optional[List[str]]
|
keywords: Optional[List[str]]
|
||||||
|
karate_relevance: Optional[str] = None
|
||||||
|
relevance_level: Optional[int] = None
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
|
||||||
224
backend/openrouter_chat.py
Normal file
224
backend/openrouter_chat.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
"""
|
||||||
|
Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MODEL / OPENROUTER_BASE_URL from env.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.openrouter")
|
||||||
|
|
||||||
|
_SKIP_ANTHROPIC_BLOCK_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"thinking",
|
||||||
|
"redacted_thinking",
|
||||||
|
"reasoning",
|
||||||
|
"tool_use",
|
||||||
|
"tool_calls",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _shinkan_ai_debug() -> bool:
|
||||||
|
return os.getenv("SHINKAN_AI_DEBUG", "").strip().lower() in ("1", "true", "yes", "full")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_nested_text(val: Any) -> str:
|
||||||
|
if val is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(val, str):
|
||||||
|
return val.strip()
|
||||||
|
if isinstance(val, bool) or isinstance(val, (int, float)):
|
||||||
|
return str(val).strip()
|
||||||
|
if isinstance(val, list):
|
||||||
|
return "".join(_coerce_nested_text(x) for x in val).strip()
|
||||||
|
if isinstance(val, dict):
|
||||||
|
# OpenRouter/Anthropic: verschachtelte text/content-Hüllen
|
||||||
|
for key in ("text", "content", "value"):
|
||||||
|
if key in val:
|
||||||
|
nested = _coerce_nested_text(val.get(key))
|
||||||
|
if nested:
|
||||||
|
return nested
|
||||||
|
return ""
|
||||||
|
return str(val).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_message_content(content: Any) -> str:
|
||||||
|
"""
|
||||||
|
Chat-Completion: `content` als String oder als Liste strukturierter Blöcke
|
||||||
|
(Anthropic Claude über OpenRouter/Bedrock, teils verschachtelt).
|
||||||
|
"""
|
||||||
|
if content is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: List[str] = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
bits = _coerce_nested_text(block)
|
||||||
|
if bits:
|
||||||
|
parts.append(bits)
|
||||||
|
elif isinstance(block, dict):
|
||||||
|
t_raw = block.get("type")
|
||||||
|
ts = str(t_raw or "").strip().lower()
|
||||||
|
if ts and (ts in _SKIP_ANTHROPIC_BLOCK_TYPES or ts.endswith("_thinking")):
|
||||||
|
continue
|
||||||
|
txt = None
|
||||||
|
if ts in ("text", "output_text", ""):
|
||||||
|
txt = block.get("text")
|
||||||
|
if txt is None:
|
||||||
|
txt = block.get("content")
|
||||||
|
else:
|
||||||
|
# unbekannten Typ weiter versuchen (Provider-Varianten), aber tool-use überspringen
|
||||||
|
low = ts
|
||||||
|
if "tool_use" in low or low.startswith("tool_"):
|
||||||
|
continue
|
||||||
|
txt = block.get("text") if block.get("text") is not None else block.get("content")
|
||||||
|
bits = _coerce_nested_text(txt)
|
||||||
|
if bits:
|
||||||
|
parts.append(bits)
|
||||||
|
return "".join(parts).strip()
|
||||||
|
if isinstance(content, dict):
|
||||||
|
return _coerce_nested_text(content)
|
||||||
|
return str(content).strip()
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterError(Exception):
|
||||||
|
"""Upstream or transport failure."""
|
||||||
|
|
||||||
|
|
||||||
|
def openrouter_chat_completion(
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
user_content: str,
|
||||||
|
system_content: Optional[str] = None,
|
||||||
|
timeout_sec: float = 120.0,
|
||||||
|
temperature: float = 0.25,
|
||||||
|
site_url: Optional[str] = None,
|
||||||
|
app_title: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Returns assistant message content (plain string). Caller validates empty responses.
|
||||||
|
"""
|
||||||
|
base = (os.getenv("OPENROUTER_BASE_URL") or "").strip().rstrip("/") or "https://openrouter.ai/api/v1"
|
||||||
|
url = f"{base}/chat/completions"
|
||||||
|
|
||||||
|
headers: Dict[str, str] = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
referer = (site_url or os.getenv("APP_URL") or "").strip()
|
||||||
|
if referer:
|
||||||
|
headers["HTTP-Referer"] = referer
|
||||||
|
title = (app_title or os.getenv("OPENROUTER_APP_TITLE") or "Shinkan Jinkendo").strip()
|
||||||
|
if title:
|
||||||
|
headers["X-Title"] = title
|
||||||
|
|
||||||
|
messages: List[Dict[str, str]] = []
|
||||||
|
if system_content and str(system_content).strip():
|
||||||
|
messages.append({"role": "system", "content": str(system_content).strip()})
|
||||||
|
messages.append({"role": "user", "content": user_content})
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout_sec) as client:
|
||||||
|
resp = client.post(url, headers=headers, json=payload)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise OpenRouterError(str(e)) from e
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
j = resp.json()
|
||||||
|
detail = (
|
||||||
|
str(j.get("error", {}).get("message"))
|
||||||
|
if isinstance(j.get("error"), dict)
|
||||||
|
else str(j.get("message") or j)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
detail = (resp.text or "")[:600]
|
||||||
|
raise OpenRouterError(f"HTTP {resp.status_code}: {detail}".strip())
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise OpenRouterError("Ungueltige JSON-Antwort von OpenRouter") from e
|
||||||
|
|
||||||
|
choices = data.get("choices") if isinstance(data, dict) else None
|
||||||
|
if not choices or not isinstance(choices, list):
|
||||||
|
raise OpenRouterError("OpenRouter: keine choices in Antwort")
|
||||||
|
|
||||||
|
msg0 = choices[0] if choices else {}
|
||||||
|
inner = msg0.get("message") if isinstance(msg0, dict) else None
|
||||||
|
|
||||||
|
blobs: List[Any] = []
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
if inner.get("content") is not None:
|
||||||
|
blobs.append(inner.get("content"))
|
||||||
|
if inner.get("refusal") is not None:
|
||||||
|
blobs.append(inner.get("refusal"))
|
||||||
|
elif isinstance(inner, str):
|
||||||
|
blobs.append(inner)
|
||||||
|
if isinstance(msg0, dict) and msg0.get("content") is not None and msg0.get("content") not in blobs:
|
||||||
|
blobs.append(msg0.get("content"))
|
||||||
|
|
||||||
|
pieces = [_flatten_message_content(b).strip() for b in blobs if b is not None]
|
||||||
|
joined = ("\n".join(p for p in pieces if p)).strip()
|
||||||
|
|
||||||
|
if _shinkan_ai_debug():
|
||||||
|
fr = str(msg0.get("finish_reason") or "") if isinstance(msg0, dict) else ""
|
||||||
|
fu = data.get("usage") if isinstance(data.get("usage"), dict) else {}
|
||||||
|
pu = str(fu.get("prompt_tokens") or "")
|
||||||
|
pc = str(fu.get("completion_tokens") or "")
|
||||||
|
pt = str(fu.get("total_tokens") or "")
|
||||||
|
raw_cls = type(blobs[0]).__name__ if blobs else "none"
|
||||||
|
cc = str(len(joined))
|
||||||
|
_logger.warning(
|
||||||
|
"[AI_DEBUG/openrouter] model=%s finish_reason=%s usage_prompt=%s usage_completion=%s usage_total=%s "
|
||||||
|
"raw_content_cls=%s out_chars=%s",
|
||||||
|
model,
|
||||||
|
fr,
|
||||||
|
pu,
|
||||||
|
pc,
|
||||||
|
pt,
|
||||||
|
raw_cls,
|
||||||
|
cc,
|
||||||
|
)
|
||||||
|
|
||||||
|
return joined
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_openrouter_env() -> tuple[str, str]:
|
||||||
|
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
|
||||||
|
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
|
||||||
|
return key, model
|
||||||
|
|
||||||
|
|
||||||
|
def default_openrouter_model_id() -> str:
|
||||||
|
"""Standard-Modell aus OPENROUTER_MODEL (ohne API-Key zu pruefen)."""
|
||||||
|
_, model = normalize_openrouter_env()
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def effective_openrouter_model_for_prompt_row(row: Optional[Mapping[str, Any]]) -> str:
|
||||||
|
"""
|
||||||
|
Pro-Prompt-Override in ai_prompts.openrouter_model, sonst Env-Default.
|
||||||
|
|
||||||
|
`row` kann eine partial Row aus load_ai_prompt_row sein (Felder slug, openrouter_model, …).
|
||||||
|
"""
|
||||||
|
if row:
|
||||||
|
custom = str(row.get("openrouter_model") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return custom
|
||||||
|
return default_openrouter_model_id()
|
||||||
147
backend/planning_catalog_context.py
Normal file
147
backend/planning_catalog_context.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
"""
|
||||||
|
Katalog-Kontext für Progressionsgraph-Planung — Fokusbereich, Stil, Trainingsstil, Zielgruppe.
|
||||||
|
|
||||||
|
Explizite Trainer-Auswahl ergänzt Freitext/LLM; ersetzt kein Roadmap-Didaktik-Modell.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from planning_exercise_profiles import PlanningTargetProfile, _normalize_weight_map
|
||||||
|
from planning_exercise_target_pipeline import (
|
||||||
|
SCENARIO_FREE_SEARCH,
|
||||||
|
merge_query_overlay_into_target,
|
||||||
|
)
|
||||||
|
from planning_exercise_text_signals import resolve_planning_text_to_catalog_weights
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningCatalogContextItem(BaseModel):
|
||||||
|
id: int = Field(..., ge=1)
|
||||||
|
is_primary: bool = False
|
||||||
|
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressionPlanningCatalogContext(BaseModel):
|
||||||
|
focus_areas: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||||
|
style_directions: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||||
|
training_types: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||||
|
target_groups: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_context_has_items(catalog: Optional[ProgressionPlanningCatalogContext]) -> bool:
|
||||||
|
if catalog is None:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
catalog.focus_areas
|
||||||
|
or catalog.style_directions
|
||||||
|
or catalog.training_types
|
||||||
|
or catalog.target_groups
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_items_to_weight_map(
|
||||||
|
items: Sequence[PlanningCatalogContextItem],
|
||||||
|
*,
|
||||||
|
primary_weight: float = 0.95,
|
||||||
|
secondary_weight: float = 0.78,
|
||||||
|
) -> Dict[int, float]:
|
||||||
|
out: Dict[int, float] = {}
|
||||||
|
for item in items or []:
|
||||||
|
base = primary_weight if item.is_primary else secondary_weight
|
||||||
|
w = base * float(item.weight)
|
||||||
|
iid = int(item.id)
|
||||||
|
out[iid] = max(out.get(iid, 0.0), w)
|
||||||
|
return _normalize_weight_map(out) if out else out
|
||||||
|
|
||||||
|
|
||||||
|
def merge_catalog_context_into_target(
|
||||||
|
target: PlanningTargetProfile,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||||
|
*,
|
||||||
|
emphasis: str = "replace",
|
||||||
|
) -> PlanningTargetProfile:
|
||||||
|
"""Trainer-Katalog-Kontext ins Erwartungsprofil — beeinflusst Retrieval-Scoring."""
|
||||||
|
if not catalog_context_has_items(catalog):
|
||||||
|
return target
|
||||||
|
|
||||||
|
focus = catalog_items_to_weight_map(catalog.focus_areas)
|
||||||
|
style = catalog_items_to_weight_map(catalog.style_directions, primary_weight=0.9, secondary_weight=0.72)
|
||||||
|
tt = catalog_items_to_weight_map(catalog.training_types, primary_weight=0.9, secondary_weight=0.72)
|
||||||
|
tg = catalog_items_to_weight_map(catalog.target_groups, primary_weight=0.88, secondary_weight=0.7)
|
||||||
|
|
||||||
|
merged = merge_query_overlay_into_target(
|
||||||
|
target,
|
||||||
|
focus=focus,
|
||||||
|
style=style,
|
||||||
|
tt=tt,
|
||||||
|
tg=tg,
|
||||||
|
skills={},
|
||||||
|
emphasis=emphasis,
|
||||||
|
scenario=SCENARIO_FREE_SEARCH,
|
||||||
|
)
|
||||||
|
sources = list(merged.sources or [])
|
||||||
|
if "catalog_context" not in sources:
|
||||||
|
sources.append("catalog_context")
|
||||||
|
merged.sources = sources
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_target_from_planning_text_blobs(
|
||||||
|
cur,
|
||||||
|
target: PlanningTargetProfile,
|
||||||
|
*text_blobs: Optional[str],
|
||||||
|
) -> PlanningTargetProfile:
|
||||||
|
"""Additive Katalog-Signale aus Freitext (Anfrage, Start/Ziel, Notizen)."""
|
||||||
|
combined = " ".join(str(t or "").strip() for t in text_blobs if (t or "").strip())
|
||||||
|
if len(combined) < 4:
|
||||||
|
return target
|
||||||
|
focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(cur, combined)
|
||||||
|
if not (focus or style or tt or tg or skills):
|
||||||
|
return target
|
||||||
|
merged = merge_query_overlay_into_target(
|
||||||
|
target,
|
||||||
|
focus=focus,
|
||||||
|
style=style,
|
||||||
|
tt=tt,
|
||||||
|
tg=tg,
|
||||||
|
skills=skills,
|
||||||
|
emphasis="additive",
|
||||||
|
scenario=SCENARIO_FREE_SEARCH,
|
||||||
|
)
|
||||||
|
sources = list(merged.sources or [])
|
||||||
|
if "text_catalog_signals" not in sources:
|
||||||
|
sources.append("text_catalog_signals")
|
||||||
|
merged.sources = sources
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_context_from_mapping(raw: Any) -> Optional[ProgressionPlanningCatalogContext]:
|
||||||
|
if not raw or not isinstance(raw, Mapping):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ctx = ProgressionPlanningCatalogContext.model_validate(dict(raw))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return ctx if catalog_context_has_items(ctx) else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_catalog_context_from_graph_row(
|
||||||
|
planning_roadmap: Any,
|
||||||
|
) -> Optional[ProgressionPlanningCatalogContext]:
|
||||||
|
if not isinstance(planning_roadmap, dict):
|
||||||
|
return None
|
||||||
|
return catalog_context_from_mapping(planning_roadmap.get("planning_catalog_context"))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlanningCatalogContextItem",
|
||||||
|
"ProgressionPlanningCatalogContext",
|
||||||
|
"catalog_context_from_mapping",
|
||||||
|
"catalog_context_has_items",
|
||||||
|
"catalog_items_to_weight_map",
|
||||||
|
"enrich_target_from_planning_text_blobs",
|
||||||
|
"load_catalog_context_from_graph_row",
|
||||||
|
"merge_catalog_context_into_target",
|
||||||
|
]
|
||||||
69
backend/planning_exercise_expectation.py
Normal file
69
backend/planning_exercise_expectation.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""
|
||||||
|
Preset „Nächste aus Kontext“: LLM leitet Erwartungsprofil aus Planungskontext ab.
|
||||||
|
|
||||||
|
Prompt: planning_exercise_expectation_profile (Migration 074)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
from planning_exercise_intent import (
|
||||||
|
PlanningQueryIntentParsed,
|
||||||
|
_compact_json,
|
||||||
|
_load_compact_catalog,
|
||||||
|
_load_skills_catalog_compact,
|
||||||
|
parse_planning_query_intent_response,
|
||||||
|
)
|
||||||
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
|
from openrouter_chat import (
|
||||||
|
effective_openrouter_model_for_prompt_row,
|
||||||
|
normalize_openrouter_env,
|
||||||
|
openrouter_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_expectation")
|
||||||
|
|
||||||
|
|
||||||
|
def try_build_planning_expectation_from_context(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
heuristic_intent: str,
|
||||||
|
context_summary: Mapping[str, Any],
|
||||||
|
target_profile_summary: Mapping[str, Any],
|
||||||
|
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
||||||
|
"""
|
||||||
|
LLM-Erwartungsprofil für preset_next / leere Anfrage mit Planungsbezug.
|
||||||
|
Returns (parsed overlay, applied).
|
||||||
|
"""
|
||||||
|
api_key, _ = normalize_openrouter_env()
|
||||||
|
if not api_key:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"heuristic_intent": heuristic_intent or "suggest_next",
|
||||||
|
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||||
|
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||||
|
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||||
|
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||||
|
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||||
|
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||||
|
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_expectation_profile", variables)
|
||||||
|
model = effective_openrouter_model_for_prompt_row(prow)
|
||||||
|
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||||
|
parsed = parse_planning_query_intent_response(raw)
|
||||||
|
if parsed.scenario not in ("preset_next", "continue_plan", "free_search"):
|
||||||
|
parsed = parsed.model_copy(update={"scenario": "preset_next"})
|
||||||
|
return parsed, True
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
return None, False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Planungs-Erwartungsprofil-LLM fehlgeschlagen: %s", exc)
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["try_build_planning_expectation_from_context"]
|
||||||
395
backend/planning_exercise_form_context.py
Normal file
395
backend/planning_exercise_form_context.py
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
"""
|
||||||
|
Planungs-KI Phase D: strukturierter Planungskontext für POST /exercises/ai/suggest.
|
||||||
|
|
||||||
|
Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instructions) injiziert.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
_MAX_JSON_CHARS = 6000
|
||||||
|
_MAX_STRING = 800
|
||||||
|
|
||||||
|
|
||||||
|
def compact_planning_context_json(obj: Any) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_str(val: Any, *, limit: int = _MAX_STRING) -> Optional[str]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
s = str(val).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if len(s) > limit:
|
||||||
|
return s[: limit - 1] + "…"
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_planning_context_for_ai(ctx: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Reduziert Client-Payload auf prompt-taugliche, begrenzte Felder."""
|
||||||
|
if not ctx:
|
||||||
|
return {}
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
for key, val in dict(ctx).items():
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
k = str(key).strip()
|
||||||
|
if not k:
|
||||||
|
continue
|
||||||
|
if isinstance(val, str):
|
||||||
|
t = _trim_str(val)
|
||||||
|
if t:
|
||||||
|
out[k] = t
|
||||||
|
elif isinstance(val, (int, float, bool)):
|
||||||
|
out[k] = val
|
||||||
|
elif isinstance(val, list):
|
||||||
|
items = []
|
||||||
|
for item in val[:12]:
|
||||||
|
if isinstance(item, str):
|
||||||
|
t = _trim_str(item, limit=200)
|
||||||
|
if t:
|
||||||
|
items.append(t)
|
||||||
|
elif isinstance(item, (int, float, bool)):
|
||||||
|
items.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
sub = sanitize_planning_context_for_ai(item)
|
||||||
|
if sub:
|
||||||
|
items.append(sub)
|
||||||
|
if items:
|
||||||
|
out[k] = items
|
||||||
|
elif isinstance(val, dict):
|
||||||
|
sub = sanitize_planning_context_for_ai(val)
|
||||||
|
if sub:
|
||||||
|
out[k] = sub
|
||||||
|
raw = compact_planning_context_json(out)
|
||||||
|
if len(raw) > _MAX_JSON_CHARS:
|
||||||
|
out["truncated"] = True
|
||||||
|
out.pop("path_steps_preview", None)
|
||||||
|
raw = compact_planning_context_json(out)
|
||||||
|
if len(raw) > _MAX_JSON_CHARS:
|
||||||
|
return {"source": out.get("source"), "truncated": True, "goal_query": out.get("goal_query")}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def planning_context_prompt_variables(
|
||||||
|
planning_context: Optional[Mapping[str, Any]],
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
cleaned = sanitize_planning_context_for_ai(planning_context)
|
||||||
|
if not cleaned:
|
||||||
|
return {"planning_context_json": "-", "has_planning_context": ""}
|
||||||
|
return {
|
||||||
|
"planning_context_json": compact_planning_context_json(cleaned),
|
||||||
|
"has_planning_context": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _major_index_from_step(step: Mapping[str, Any]) -> Optional[int]:
|
||||||
|
for key in ("roadmap_major_step_index", "major_step_index"):
|
||||||
|
raw = step.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prior_path_steps_before_major(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
major_idx: int,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Pfadschritte mit kleinerem roadmap_major_step_index, sortiert."""
|
||||||
|
prior: List[Dict[str, Any]] = []
|
||||||
|
for step in steps:
|
||||||
|
mi = _major_index_from_step(step)
|
||||||
|
if mi is not None and mi < major_idx:
|
||||||
|
prior.append(dict(step))
|
||||||
|
prior.sort(key=lambda s: _major_index_from_step(s) or 0)
|
||||||
|
return prior
|
||||||
|
|
||||||
|
|
||||||
|
def _step_display_fields(step: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
title = _trim_str(
|
||||||
|
step.get("title") or step.get("exercise_title"),
|
||||||
|
limit=200,
|
||||||
|
)
|
||||||
|
learning_goal = _trim_str(
|
||||||
|
step.get("roadmap_learning_goal") or step.get("learning_goal"),
|
||||||
|
limit=500,
|
||||||
|
)
|
||||||
|
summary = _trim_str(step.get("summary"), limit=400)
|
||||||
|
start_state = _trim_str(step.get("roadmap_start_state") or step.get("start_state"))
|
||||||
|
target_state = _trim_str(step.get("roadmap_target_state") or step.get("target_state"))
|
||||||
|
phase = _trim_str(step.get("roadmap_phase") or step.get("phase"))
|
||||||
|
criteria_raw = step.get("stage_success_criteria") or step.get("success_criteria") or []
|
||||||
|
criteria = [
|
||||||
|
t
|
||||||
|
for x in criteria_raw
|
||||||
|
if (t := _trim_str(x, limit=200))
|
||||||
|
][:4]
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"title": title,
|
||||||
|
"learning_goal": learning_goal,
|
||||||
|
"summary": summary,
|
||||||
|
"start_state": start_state,
|
||||||
|
"target_state": target_state,
|
||||||
|
"phase": phase,
|
||||||
|
"success_criteria": criteria or None,
|
||||||
|
"major_step_index": _major_index_from_step(step),
|
||||||
|
}
|
||||||
|
return {k: v for k, v in out.items() if v is not None and v != "" and v != []}
|
||||||
|
|
||||||
|
|
||||||
|
def build_progression_entry_state(
|
||||||
|
*,
|
||||||
|
major_step_index: Optional[int] = None,
|
||||||
|
prior_steps: Sequence[Mapping[str, Any]] = (),
|
||||||
|
start_situation: Optional[str] = None,
|
||||||
|
current_stage_start: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Eingangszustand für eine Roadmap-Stufe: erreichte Voraussetzungen aus Vorstufen.
|
||||||
|
"""
|
||||||
|
prior_compact = [_step_display_fields(s) for s in prior_steps]
|
||||||
|
prior_compact = [
|
||||||
|
p
|
||||||
|
for p in prior_compact
|
||||||
|
if any(p.get(k) for k in ("title", "learning_goal", "summary", "success_criteria"))
|
||||||
|
]
|
||||||
|
|
||||||
|
achievements: List[str] = []
|
||||||
|
detail_lines: List[str] = []
|
||||||
|
for p in prior_compact:
|
||||||
|
if p.get("success_criteria"):
|
||||||
|
achievements.extend(p["success_criteria"])
|
||||||
|
elif p.get("learning_goal"):
|
||||||
|
achievements.append(p["learning_goal"])
|
||||||
|
|
||||||
|
label_parts: List[str] = []
|
||||||
|
if p.get("major_step_index") is not None:
|
||||||
|
label_parts.append(f"Stufe {int(p['major_step_index']) + 1}")
|
||||||
|
if p.get("phase"):
|
||||||
|
label_parts.append(f"({p['phase']})")
|
||||||
|
if p.get("title"):
|
||||||
|
label_parts.append(f"„{p['title']}\"")
|
||||||
|
prefix = " ".join(label_parts) if label_parts else "Vorstufe"
|
||||||
|
achieved = ""
|
||||||
|
if p.get("target_state"):
|
||||||
|
achieved = p["target_state"]
|
||||||
|
elif p.get("success_criteria"):
|
||||||
|
achieved = "; ".join(p["success_criteria"])
|
||||||
|
elif p.get("learning_goal"):
|
||||||
|
achieved = p["learning_goal"]
|
||||||
|
elif p.get("summary"):
|
||||||
|
achieved = p["summary"]
|
||||||
|
if achieved:
|
||||||
|
detail_lines.append(f"{prefix}: erreicht — {achieved}")
|
||||||
|
|
||||||
|
immediate_entry: Optional[str] = _trim_str(current_stage_start)
|
||||||
|
if not immediate_entry and prior_compact:
|
||||||
|
immediate = prior_compact[-1]
|
||||||
|
if immediate.get("target_state"):
|
||||||
|
immediate_entry = immediate["target_state"]
|
||||||
|
elif immediate.get("success_criteria"):
|
||||||
|
immediate_entry = "; ".join(immediate["success_criteria"])
|
||||||
|
elif immediate.get("learning_goal"):
|
||||||
|
immediate_entry = immediate["learning_goal"]
|
||||||
|
elif immediate.get("summary"):
|
||||||
|
immediate_entry = immediate["summary"]
|
||||||
|
elif not immediate_entry and start_situation:
|
||||||
|
immediate_entry = start_situation
|
||||||
|
|
||||||
|
entry_state = immediate_entry or start_situation
|
||||||
|
if prior_compact and start_situation and not immediate_entry:
|
||||||
|
detail_lines.insert(0, f"Ausgangsbasis Pfad: {start_situation}")
|
||||||
|
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
if entry_state:
|
||||||
|
out["entry_state"] = _trim_str(entry_state, limit=1200)
|
||||||
|
if detail_lines:
|
||||||
|
out["entry_state_detail"] = _trim_str("\n".join(detail_lines), limit=2000)
|
||||||
|
if prior_compact:
|
||||||
|
out["prior_steps"] = prior_compact[:6]
|
||||||
|
if achievements:
|
||||||
|
out["prior_achievements"] = list(dict.fromkeys(achievements))[:8]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_gap_snapshot_with_entry_state(
|
||||||
|
snapshot: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
major_step_index: Optional[int],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
snap = dict(snapshot)
|
||||||
|
if major_step_index is None:
|
||||||
|
return snap
|
||||||
|
try:
|
||||||
|
mi = int(major_step_index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return snap
|
||||||
|
prior = prior_path_steps_before_major(steps, mi)
|
||||||
|
entry = build_progression_entry_state(
|
||||||
|
major_step_index=mi,
|
||||||
|
prior_steps=prior,
|
||||||
|
start_situation=snap.get("start_situation"),
|
||||||
|
current_stage_start=snap.get("stage_start_state"),
|
||||||
|
)
|
||||||
|
snap.update(entry)
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
def build_progression_gap_snapshot(
|
||||||
|
*,
|
||||||
|
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||||
|
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||||
|
stage_spec: Optional[Mapping[str, Any]] = None,
|
||||||
|
semantic_brief: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Kompakter Roadmap-Kontext für Lücken-Übungen (Start, Ziel, Stufe, Fähigkeiten-Hinweise)."""
|
||||||
|
ga = dict(goal_analysis or {})
|
||||||
|
rs = dict(resolved_structured or {})
|
||||||
|
spec = dict(stage_spec or {})
|
||||||
|
brief = dict(semantic_brief or {})
|
||||||
|
|
||||||
|
start = _trim_str(rs.get("start_situation") or ga.get("start_assumption"))
|
||||||
|
target = _trim_str(rs.get("target_state") or ga.get("target_state"))
|
||||||
|
notes = _trim_str(rs.get("roadmap_notes"))
|
||||||
|
topic = _trim_str(ga.get("primary_topic") or brief.get("primary_topic"))
|
||||||
|
|
||||||
|
skill_hints: List[str] = []
|
||||||
|
for item in (brief.get("must_phrases") or [])[:4]:
|
||||||
|
t = _trim_str(item, limit=120)
|
||||||
|
if t:
|
||||||
|
skill_hints.append(t)
|
||||||
|
arc = brief.get("development_arc")
|
||||||
|
if isinstance(arc, list) and arc:
|
||||||
|
skill_hints.append(f"Entwicklungsbogen: {' → '.join(str(x) for x in arc[:5])}")
|
||||||
|
|
||||||
|
success_path = [
|
||||||
|
_trim_str(x, limit=200)
|
||||||
|
for x in (ga.get("success_criteria") or [])
|
||||||
|
if _trim_str(x, limit=200)
|
||||||
|
][:4]
|
||||||
|
stage_success = [
|
||||||
|
_trim_str(x, limit=200)
|
||||||
|
for x in (spec.get("success_criteria") or [])
|
||||||
|
if _trim_str(x, limit=200)
|
||||||
|
][:4]
|
||||||
|
load_profile = [
|
||||||
|
_trim_str(x, limit=80)
|
||||||
|
for x in (spec.get("load_profile") or [])
|
||||||
|
if _trim_str(x, limit=80)
|
||||||
|
][:6]
|
||||||
|
anti_patterns = [
|
||||||
|
_trim_str(x, limit=200)
|
||||||
|
for x in (spec.get("anti_patterns") or [])
|
||||||
|
if _trim_str(x, limit=200)
|
||||||
|
][:3]
|
||||||
|
|
||||||
|
snap: Dict[str, Any] = {
|
||||||
|
"primary_topic": topic,
|
||||||
|
"start_situation": start,
|
||||||
|
"target_state": target,
|
||||||
|
"roadmap_notes": notes,
|
||||||
|
"stage_learning_goal": _trim_str(
|
||||||
|
spec.get("learning_goal"), limit=1200
|
||||||
|
),
|
||||||
|
"stage_start_state": _trim_str(spec.get("start_state")),
|
||||||
|
"stage_target_state": _trim_str(spec.get("target_state")),
|
||||||
|
"stage_phase": _trim_str(spec.get("phase")),
|
||||||
|
"stage_exercise_type": _trim_str(spec.get("exercise_type")),
|
||||||
|
"stage_load_profile": load_profile or None,
|
||||||
|
"stage_success_criteria": stage_success or None,
|
||||||
|
"stage_anti_patterns": anti_patterns or None,
|
||||||
|
"path_success_criteria": success_path or None,
|
||||||
|
"skill_hints": skill_hints or None,
|
||||||
|
}
|
||||||
|
return {k: v for k, v in snap.items() if v is not None and v != "" and v != []}
|
||||||
|
|
||||||
|
|
||||||
|
def build_progression_path_gap_planning_context(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
primary_topic: Optional[str] = None,
|
||||||
|
progression_graph_id: Optional[int] = None,
|
||||||
|
offer: Optional[Mapping[str, Any]] = None,
|
||||||
|
neighbor_before: Optional[Mapping[str, Any]] = None,
|
||||||
|
neighbor_after: Optional[Mapping[str, Any]] = None,
|
||||||
|
prior_path_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
path_step_count: int = 0,
|
||||||
|
major_step_count: Optional[int] = None,
|
||||||
|
roadmap_phase: Optional[str] = None,
|
||||||
|
roadmap_learning_goal: Optional[str] = None,
|
||||||
|
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||||
|
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||||
|
stage_spec: Optional[Mapping[str, Any]] = None,
|
||||||
|
semantic_brief: Optional[Mapping[str, Any]] = None,
|
||||||
|
stage_learning_goal_override: Optional[str] = None,
|
||||||
|
gap_trainer_supplements: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
|
||||||
|
offer = offer or {}
|
||||||
|
gap = offer.get("gap") if isinstance(offer.get("gap"), dict) else {}
|
||||||
|
major_idx = offer.get("roadmap_major_step_index")
|
||||||
|
if major_idx is None and isinstance(gap, dict):
|
||||||
|
major_idx = gap.get("roadmap_major_step_index")
|
||||||
|
|
||||||
|
ctx: Dict[str, Any] = {
|
||||||
|
"source": "progression_path_gap_fill",
|
||||||
|
"goal_query": _trim_str(goal_query, limit=2000),
|
||||||
|
"primary_topic": _trim_str(primary_topic),
|
||||||
|
"progression_graph_id": progression_graph_id,
|
||||||
|
"gap_source": _trim_str(offer.get("source")),
|
||||||
|
"gap_phase": _trim_str(offer.get("phase") or gap.get("expected_phase")),
|
||||||
|
"roadmap_major_step_index": major_idx,
|
||||||
|
"roadmap_phase": _trim_str(roadmap_phase or offer.get("phase")),
|
||||||
|
"roadmap_learning_goal": _trim_str(
|
||||||
|
roadmap_learning_goal or offer.get("title_hint") or gap.get("learning_goal"),
|
||||||
|
limit=1200,
|
||||||
|
),
|
||||||
|
"neighbor_before_title": _trim_str(
|
||||||
|
(neighbor_before or {}).get("title") or offer.get("from_title")
|
||||||
|
),
|
||||||
|
"neighbor_after_title": _trim_str(
|
||||||
|
(neighbor_after or {}).get("title") or offer.get("to_title")
|
||||||
|
),
|
||||||
|
"path_step_count": path_step_count,
|
||||||
|
"major_step_count": major_step_count,
|
||||||
|
}
|
||||||
|
snap = build_progression_gap_snapshot(
|
||||||
|
goal_analysis=goal_analysis,
|
||||||
|
resolved_structured=resolved_structured,
|
||||||
|
stage_spec=stage_spec,
|
||||||
|
semantic_brief=semantic_brief,
|
||||||
|
)
|
||||||
|
ctx.update(snap)
|
||||||
|
if major_idx is not None and prior_path_steps:
|
||||||
|
ctx.update(
|
||||||
|
build_progression_entry_state(
|
||||||
|
major_step_index=major_idx,
|
||||||
|
prior_steps=list(prior_path_steps),
|
||||||
|
start_situation=ctx.get("start_situation"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if stage_learning_goal_override and stage_learning_goal_override.strip():
|
||||||
|
ctx["stage_learning_goal"] = _trim_str(stage_learning_goal_override, limit=1200)
|
||||||
|
ctx["roadmap_learning_goal"] = ctx["stage_learning_goal"]
|
||||||
|
if gap_trainer_supplements and gap_trainer_supplements.strip():
|
||||||
|
ctx["gap_trainer_supplements"] = _trim_str(gap_trainer_supplements, limit=2000)
|
||||||
|
return sanitize_planning_context_for_ai(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_progression_entry_state",
|
||||||
|
"build_progression_gap_snapshot",
|
||||||
|
"build_progression_path_gap_planning_context",
|
||||||
|
"enrich_gap_snapshot_with_entry_state",
|
||||||
|
"prior_path_steps_before_major",
|
||||||
|
"compact_planning_context_json",
|
||||||
|
"planning_context_prompt_variables",
|
||||||
|
"sanitize_planning_context_for_ai",
|
||||||
|
]
|
||||||
272
backend/planning_exercise_intent.py
Normal file
272
backend/planning_exercise_intent.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""
|
||||||
|
P1: LLM-Intent aus Planungs-Suchfrage → strukturiertes Query-Overlay für PlanningTargetProfile.
|
||||||
|
|
||||||
|
Prompt: planning_exercise_search_intent (Migration 073)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
|
from openrouter_chat import (
|
||||||
|
effective_openrouter_model_for_prompt_row,
|
||||||
|
normalize_openrouter_env,
|
||||||
|
openrouter_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_intent")
|
||||||
|
|
||||||
|
VALID_PARSED_INTENTS = {
|
||||||
|
"suggest_next",
|
||||||
|
"progression_next",
|
||||||
|
"deepen_exercise",
|
||||||
|
"continue_plan_goal",
|
||||||
|
"free_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_SCENARIOS = {
|
||||||
|
"preset_next",
|
||||||
|
"progression",
|
||||||
|
"deepen",
|
||||||
|
"continue_plan",
|
||||||
|
"additive_constraint",
|
||||||
|
"free_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_EMPHASIS = {"additive", "replace", "neutral"}
|
||||||
|
|
||||||
|
|
||||||
|
class SkillHint(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningQueryIntentParsed(BaseModel):
|
||||||
|
intent: str = "free_search"
|
||||||
|
scenario: str = "free_search"
|
||||||
|
skill_hints: List[SkillHint] = Field(default_factory=list)
|
||||||
|
focus_hints: List[str] = Field(default_factory=list)
|
||||||
|
style_hints: List[str] = Field(default_factory=list)
|
||||||
|
training_type_hints: List[str] = Field(default_factory=list)
|
||||||
|
target_group_hints: List[str] = Field(default_factory=list)
|
||||||
|
requires_partner: Optional[bool] = None
|
||||||
|
emphasis: str = "additive"
|
||||||
|
rationale: Optional[str] = Field(default=None, max_length=400)
|
||||||
|
|
||||||
|
@field_validator("intent")
|
||||||
|
@classmethod
|
||||||
|
def _intent(cls, v: str) -> str:
|
||||||
|
s = (v or "").strip().lower()
|
||||||
|
return s if s in VALID_PARSED_INTENTS else "free_search"
|
||||||
|
|
||||||
|
@field_validator("scenario")
|
||||||
|
@classmethod
|
||||||
|
def _scenario(cls, v: str) -> str:
|
||||||
|
s = (v or "").strip().lower()
|
||||||
|
return s if s in VALID_SCENARIOS else "free_search"
|
||||||
|
|
||||||
|
@field_validator("emphasis")
|
||||||
|
@classmethod
|
||||||
|
def _emphasis(cls, v: str) -> str:
|
||||||
|
s = (v or "").strip().lower()
|
||||||
|
return s if s in VALID_EMPHASIS else "additive"
|
||||||
|
|
||||||
|
@field_validator("focus_hints", "style_hints", "training_type_hints", "target_group_hints", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _str_list(cls, v: Any) -> List[str]:
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [v.strip()] if v.strip() else []
|
||||||
|
out: List[str] = []
|
||||||
|
for item in v:
|
||||||
|
s = str(item or "").strip()
|
||||||
|
if s and s not in out:
|
||||||
|
out.append(s[:120])
|
||||||
|
return out[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||||
|
s = (text or "").strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
|
if s.endswith("```"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
start = s.find("{")
|
||||||
|
end = s.rfind("}")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||||
|
obj = json.loads(s[start : end + 1])
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def parse_planning_query_intent_response(text: str) -> PlanningQueryIntentParsed:
|
||||||
|
obj = _extract_json_object(text)
|
||||||
|
return PlanningQueryIntentParsed.model_validate(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_json(obj: Any) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_compact_catalog(cur, table: str, id_col: str, name_col: str = "name", limit: int = 80) -> List[Dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {id_col} AS id, {name_col} AS name
|
||||||
|
FROM {table}
|
||||||
|
ORDER BY {name_col} ASC NULLS LAST
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [{"id": int(r["id"]), "name": str(r["name"] or "")[:80]} for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skills_catalog_compact(cur, limit: int = 120) -> List[Dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, category
|
||||||
|
FROM skills
|
||||||
|
WHERE status IS NULL OR status = 'active'
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(r["id"]),
|
||||||
|
"name": str(r["name"] or "")[:80],
|
||||||
|
"category": str(r.get("category") or "")[:40],
|
||||||
|
}
|
||||||
|
for r in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_name_hint(cur, table: str, hint: str, *, extra_where: str = "") -> Optional[int]:
|
||||||
|
h = (hint or "").strip()
|
||||||
|
if len(h) < 2:
|
||||||
|
return None
|
||||||
|
q = h.lower()
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, name
|
||||||
|
FROM {table}
|
||||||
|
WHERE LOWER(name) LIKE %s {extra_where}
|
||||||
|
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||||
|
LENGTH(name) ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(f"%{q}%", q, f"{q}%"),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row["id"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_query_intent_catalog_ids(
|
||||||
|
cur,
|
||||||
|
parsed: PlanningQueryIntentParsed,
|
||||||
|
) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Mappt Text-Hints auf Katalog-IDs. Returns (focus, style, tt, tg, skills, resolved_skills_meta).
|
||||||
|
"""
|
||||||
|
focus: Dict[int, float] = {}
|
||||||
|
style: Dict[int, float] = {}
|
||||||
|
tt: Dict[int, float] = {}
|
||||||
|
tg: Dict[int, float] = {}
|
||||||
|
skills: Dict[int, float] = {}
|
||||||
|
resolved_skills: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for hint in parsed.focus_hints:
|
||||||
|
fid = _resolve_name_hint(cur, "focus_areas", hint)
|
||||||
|
if fid:
|
||||||
|
focus[fid] = max(focus.get(fid, 0.0), 0.9)
|
||||||
|
|
||||||
|
for hint in parsed.style_hints:
|
||||||
|
sid = _resolve_name_hint(cur, "style_directions", hint)
|
||||||
|
if sid:
|
||||||
|
style[sid] = max(style.get(sid, 0.0), 0.85)
|
||||||
|
|
||||||
|
for hint in parsed.training_type_hints:
|
||||||
|
tid = _resolve_name_hint(cur, "training_types", hint)
|
||||||
|
if tid:
|
||||||
|
tt[tid] = max(tt.get(tid, 0.0), 0.85)
|
||||||
|
|
||||||
|
for hint in parsed.target_group_hints:
|
||||||
|
gid = _resolve_name_hint(cur, "target_groups", hint)
|
||||||
|
if gid:
|
||||||
|
tg[gid] = max(tg.get(gid, 0.0), 0.85)
|
||||||
|
|
||||||
|
for sh in parsed.skill_hints[:8]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name FROM skills
|
||||||
|
WHERE (status IS NULL OR status = 'active')
|
||||||
|
AND LOWER(name) LIKE %s
|
||||||
|
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||||
|
LENGTH(name) ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(f"%{sh.name.lower()}%", sh.name.lower(), f"{sh.name.lower()}%"),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
sid = int(row["id"])
|
||||||
|
skills[sid] = max(skills.get(sid, 0.0), float(sh.weight))
|
||||||
|
resolved_skills.append({"skill_id": sid, "name": str(row["name"] or sh.name), "weight": skills[sid]})
|
||||||
|
|
||||||
|
return focus, style, tt, tg, skills, resolved_skills
|
||||||
|
|
||||||
|
|
||||||
|
def try_parse_planning_query_intent(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
heuristic_intent: str,
|
||||||
|
scenario_hint: str,
|
||||||
|
context_summary: Mapping[str, Any],
|
||||||
|
target_profile_summary: Mapping[str, Any],
|
||||||
|
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
||||||
|
api_key, _ = normalize_openrouter_env()
|
||||||
|
if not api_key or not (query or "").strip():
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"search_query": (query or "").strip(),
|
||||||
|
"heuristic_intent": heuristic_intent or "",
|
||||||
|
"scenario_hint": scenario_hint or "",
|
||||||
|
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||||
|
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||||
|
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||||
|
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||||
|
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||||
|
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||||
|
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_intent", variables)
|
||||||
|
model = effective_openrouter_model_for_prompt_row(prow)
|
||||||
|
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||||
|
parsed = parse_planning_query_intent_response(raw)
|
||||||
|
return parsed, True
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
return None, False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Planungs-Intent-LLM fehlgeschlagen: %s", exc)
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlanningQueryIntentParsed",
|
||||||
|
"parse_planning_query_intent_response",
|
||||||
|
"resolve_query_intent_catalog_ids",
|
||||||
|
"try_parse_planning_query_intent",
|
||||||
|
]
|
||||||
223
backend/planning_exercise_llm_rank.py
Normal file
223
backend/planning_exercise_llm_rank.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""
|
||||||
|
Phase 2 Planungs-Übungssuche: LLM-Rerank über Hybrid-Kandidaten.
|
||||||
|
|
||||||
|
Prompt-Slug: planning_exercise_search_rank (Migration 072)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
|
from exercise_ai import strip_html_to_plain
|
||||||
|
from openrouter_chat import (
|
||||||
|
effective_openrouter_model_for_prompt_row,
|
||||||
|
normalize_openrouter_env,
|
||||||
|
openrouter_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_llm_rank")
|
||||||
|
|
||||||
|
_LLM_RERANK_POOL = 32
|
||||||
|
_MAX_GOAL_PLAIN = 480
|
||||||
|
_MAX_SUMMARY_PLAIN = 320
|
||||||
|
_MAX_REASON_LEN = 160
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_json(obj: Any) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||||
|
s = (text or "").strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
|
if s.endswith("```"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
start = s.find("{")
|
||||||
|
end = s.rfind("}")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||||
|
obj = json.loads(s[start : end + 1])
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def parse_planning_exercise_rank_response(
|
||||||
|
text: str,
|
||||||
|
allowed_ids: Set[int],
|
||||||
|
) -> Tuple[List[int], Dict[int, str]]:
|
||||||
|
"""
|
||||||
|
Validiert LLM-Ranking: nur erlaubte exercise_id, dedupliziert, Reihenfolge beibehalten.
|
||||||
|
"""
|
||||||
|
obj = _extract_json_object(text)
|
||||||
|
ranked_raw = obj.get("ranked_ids") or obj.get("ranked") or obj.get("ids")
|
||||||
|
if not isinstance(ranked_raw, list):
|
||||||
|
raise ValueError("ranked_ids fehlt oder ist keine Liste")
|
||||||
|
|
||||||
|
ranked: List[int] = []
|
||||||
|
seen: Set[int] = set()
|
||||||
|
for raw in ranked_raw:
|
||||||
|
try:
|
||||||
|
eid = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if eid < 1 or eid not in allowed_ids or eid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(eid)
|
||||||
|
ranked.append(eid)
|
||||||
|
|
||||||
|
reasons_out: Dict[int, str] = {}
|
||||||
|
reasons_raw = obj.get("reasons") or obj.get("reasons_by_id") or {}
|
||||||
|
if isinstance(reasons_raw, dict):
|
||||||
|
for k, v in reasons_raw.items():
|
||||||
|
try:
|
||||||
|
eid = int(k)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if eid not in allowed_ids:
|
||||||
|
continue
|
||||||
|
txt = str(v or "").strip()
|
||||||
|
if txt:
|
||||||
|
reasons_out[eid] = txt[:_MAX_REASON_LEN]
|
||||||
|
|
||||||
|
return ranked, reasons_out
|
||||||
|
|
||||||
|
|
||||||
|
def _build_candidate_payload(
|
||||||
|
hit: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
goal_plain: str,
|
||||||
|
skill_names: Sequence[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": int(hit["id"]),
|
||||||
|
"title": str(hit.get("title") or "").strip()[:200],
|
||||||
|
"summary": strip_html_to_plain(hit.get("summary"), max_len=_MAX_SUMMARY_PLAIN),
|
||||||
|
"goal": goal_plain,
|
||||||
|
"skills": list(skill_names)[:8],
|
||||||
|
"retrieval_score": float(hit.get("score") or 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_exercise_goals(cur, exercise_ids: Sequence[int]) -> Dict[int, str]:
|
||||||
|
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
ph = ",".join(["%s"] * len(ids))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id, goal FROM exercises WHERE id IN ({ph})",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
return {int(r["id"]): str(r.get("goal") or "") for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skill_names(cur, skill_ids: Sequence[int]) -> Dict[int, str]:
|
||||||
|
ids = sorted({int(x) for x in skill_ids if int(x) > 0})
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
ph = ",".join(["%s"] * len(ids))
|
||||||
|
cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", ids)
|
||||||
|
return {int(r["id"]): str(r.get("name") or "") for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def try_llm_rerank_planning_hits(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
hits: List[Dict[str, Any]],
|
||||||
|
skills_by_ex: Mapping[int, Set[int]],
|
||||||
|
query: str,
|
||||||
|
intent: str,
|
||||||
|
context_summary: Mapping[str, Any],
|
||||||
|
target_profile_summary: Mapping[str, Any],
|
||||||
|
limit: int,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||||
|
"""
|
||||||
|
Optionaler LLM-Rerank der Top-Kandidaten. Bei Fehler: Original-Reihenfolge, llm_applied=False.
|
||||||
|
"""
|
||||||
|
if not hits:
|
||||||
|
return hits, False
|
||||||
|
|
||||||
|
api_key, _ = normalize_openrouter_env()
|
||||||
|
if not api_key:
|
||||||
|
return hits, False
|
||||||
|
|
||||||
|
pool = hits[:_LLM_RERANK_POOL]
|
||||||
|
allowed_ids = {int(h["id"]) for h in pool}
|
||||||
|
goals = _load_exercise_goals(cur, list(allowed_ids))
|
||||||
|
|
||||||
|
all_skill_ids: Set[int] = set()
|
||||||
|
for eid in allowed_ids:
|
||||||
|
all_skill_ids.update(skills_by_ex.get(eid) or set())
|
||||||
|
skill_name_map = _load_skill_names(cur, list(all_skill_ids))
|
||||||
|
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
for hit in pool:
|
||||||
|
eid = int(hit["id"])
|
||||||
|
sk_ids = sorted(skills_by_ex.get(eid) or set())
|
||||||
|
sk_names = [skill_name_map.get(sid, f"#{sid}") for sid in sk_ids[:8]]
|
||||||
|
goal_plain = strip_html_to_plain(goals.get(eid), max_len=_MAX_GOAL_PLAIN)
|
||||||
|
candidates.append(
|
||||||
|
_build_candidate_payload(hit, goal_plain=goal_plain, skill_names=sk_names)
|
||||||
|
)
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"search_query": query or "",
|
||||||
|
"intent": intent or "",
|
||||||
|
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||||
|
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||||
|
"candidates_json": _compact_json(candidates),
|
||||||
|
"result_limit": str(max(1, min(int(limit), 50))),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_rank", variables)
|
||||||
|
model = effective_openrouter_model_for_prompt_row(prow)
|
||||||
|
raw = openrouter_chat_completion(
|
||||||
|
api_key=api_key,
|
||||||
|
model=model,
|
||||||
|
user_content=rendered.text,
|
||||||
|
)
|
||||||
|
ranked_ids, llm_reasons = parse_planning_exercise_rank_response(raw, allowed_ids)
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
return hits, False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Planungs-LLM-Rerank fehlgeschlagen: %s", exc)
|
||||||
|
return hits, False
|
||||||
|
|
||||||
|
if not ranked_ids:
|
||||||
|
return hits, False
|
||||||
|
|
||||||
|
hit_by_id = {int(h["id"]): h for h in hits}
|
||||||
|
reranked: List[Dict[str, Any]] = []
|
||||||
|
used: Set[int] = set()
|
||||||
|
for eid in ranked_ids:
|
||||||
|
hit = hit_by_id.get(eid)
|
||||||
|
if not hit:
|
||||||
|
continue
|
||||||
|
used.add(eid)
|
||||||
|
new_hit = dict(hit)
|
||||||
|
reasons = list(hit.get("reasons") or [])
|
||||||
|
llm_reason = llm_reasons.get(eid)
|
||||||
|
if llm_reason and llm_reason not in reasons:
|
||||||
|
reasons.insert(0, llm_reason)
|
||||||
|
new_hit["reasons"] = reasons
|
||||||
|
new_hit["llm_rank"] = len(reranked) + 1
|
||||||
|
reranked.append(new_hit)
|
||||||
|
|
||||||
|
for hit in hits:
|
||||||
|
eid = int(hit["id"])
|
||||||
|
if eid in used:
|
||||||
|
continue
|
||||||
|
reranked.append(dict(hit))
|
||||||
|
|
||||||
|
return reranked[: max(int(limit), len(reranked))], True
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"parse_planning_exercise_rank_response",
|
||||||
|
"try_llm_rerank_planning_hits",
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user