Compare commits
237 Commits
vor_Multi_
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e8f51566 | |||
| 7e5ef4561a | |||
| 53f2b027cc | |||
| 9cee862c32 | |||
| 0b203489f7 | |||
| 1c67a50ce4 | |||
| 87d9fa9b65 | |||
| 4b9374765b | |||
| b629f192ac | |||
| 313d613b7c | |||
| 7265cd5a01 | |||
| 5e5f4ca8d4 | |||
| f0e581a9f5 | |||
| cd457e3ea0 | |||
| e9bf5bd1a5 | |||
| 3468b2066e | |||
| a1e4ad66df | |||
| 85fccdd093 | |||
| 19bbcdaf50 | |||
| cec96ae473 | |||
| 53f1c7161f | |||
| 89c6780294 | |||
| 3f130aa8ad | |||
| 69ce3f6975 | |||
| dccb065181 | |||
| e828a5da32 | |||
| 5bca5ef9eb | |||
| 5ed06002d9 | |||
| b8f65e04c5 | |||
| f3710ac0a1 | |||
| 6ab2f20f08 | |||
| a4e73c830f | |||
| 63c99b0ec5 | |||
| d448c3191f | |||
| 8a4be795f4 | |||
| a49987408b | |||
| f36a747efa | |||
| de9fdf3ac0 | |||
| 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 | |||
| 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 | |||
| bd5a409fa7 | |||
| 3450a9296a | |||
| 8d1dd59c3c | |||
| 5b73d1a1f5 | |||
| c2c736dafc | |||
| c6b8c396ad | |||
| a19ed02300 | |||
| a34e748be5 | |||
| b2157d8a40 | |||
| 50aff849d8 | |||
| a0a891e550 | |||
| 46fae3da33 | |||
| f4196c3580 | |||
| d1d8539b42 | |||
| a8633235f2 | |||
| 5c882985e0 | |||
| 04cc77d501 | |||
| 8e68261bc1 | |||
| b0611b9f7f | |||
| 614c2dcfaa | |||
| f5c886fc13 | |||
| d019c20338 | |||
| 905bce198f | |||
| 45e3b5f4f6 | |||
| 207817376d | |||
| 128a9d752e | |||
| d7d45a8927 | |||
| 9d880e2346 | |||
| c816e50c68 | |||
| 294740b780 | |||
| 675cfa85f0 | |||
| 4725eaa90b | |||
| 9f4678f418 | |||
| 5331eab39c | |||
| 93b8d09d05 | |||
| 0551bb3d80 | |||
| e22266a18c | |||
| d58db3d5dd | |||
| cdeddc7cec | |||
| 2148d0aa7f | |||
| f9e295bce0 | |||
| 888d0bd009 | |||
| 1942585546 | |||
| a28a9d399a | |||
| 9be69ace5c | |||
| 286c36e9d7 | |||
| 294b09a5d9 | |||
| e5291256d0 | |||
| 4d36bbf634 | |||
| e4451e1362 | |||
| 7245bbb1da | |||
| 5f67c01cef | |||
| 13a1d3a060 | |||
| 7f62b6ceee | |||
| 9b3f594007 | |||
| 5d308b20ba | |||
| 1d698e4b0a | |||
| a7a428745f | |||
| 2d187447bb | |||
| 2de4c0b7c9 | |||
| 34966b9e84 | |||
| 9a0cf7f823 | |||
| 78c6c51520 | |||
| 5200895a73 | |||
| 8f8bdf6d8b | |||
| f67bf280c3 | |||
| 732b322c52 | |||
| e382b6ed35 | |||
| a4548f5587 | |||
| 9d122d4808 | |||
| 9c3494a7ea | |||
| 9353909fda | |||
| 5a8a212f40 | |||
| ab612a5335 | |||
| b2f77ca627 | |||
| 39b1fd04f0 | |||
| 9020e5eb16 | |||
| 46feb4c867 | |||
| 3067b2e6a8 | |||
| 728b37ad5f | |||
| 8afdd811db | |||
| 4588ef4c7e | |||
| 6e6270b717 | |||
| 14b005e9b8 | |||
| ef4dd93324 | |||
| e50c18f92e | |||
| d19a1061d8 | |||
| cb868373f4 | |||
| 472cf1afdb | |||
| 0cb0e81d27 | |||
| 6a9351874f | |||
| 734d943d73 | |||
| 16eaf839e7 | |||
| 295c7e7efc | |||
| c9175bd2fd | |||
| f15aa7c415 | |||
| 1684892bcb | |||
| 4fee5a2b47 | |||
| 82705f0c3e | |||
| a51f794945 | |||
| 7693139242 | |||
| 623af621b4 | |||
| 949a77fe38 | |||
| 0275f76432 | |||
| bc1790bd82 | |||
| 8c07cf36ee | |||
| 7d2661a8e8 | |||
| 0fdee610ed | |||
| f1c470a8a3 | |||
| 736656bde8 | |||
| e441f59bff | |||
| c3eb5a62c4 | |||
| 79e748b470 | |||
| 88c4201f80 | |||
| 6e1cc62065 | |||
| 76cc81a385 | |||
| a4f11a8225 | |||
| 5e5350d5ac | |||
| 73ac2218c7 | |||
| 352237bbb9 | |||
| 4cf7133bce | |||
| c182ced7cd | |||
| 5338871f36 | |||
| 3005f1cb3e | |||
| 72e8f31cff | |||
| 73975d3402 | |||
| 4902771772 | |||
| c2efbee4ee | |||
| 514b64682c | |||
| a0a0be8bef | |||
| 613fedfaff | |||
| 2e761161ef | |||
| 0a203aaf75 | |||
| f50e9db523 | |||
| 749c185e3d | |||
| 214f90d39b |
|
|
@ -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**, **F15** Match-Dialog + getrennte Pfad-QS lokal): 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.
|
||||||
|
|
@ -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,25 +475,51 @@ 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**).
|
||||||
|
|
||||||
### Parallele Trainingsstreams (Breakout, Entwurf)
|
### 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.
|
**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).
|
**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`.
|
**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
||||||
|
|
||||||
**Schema-Hinweis (2026-05):** Tabelle `training_unit_sections` hat **`UNIQUE (training_unit_id, order_index)`** (Migration 031). Damit sind **zwei gleichzeitige „Spuren“ mit jeweils eigener Sektion auf derselben `order_index`** nicht abbildbar — Voraussetzung für Parallele Streams ist eine **geplante Migrations-/Constraint-Anpassung** (partielle Uniques pro Phase/Stream); siehe Arbeitsdokument `.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md`. **Keine invasive Migration ohne explizite Freigabe.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
|
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
|
||||||
|
|
||||||
- **`media_assets`:** Zentrale Datei-/Asset-Zeile (technisch u. a. SHA‑Dedupe, Sichtbarkeit, `club_id`, Lifecycle, Copyright, Speicherreferenz unter `library/…`). Siehe **`DATABASE_SCHEMA.md`**, **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**.
|
- **`media_assets`:** Zentrale Datei-/Asset-Zeile (technisch u. a. SHA‑Dedupe, Sichtbarkeit, `club_id`, Lifecycle, Copyright, Speicherreferenz unter `library/…`). Siehe **`DATABASE_SCHEMA.md`**, **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**.
|
||||||
- **`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -650,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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Parallele Trainingsstreams (Breakout) — Fachkonzept
|
# Parallele Trainingsstreams (Breakout) — Fachkonzept
|
||||||
|
|
||||||
**Status:** Entwurf zur Abstimmung · **Stand:** 2026-05-14
|
**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**.
|
**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`
|
**Technische Ausarbeitung:** `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
||||||
|
|
@ -95,7 +95,14 @@ Alle Streams (oder alle Kinder insgesamt) **wechseln gleichzeitig** zur nächste
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Verwandte Dokumente
|
## 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 |
|
| Dokument | Bezug |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
|
|
@ -103,4 +110,5 @@ Alle Streams (oder alle Kinder insgesamt) **wechseln gleichzeitig** zur nächste
|
||||||
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombinationsübungen, Archetypen, Stationslogik **im Item** |
|
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombinationsübungen, Archetypen, Stationslogik **im Item** |
|
||||||
| `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` | Fachliche Tiefe Kombi |
|
| `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` | Fachliche Tiefe Kombi |
|
||||||
| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick |
|
| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick |
|
||||||
|
| `docs/HANDOVER.md` | Ist-Stand Coach, offene Breakout-Punkte |
|
||||||
| `technical/DATABASE_SCHEMA.md` | Aktueller Stand Tabellen |
|
| `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`
|
||||||
|
|
@ -1,23 +1,26 @@
|
||||||
# Parallele Trainingsstreams — Technische Spezifikation (Umsetzung)
|
# Parallele Trainingsstreams — Technische Spezifikation (Umsetzung)
|
||||||
|
|
||||||
**Status:** Entwurf · **Stand:** 2026-05-14
|
**Status:** Umsetzung **Phase 1 (teils)** · **Stand:** 2026-05-14
|
||||||
**Fachgrundlage:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
|
**Fachgrundlage:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
|
||||||
|
|
||||||
Dieses Dokument beschreibt die **Umsetzung** auf Basis der **aktuellen Codebasis** (Stand Analyse 2026-05-14): eine `training_unit` mit **`training_unit_sections`** und **`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 **`TrainingUnitRunPage`** (sequentiell über Sektionen).
|
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 (relevant)
|
## 1. Ist-Stand (Code, 2026-05-14)
|
||||||
|
|
||||||
| Bereich | Aktuell |
|
| Bereich | Aktuell |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| Planstruktur | **Eine** lineare Liste `training_unit_sections` je `training_unit_id`; Items in `training_unit_section_items`. |
|
| **Schema** | Migration **063:** `training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` **oder** `parallel_stream_id`. |
|
||||||
| Rahmenprogramm | `training_framework_slots` verweisen auf **Blueprint**-`training_units` — Slots = **Serien-Spalten**, nicht simultane Breakouts in **einer** Halle. |
|
| **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. |
|
||||||
| Kombinationsübung | Ein **Item** kann Kombi sein; `planning_method_profile` = Snapshot; Coaching-UI teilweise (`CombinationPlanBracket` in Run/Peek). |
|
| **Planung (UI)** | Breakout-Panel: Ganzgruppen-/parallele Phasen, Streams; Speichern phasenbasiert (`trainingUnitSectionsForm.js`, `TrainingPlanningPage`). |
|
||||||
| Trainer-Zuweisung | `lead_trainer_profile_id`, `assistant_trainer_profile_ids` am **`training_units`**-Kopf; **keine** Zuordnung zu „welcher parallelen Spur“. |
|
| **Durchführung** | `TrainingUnitRunPage.jsx` + `trainingPlanUtils.js` (`sectionsWithPlanLocForDisplay`, `buildPlanRunViewModelFromSections`) — Phasenfolge in „Plan & Ablauf“. |
|
||||||
| Run-Modus | `TrainingUnitRunPage`: sortierte Sektionen/Items, Checkliste, Fortschritt in `sessionStorage` pro Einheit. |
|
| **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**). |
|
||||||
|
|
||||||
**Konsequenz:** Parallele Streams erfordern ein **erweitertes konzeptionelles „Gefäß“** unterhalb der Einheit (Phasen und/oder Streams) und eine **Verknüpfung** bestehender Sektionen mit diesem Gefäß — oder eine **Migration** zu einem neuen Pflicht-Container (siehe §3).
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -37,9 +40,11 @@ training_unit (Kalender-Einheit)
|
||||||
|
|
||||||
## 3. Datenmodell — Optionen
|
## 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)
|
### 3.1 Empfohlen: explizite Phasen + Streams (normalisiert)
|
||||||
|
|
||||||
Neue Tabellen (Namen bei Implementierung final festlegen):
|
Die Tabellen sind **umgesetzt** (Namen final):
|
||||||
|
|
||||||
| Tabelle | Zweck |
|
| Tabelle | Zweck |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
|
|
@ -109,15 +114,15 @@ Nur **`training_unit_parallel_streams`** + `parallel_stream_id` auf Sektionen; P
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Implementierungsphasen (Vorschlag)
|
## 8. Implementierungsphasen (Abgleich)
|
||||||
|
|
||||||
| Phase | Inhalt |
|
| Phase | Inhalt | Stand 2026-05-14 |
|
||||||
|-------|--------|
|
|-------|--------|------------------|
|
||||||
| **P1** | Schema Phasen + Streams; Migration; GET/PATCH Einheit verschachtelt; Planungs-UI; Run-UI mit Stream-Tabs |
|
| **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 |
|
| **P2** | Trainer-Zuordnung pro Stream + effektive Anzeige; Vorlagen erweitert | **Offen** |
|
||||||
| **P3** | Synchroner Hallen-Takt / Rotationsmatrix (falls fachlich freigegeben) |
|
| **P3** | Synchroner Hallen-Takt / Rotationsmatrix (falls fachlich freigegeben) | **Offen** |
|
||||||
|
|
||||||
---
|
**Offene Punkte (kurz):** siehe **`docs/HANDOVER.md`** Tabelle „Coaching & Breakout“.
|
||||||
|
|
||||||
## 9. Verwandte Dokumente
|
## 9. Verwandte Dokumente
|
||||||
|
|
||||||
|
|
@ -127,4 +132,5 @@ Nur **`training_unit_parallel_streams`** + `parallel_stream_id` auf Sektionen; P
|
||||||
| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slot vs. Parallelität |
|
| `technical/TRAINING_FRAMEWORK_SPEC.md` | Rahmen-Slot vs. Parallelität |
|
||||||
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombi, `planning_method_profile` |
|
| `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` | Kombi, `planning_method_profile` |
|
||||||
| `technical/DATABASE_SCHEMA.md`, `backend/migrations/` | DDL-Historie |
|
| `technical/DATABASE_SCHEMA.md`, `backend/migrations/` | DDL-Historie |
|
||||||
| `frontend/src/pages/TrainingPlanningPage.jsx`, `TrainingUnitRunPage.jsx`, `TrainingFrameworkProgramEditPage.jsx` | Ist-UI |
|
| `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 |
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
| `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). |
|
| `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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
||||||
| exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
|
| exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
|
||||||
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
|
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
|
||||||
|
| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; 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) |
|
| 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) |
|
||||||
|
|
@ -32,18 +33,28 @@ 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-13 — `GET /api/dashboard/kpis` (Kurzüberblick-Aggregat).
|
Letzte Änderung: 2026-06-06 — Superadmin `/api/admin/user-content/*` (Nutzer-Inhalte Moderation).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Changelog (Fortführung)
|
### Changelog (Fortführung)
|
||||||
|
|
||||||
|
- **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-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`?
|
||||||
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.
|
||||||
101
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
101
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Progressionsgraph — Slot-Editor (Phase B + F15)
|
||||||
|
|
||||||
|
**Stand:** 2026-05-22 · **Status:** Umgesetzt (F14 + F15 lokal nach 0.8.233)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
findingsStale?: boolean, // Bewertung veraltet (↔ Artefakt findings_stale)
|
||||||
|
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`, `last_findings`, **`findings_stale`**.
|
||||||
|
|
||||||
|
## Findings-Panel
|
||||||
|
|
||||||
|
Nutzt `path_qa`:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `quality_score` | Gesamt = **min(`roadmap_qa`, `assignment_qa`)** |
|
||||||
|
| `roadmap_qa` | Stufen/Roadmap (LLM `topic_coverage`, …) |
|
||||||
|
| `assignment_qa` | Slot-Befüllung (`empty_slot_count`, …) |
|
||||||
|
| `overall_ok`, `issues`, `recommendations`, `gap_fill_offers`, … | wie bisher |
|
||||||
|
|
||||||
|
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
|
||||||
|
|
||||||
|
**Bewertung veraltet:** Jede Graph-Änderung setzt `findingsStale: true` → Banner im Panel. Nach „Graph bewerten“ → `false`. Persistenz: `planning_roadmap.findings_stale`.
|
||||||
|
|
||||||
|
## Match-Flow („Übungen matchen“)
|
||||||
|
|
||||||
|
1. **Schritt 1:** `evaluate_only` + volle Pfad-QS (wie „Graph bewerten“)
|
||||||
|
2. **Schritt 2:** `unified_slot_review: true` → **`ProgressionOptimizeCompareModal`**
|
||||||
|
3. Pro Slot: aktuell vs. beste Bibliothek vs. optional KI-Vorschlag
|
||||||
|
4. **Vorauswahl:** Bibliothek nur wenn Stufen-Fit ≥ 50 % und besser als Baseline; sonst KI (bei leerem/schwachem Slot)
|
||||||
|
5. **Übernahme:** nur gewählte Slots speichern — **keine** automatische Nach-Bewertung
|
||||||
|
|
||||||
|
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `slot_contents[]` — `{ major_step_index, primary, siblings[] }`
|
||||||
|
- `last_findings` — letzter `path_qa`-Snapshot
|
||||||
|
- **`findings_stale`** — bool, Bewertung bezieht sich nicht mehr auf aktuellen Graph-Stand
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- **Slot-Keys:** stabil `slot-{index}` (nicht Lernziel-Text) — sonst Fokusverlust beim Tippen
|
||||||
|
|
||||||
|
## Ersetzt (Legacy, nicht mehr im Panel)
|
||||||
|
|
||||||
|
- `ExerciseProgressionPathBuilder` · `ProgressionChainEditor` — Code bleibt vorerst, nicht eingebunden
|
||||||
|
|
||||||
|
## Implementierungsreihenfolge
|
||||||
|
|
||||||
|
| ID | Inhalt | Status |
|
||||||
|
|----|--------|--------|
|
||||||
|
| 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 | ✅ |
|
||||||
|
| F15 | Unified Slot-Review, getrennte QS, `findings_stale` | ✅ |
|
||||||
|
|
||||||
|
**Ist-Doku:** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` §8.1 · `docs/HANDOVER.md` §2.8 F15
|
||||||
|
|
@ -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,10 +1,13 @@
|
||||||
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]
|
||||||
|
|
@ -17,8 +20,10 @@ jobs:
|
||||||
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,12 +33,27 @@ 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 &&
|
||||||
|
|
|
||||||
|
|
@ -12,10 +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`** |
|
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
|
||||||
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.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
|
||||||
|
|
||||||
|
|
@ -86,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",
|
||||||
|
]
|
||||||
317
backend/ai_prompt_planning_preview.py
Normal file
317
backend/ai_prompt_planning_preview.py
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
"""
|
||||||
|
Admin-Vorschau: Platzhalter für Planungs-Prompts (Progressionsgraph, Pfad-QS, Suggest).
|
||||||
|
|
||||||
|
Nutzt repräsentative Beispieldaten + echte Katalog-Auszüge aus der DB.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from planning_exercise_semantics import brief_to_summary_dict, build_semantic_brief
|
||||||
|
from planning_intent_context import build_planning_intent_context
|
||||||
|
from planning_prompt_variables import merge_planning_prompt_variables
|
||||||
|
|
||||||
|
PLANNING_PROMPT_SLUGS = frozenset(
|
||||||
|
{
|
||||||
|
"planning_progression_start_target",
|
||||||
|
"planning_progression_goal_analysis",
|
||||||
|
"planning_progression_roadmap",
|
||||||
|
"planning_progression_stage_spec",
|
||||||
|
"planning_exercise_query_semantics",
|
||||||
|
"planning_exercise_path_qa",
|
||||||
|
"planning_exercise_search_intent",
|
||||||
|
"planning_exercise_search_rank",
|
||||||
|
"planning_exercise_expectation_profile",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningPromptPreviewInput(BaseModel):
|
||||||
|
goal_query: str = Field(
|
||||||
|
default="Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe",
|
||||||
|
max_length=2000,
|
||||||
|
)
|
||||||
|
user_notes: str = Field(default="Fokus Breitensport, ohne Wettkampfdruck.", max_length=2000)
|
||||||
|
max_steps: int = Field(default=5, ge=2, le=10)
|
||||||
|
search_query: Optional[str] = Field(default=None, max_length=2000)
|
||||||
|
planning_catalog_context: Optional[Dict[str, Any]] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_planning_prompt_slug(slug: str) -> bool:
|
||||||
|
return (slug or "").strip().lower() in PLANNING_PROMPT_SLUGS
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_json(obj: Any) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_goal_analysis() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"start_assumption": "Grundstellung und einfache Frontkick-Bewegung bekannt",
|
||||||
|
"target_state": "Kontrollierter Mae Geri in Kumite-Nähe mit Hüftöffnung",
|
||||||
|
"success_criteria": [
|
||||||
|
"Hüfte öffnet vor dem Kick",
|
||||||
|
"Ballen trifft Zielzone",
|
||||||
|
"Rückzug ohne Balanceverlust",
|
||||||
|
],
|
||||||
|
"constraints": {
|
||||||
|
"partner_required": False,
|
||||||
|
"excluded_themes": ["reine Kraft ohne Technikbezug"],
|
||||||
|
"trainer_notes": "Breitensport, kein Wettkampf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_major_steps(max_steps: int) -> List[Dict[str, Any]]:
|
||||||
|
phases = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"]
|
||||||
|
titles = [
|
||||||
|
"Grundstellung und Mae Geri Einstieg",
|
||||||
|
"Hüftöffnung und Ballen-Fokus",
|
||||||
|
"Koordination und Rückzug",
|
||||||
|
"Anwendung in Partnerübung",
|
||||||
|
"Qualität unter leichtem Druck",
|
||||||
|
]
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for i in range(max_steps):
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"phase": phases[min(i, len(phases) - 1)],
|
||||||
|
"title": titles[min(i, len(titles) - 1)],
|
||||||
|
"learning_goal": titles[min(i, len(titles) - 1)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_path_steps() -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"exercise_id": 101,
|
||||||
|
"title": "Mae Geri — Stand und Hüftöffnung",
|
||||||
|
"goal": "Frontkick mit geöffneter Hüfte aus Grundstellung",
|
||||||
|
"is_bridge": False,
|
||||||
|
"is_ai_proposal": False,
|
||||||
|
"reasons": ["Stufen-Gate: Grundlagen"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"exercise_id": 102,
|
||||||
|
"title": "Mae Geri — Ballen und Rückzug",
|
||||||
|
"goal": "Präziser Ballentreffer mit kontrolliertem Rückzug",
|
||||||
|
"is_bridge": False,
|
||||||
|
"is_ai_proposal": False,
|
||||||
|
"reasons": ["Nachfolger im Graph"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_planning_context() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scope": "progression_path",
|
||||||
|
"goal_query": "Mae Geri vom Grundschritt bis zur Kumite-Nähe",
|
||||||
|
"stage_index": 1,
|
||||||
|
"learning_goal": "Hüftöffnung und Ballen-Fokus",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_target_profile() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"primary_focus": "Kihon",
|
||||||
|
"training_type": "Breitensport",
|
||||||
|
"skill_expectations": ["Geri Waza", "Koordination"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_candidates() -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"exercise_id": 101,
|
||||||
|
"title": "Mae Geri — Stand und Hüftöffnung",
|
||||||
|
"summary": "Frontkick mit Hüftöffnung",
|
||||||
|
"skill_names": ["Geri Waza"],
|
||||||
|
"score_hint": 0.82,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exercise_id": 102,
|
||||||
|
"title": "Mae Geri — Ballen und Rückzug",
|
||||||
|
"summary": "Ballentreffer mit Rückzug",
|
||||||
|
"skill_names": ["Geri Waza", "Koordination"],
|
||||||
|
"score_hint": 0.76,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalog_variables(cur) -> Dict[str, str]:
|
||||||
|
from planning_exercise_intent import (
|
||||||
|
_load_compact_catalog,
|
||||||
|
_load_skills_catalog_compact,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"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")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_catalog_context(body: PlanningPromptPreviewInput):
|
||||||
|
from planning_catalog_context import catalog_context_from_mapping
|
||||||
|
|
||||||
|
raw = body.planning_catalog_context
|
||||||
|
if raw:
|
||||||
|
return catalog_context_from_mapping(raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_catalog_preview(cur, slug: str, base: Dict[str, str], body: PlanningPromptPreviewInput) -> Dict[str, str]:
|
||||||
|
return merge_planning_prompt_variables(
|
||||||
|
cur,
|
||||||
|
base,
|
||||||
|
catalog=_preview_catalog_context(body),
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_planning_prompt_preview_variables(
|
||||||
|
cur,
|
||||||
|
slug: str,
|
||||||
|
body: PlanningPromptPreviewInput,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Mustache-Variablen für Planungs-Prompt-Vorschau im Admin."""
|
||||||
|
s = (slug or "").strip().lower()
|
||||||
|
if s not in PLANNING_PROMPT_SLUGS:
|
||||||
|
raise ValueError(f"Kein Planungs-Prompt-Slug: {slug!r}")
|
||||||
|
|
||||||
|
goal_query = (body.goal_query or "").strip() or "Mae Geri Progression"
|
||||||
|
search_query = (body.search_query or "").strip() or goal_query
|
||||||
|
max_steps = int(body.max_steps)
|
||||||
|
brief = build_semantic_brief(goal_query)
|
||||||
|
brief_json = _compact_json(brief_to_summary_dict(brief))
|
||||||
|
goal_analysis = _sample_goal_analysis()
|
||||||
|
major_steps = _sample_major_steps(max_steps)
|
||||||
|
intent_ctx = build_planning_intent_context(
|
||||||
|
goal_query=goal_query,
|
||||||
|
goal_analysis=goal_analysis,
|
||||||
|
semantic_brief=brief,
|
||||||
|
extra_context=(body.user_notes or "").strip() or None,
|
||||||
|
)
|
||||||
|
intent_ctx_json = _compact_json(intent_ctx.to_api_dict())
|
||||||
|
ctx = _sample_planning_context()
|
||||||
|
target = _sample_target_profile()
|
||||||
|
catalogs = _load_catalog_variables(cur)
|
||||||
|
|
||||||
|
if s == "planning_progression_start_target":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
"user_notes": (body.user_notes or "").strip(),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_progression_goal_analysis":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_progression_roadmap":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"goal_analysis_json": _compact_json(goal_analysis),
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
"max_steps": str(max_steps),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_progression_stage_spec":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"goal_analysis_json": _compact_json(goal_analysis),
|
||||||
|
"major_steps_json": _compact_json(major_steps),
|
||||||
|
"intent_context_json": intent_ctx_json,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_exercise_query_semantics":
|
||||||
|
return {
|
||||||
|
"search_query": search_query,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == "planning_exercise_path_qa":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
"steps_json": _compact_json(_sample_path_steps()),
|
||||||
|
"gaps_json": _compact_json([]),
|
||||||
|
"bridge_inserts_json": _compact_json([]),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_exercise_search_intent":
|
||||||
|
return {
|
||||||
|
"search_query": search_query,
|
||||||
|
"heuristic_intent": "progression_next",
|
||||||
|
"scenario_hint": "preset_next",
|
||||||
|
"planning_context_json": _compact_json(ctx),
|
||||||
|
"target_profile_json": _compact_json(target),
|
||||||
|
**catalogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == "planning_exercise_search_rank":
|
||||||
|
return {
|
||||||
|
"search_query": search_query,
|
||||||
|
"intent": "progression_next",
|
||||||
|
"planning_context_json": _compact_json(ctx),
|
||||||
|
"target_profile_json": _compact_json(target),
|
||||||
|
"candidates_json": _compact_json(_sample_candidates()),
|
||||||
|
"result_limit": "5",
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == "planning_exercise_expectation_profile":
|
||||||
|
return {
|
||||||
|
"heuristic_intent": "suggest_next",
|
||||||
|
"planning_context_json": _compact_json(ctx),
|
||||||
|
"target_profile_json": _compact_json(target),
|
||||||
|
**{k: v for k, v in catalogs.items() if k != "style_directions_catalog_json"},
|
||||||
|
}
|
||||||
|
|
||||||
|
raise ValueError(f"Planungs-Prompt-Slug nicht implementiert: {slug!r}")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PLANNING_PROMPT_SLUGS",
|
||||||
|
"PlanningPromptPreviewInput",
|
||||||
|
"is_planning_prompt_slug",
|
||||||
|
"resolve_planning_prompt_preview_variables",
|
||||||
|
]
|
||||||
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))
|
||||||
432
backend/catalog_prompt_slots.py
Normal file
432
backend/catalog_prompt_slots.py
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
"""
|
||||||
|
Katalog-Prompt-Slots — Slot-Typ-Vokabular + Werte pro Stammdaten-Zeile (H2).
|
||||||
|
|
||||||
|
Prompts in ai_prompts referenzieren Platzhalter wie {{focus_area_hints_on_progression}}.
|
||||||
|
Inhalte liegen in catalog_prompt_slots (Admin-editierbar), nicht im Code pro Eintrag.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from planning_catalog_context import (
|
||||||
|
ProgressionPlanningCatalogContext,
|
||||||
|
PlanningCatalogContextItem,
|
||||||
|
catalog_context_has_items,
|
||||||
|
)
|
||||||
|
from catalog_slot_fallbacks import merge_stored_slots_with_fallbacks
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dimensionen (Prioritätsreihenfolge)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CatalogKindConfig:
|
||||||
|
kind: str
|
||||||
|
table: str
|
||||||
|
context_attr: str
|
||||||
|
label_de: str
|
||||||
|
|
||||||
|
|
||||||
|
CATALOG_KINDS: Tuple[CatalogKindConfig, ...] = (
|
||||||
|
CatalogKindConfig("focus_area", "focus_areas", "focus_areas", "Primärfokus"),
|
||||||
|
CatalogKindConfig("training_type", "training_types", "training_types", "Trainingsstil"),
|
||||||
|
CatalogKindConfig("target_group", "target_groups", "target_groups", "Zielgruppe"),
|
||||||
|
CatalogKindConfig("style_direction", "style_directions", "style_directions", "Stilrichtung"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_KIND_BY_NAME = {c.kind: c for c in CATALOG_KINDS}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slot-Typen (Vokabular — erweiterbar via catalog_prompt_slot_types)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SLOT_KEYS: Tuple[str, ...] = (
|
||||||
|
"description",
|
||||||
|
"hints_on_progression",
|
||||||
|
"hints_on_exercise",
|
||||||
|
"hints_on_path_qa",
|
||||||
|
"anti_patterns",
|
||||||
|
"rematch_guard",
|
||||||
|
)
|
||||||
|
|
||||||
|
LLM_SLOT_KEYS: Tuple[str, ...] = tuple(k for k in SLOT_KEYS if k != "rematch_guard")
|
||||||
|
|
||||||
|
GUIDANCE_BLOCK_SLOTS: Tuple[str, ...] = (
|
||||||
|
"description",
|
||||||
|
"hints_on_progression",
|
||||||
|
"hints_on_path_qa",
|
||||||
|
"anti_patterns",
|
||||||
|
)
|
||||||
|
|
||||||
|
GUIDANCE_PROFILE_BY_SLUG: Dict[str, Tuple[str, ...]] = {
|
||||||
|
"planning_exercise_path_qa": ("description", "hints_on_path_qa", "anti_patterns"),
|
||||||
|
"planning_progression_roadmap": ("description", "hints_on_progression", "anti_patterns"),
|
||||||
|
"planning_progression_stage_spec": ("hints_on_progression", "anti_patterns", "description"),
|
||||||
|
"planning_progression_goal_analysis": ("description", "hints_on_progression"),
|
||||||
|
"planning_progression_start_target": ("description",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def placeholder_key(catalog_kind: str, slot_key: str) -> str:
|
||||||
|
return f"{catalog_kind}_{slot_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def all_placeholder_keys() -> List[str]:
|
||||||
|
keys: List[str] = []
|
||||||
|
for cfg in CATALOG_KINDS:
|
||||||
|
for slot in SLOT_KEYS:
|
||||||
|
keys.append(placeholder_key(cfg.kind, slot))
|
||||||
|
keys.extend(["catalog_guidance_block", "catalog_context_json", "has_catalog_guidance"])
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def empty_catalog_variables() -> Dict[str, str]:
|
||||||
|
out = {k: "" for k in all_placeholder_keys()}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Katalog-Kontext → aktiver Eintrag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pick_active_catalog_item(
|
||||||
|
items: Sequence[PlanningCatalogContextItem],
|
||||||
|
) -> Optional[PlanningCatalogContextItem]:
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
primaries = [i for i in items if i.is_primary]
|
||||||
|
if primaries:
|
||||||
|
return primaries[0]
|
||||||
|
if len(items) == 1:
|
||||||
|
return items[0]
|
||||||
|
return max(items, key=lambda i: (float(i.weight), -int(i.id)))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalog_row(cur, table: str, item_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, name, description
|
||||||
|
FROM {table}
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(int(item_id),),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": int(row["id"]),
|
||||||
|
"name": str(row.get("name") or "").strip(),
|
||||||
|
"description": str(row.get("description") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_slots_for_entry(cur, catalog_kind: str, catalog_id: int) -> Dict[str, str]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT slot_key, content
|
||||||
|
FROM catalog_prompt_slots
|
||||||
|
WHERE catalog_kind = %s AND catalog_id = %s
|
||||||
|
""",
|
||||||
|
(catalog_kind, int(catalog_id)),
|
||||||
|
)
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for row in cur.fetchall():
|
||||||
|
key = str(row.get("slot_key") or "").strip()
|
||||||
|
if key:
|
||||||
|
out[key] = str(row.get("content") or "").strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_types_table_ready(cur) -> bool:
|
||||||
|
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.catalog_prompt_slot_types",))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
val = row.get("t") if isinstance(row, dict) else row[0]
|
||||||
|
return val is not None and str(val).strip() != ""
|
||||||
|
|
||||||
|
|
||||||
|
def list_slot_type_definitions(cur) -> List[Dict[str, Any]]:
|
||||||
|
if not _slot_types_table_ready(cur):
|
||||||
|
return _fallback_slot_type_rows()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT slot_key, display_name, description, applicable_kinds, sort_order, for_llm, for_code
|
||||||
|
FROM catalog_prompt_slot_types
|
||||||
|
ORDER BY sort_order ASC NULLS LAST, slot_key ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
d = dict(row)
|
||||||
|
kinds = d.get("applicable_kinds")
|
||||||
|
if isinstance(kinds, str):
|
||||||
|
kinds = [k.strip() for k in kinds.strip("{}").split(",") if k.strip()]
|
||||||
|
d["applicable_kinds"] = list(kinds or [])
|
||||||
|
rows.append(d)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_slot_type_rows() -> List[Dict[str, Any]]:
|
||||||
|
labels = {
|
||||||
|
"description": "Allgemeine Beschreibung",
|
||||||
|
"hints_on_progression": "Hinweise Progressionsgraph",
|
||||||
|
"hints_on_exercise": "Hinweise Übungsanlage",
|
||||||
|
"hints_on_path_qa": "Hinweise Pfad-QS",
|
||||||
|
"anti_patterns": "Anti-Patterns (Fehlbewertung vermeiden)",
|
||||||
|
"rematch_guard": "Rematch-Guard (Code)",
|
||||||
|
}
|
||||||
|
kinds = [c.kind for c in CATALOG_KINDS]
|
||||||
|
rows = []
|
||||||
|
for i, key in enumerate(SLOT_KEYS):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"slot_key": key,
|
||||||
|
"display_name": labels.get(key, key),
|
||||||
|
"description": "",
|
||||||
|
"applicable_kinds": kinds,
|
||||||
|
"sort_order": (i + 1) * 10,
|
||||||
|
"for_llm": key != "rematch_guard",
|
||||||
|
"for_code": key == "rematch_guard",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_entry_slot_values(
|
||||||
|
stored: Mapping[str, str],
|
||||||
|
row: Mapping[str, Any],
|
||||||
|
catalog_kind: str,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""DB → Namens-Fallback → Stammdaten-Beschreibung (nur description)."""
|
||||||
|
return merge_stored_slots_with_fallbacks(
|
||||||
|
stored,
|
||||||
|
catalog_kind=catalog_kind,
|
||||||
|
name=str(row.get("name") or ""),
|
||||||
|
stammdaten_description=str(row.get("description") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_catalog_entry_slots(cur, catalog_kind: str, catalog_id: int) -> Dict[str, Any]:
|
||||||
|
cfg = _KIND_BY_NAME.get((catalog_kind or "").strip())
|
||||||
|
if not cfg:
|
||||||
|
raise ValueError(f"Unbekannter catalog_kind: {catalog_kind!r}")
|
||||||
|
row = _load_catalog_row(cur, cfg.table, catalog_id)
|
||||||
|
if not row:
|
||||||
|
raise LookupError("Katalog-Eintrag nicht gefunden")
|
||||||
|
stored = _load_slots_for_entry(cur, cfg.kind, catalog_id)
|
||||||
|
merged = _resolve_entry_slot_values(stored, row, cfg.kind)
|
||||||
|
return {
|
||||||
|
"catalog_kind": cfg.kind,
|
||||||
|
"catalog_id": int(catalog_id),
|
||||||
|
"name": row["name"],
|
||||||
|
"slots": merged,
|
||||||
|
"stored_slots": {k: stored.get(k, "") for k in SLOT_KEYS},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_catalog_entry_slots(
|
||||||
|
cur,
|
||||||
|
catalog_kind: str,
|
||||||
|
catalog_id: int,
|
||||||
|
slots: Mapping[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
cfg = _KIND_BY_NAME.get((catalog_kind or "").strip())
|
||||||
|
if not cfg:
|
||||||
|
raise ValueError(f"Unbekannter catalog_kind: {catalog_kind!r}")
|
||||||
|
row = _load_catalog_row(cur, cfg.table, catalog_id)
|
||||||
|
if not row:
|
||||||
|
raise LookupError("Katalog-Eintrag nicht gefunden")
|
||||||
|
for slot_key, raw in (slots or {}).items():
|
||||||
|
sk = str(slot_key or "").strip()
|
||||||
|
if sk not in SLOT_KEYS:
|
||||||
|
continue
|
||||||
|
content = str(raw or "").strip()
|
||||||
|
if not content:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM catalog_prompt_slots
|
||||||
|
WHERE catalog_kind = %s AND catalog_id = %s AND slot_key = %s
|
||||||
|
""",
|
||||||
|
(cfg.kind, int(catalog_id), sk),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, NOW())
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key)
|
||||||
|
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(cfg.kind, int(catalog_id), sk, content),
|
||||||
|
)
|
||||||
|
return get_catalog_entry_slots(cur, cfg.kind, catalog_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_dimension_section(
|
||||||
|
label_de: str,
|
||||||
|
name: str,
|
||||||
|
slot_values: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
slot_keys: Sequence[str],
|
||||||
|
) -> Optional[str]:
|
||||||
|
parts: List[str] = [f"### {label_de} — {name}"]
|
||||||
|
labels = {
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"hints_on_progression": "Progressions-Hinweise",
|
||||||
|
"hints_on_path_qa": "QS-Hinweise",
|
||||||
|
"hints_on_exercise": "Übungsanlage",
|
||||||
|
"anti_patterns": "Vermeiden",
|
||||||
|
}
|
||||||
|
added = False
|
||||||
|
for sk in slot_keys:
|
||||||
|
text = str(slot_values.get(sk) or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
added = True
|
||||||
|
if sk == "description":
|
||||||
|
parts.append(text)
|
||||||
|
else:
|
||||||
|
parts.append(f"{labels.get(sk, sk)}: {text}")
|
||||||
|
if not added:
|
||||||
|
return None
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_guidance_block(
|
||||||
|
sections: List[str],
|
||||||
|
) -> str:
|
||||||
|
if not sections:
|
||||||
|
return ""
|
||||||
|
return "## Katalog-Kontext (Didaktik & Bewertung)\n\n" + "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_catalog_prompt_variables(
|
||||||
|
cur,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||||
|
*,
|
||||||
|
slug: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Liefert Mustache-Strings + Metadaten.
|
||||||
|
|
||||||
|
Returns dict mit allen {{kind_slot}} Keys, catalog_guidance_block, catalog_context_json,
|
||||||
|
has_catalog_guidance (bool), active_slots (list).
|
||||||
|
"""
|
||||||
|
variables = empty_catalog_variables()
|
||||||
|
meta: Dict[str, Any] = {
|
||||||
|
"active_slots": [],
|
||||||
|
"audit": {},
|
||||||
|
}
|
||||||
|
if cur is None or not catalog_context_has_items(catalog):
|
||||||
|
variables["catalog_context_json"] = ""
|
||||||
|
return {**variables, **meta}
|
||||||
|
|
||||||
|
profile = GUIDANCE_PROFILE_BY_SLUG.get((slug or "").strip().lower(), GUIDANCE_BLOCK_SLOTS)
|
||||||
|
sections: List[str] = []
|
||||||
|
audit: Dict[str, Any] = {}
|
||||||
|
has_any = False
|
||||||
|
active_slots: List[str] = []
|
||||||
|
|
||||||
|
for cfg in CATALOG_KINDS:
|
||||||
|
items = getattr(catalog, cfg.context_attr, None) or []
|
||||||
|
active = pick_active_catalog_item(items)
|
||||||
|
if not active:
|
||||||
|
continue
|
||||||
|
row = _load_catalog_row(cur, cfg.table, active.id)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
stored = _load_slots_for_entry(cur, cfg.kind, row["id"]) if _slot_types_table_ready(cur) else {}
|
||||||
|
slot_values = _resolve_entry_slot_values(stored, row, cfg.kind)
|
||||||
|
for sk in SLOT_KEYS:
|
||||||
|
pk = placeholder_key(cfg.kind, sk)
|
||||||
|
text = slot_values.get(sk, "")
|
||||||
|
variables[pk] = text
|
||||||
|
if text.strip() and sk in LLM_SLOT_KEYS:
|
||||||
|
has_any = True
|
||||||
|
active_slots.append(pk)
|
||||||
|
|
||||||
|
audit[cfg.context_attr] = {
|
||||||
|
"catalog_kind": cfg.kind,
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"is_primary": bool(active.is_primary),
|
||||||
|
"weight": float(active.weight),
|
||||||
|
"filled_slots": [k for k in LLM_SLOT_KEYS if slot_values.get(k, "").strip()],
|
||||||
|
"stored_slots": [k for k in SLOT_KEYS if (stored.get(k) or "").strip()],
|
||||||
|
}
|
||||||
|
|
||||||
|
section = _render_dimension_section(cfg.label_de, row["name"], slot_values, slot_keys=profile)
|
||||||
|
if section:
|
||||||
|
sections.append(section)
|
||||||
|
|
||||||
|
variables["catalog_guidance_block"] = _compose_guidance_block(sections)
|
||||||
|
ctx_json = json.dumps(audit, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
variables["catalog_context_json"] = f"Katalog-Audit: {ctx_json}" if audit else ""
|
||||||
|
variables["has_catalog_guidance"] = "true" if has_any else ""
|
||||||
|
return {
|
||||||
|
**variables,
|
||||||
|
"active_slots": active_slots,
|
||||||
|
"audit": audit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_rematch_guard_for_catalog(
|
||||||
|
cur,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Erste passende rematch_guard entlang der Dimensions-Priorität."""
|
||||||
|
if cur is None or not catalog_context_has_items(catalog):
|
||||||
|
return None
|
||||||
|
for cfg in CATALOG_KINDS:
|
||||||
|
items = getattr(catalog, cfg.context_attr, None) or []
|
||||||
|
active = pick_active_catalog_item(items)
|
||||||
|
if not active:
|
||||||
|
continue
|
||||||
|
stored = _load_slots_for_entry(cur, cfg.kind, active.id)
|
||||||
|
row = _load_catalog_row(cur, cfg.table, active.id)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
slot_values = _resolve_entry_slot_values(stored, row, cfg.kind)
|
||||||
|
guard = (slot_values.get("rematch_guard") or "").strip()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Abwärtskompatibilität H1-API
|
||||||
|
def build_catalog_guidance_for_prompt(
|
||||||
|
cur,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||||
|
*,
|
||||||
|
slug: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
resolved = resolve_catalog_prompt_variables(cur, catalog, slug=slug)
|
||||||
|
return {
|
||||||
|
"catalog_guidance_block": resolved.get("catalog_guidance_block", ""),
|
||||||
|
"catalog_context_json": resolved.get("catalog_context_json", ""),
|
||||||
|
"has_catalog_guidance": resolved.get("has_catalog_guidance") == "true",
|
||||||
|
"snippet_keys": list(resolved.get("active_slots") or []),
|
||||||
|
"variables": {k: str(resolved.get(k) or "") for k in all_placeholder_keys()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CATALOG_KINDS",
|
||||||
|
"GUIDANCE_PROFILE_BY_SLUG",
|
||||||
|
"SLOT_KEYS",
|
||||||
|
"build_catalog_guidance_for_prompt",
|
||||||
|
"empty_catalog_variables",
|
||||||
|
"get_catalog_entry_slots",
|
||||||
|
"get_rematch_guard_for_catalog",
|
||||||
|
"list_slot_type_definitions",
|
||||||
|
"pick_active_catalog_item",
|
||||||
|
"placeholder_key",
|
||||||
|
"all_placeholder_keys",
|
||||||
|
"resolve_catalog_prompt_variables",
|
||||||
|
"upsert_catalog_entry_slots",
|
||||||
|
]
|
||||||
284
backend/catalog_slot_fallbacks.py
Normal file
284
backend/catalog_slot_fallbacks.py
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
"""
|
||||||
|
Namensbasierte Fallback-Slots — bis Admin/DB befüllt sind (H1-Registry-Inhalt).
|
||||||
|
|
||||||
|
DB-Werte in catalog_prompt_slots haben immer Vorrang. Fallbacks füllen nur leere Slot-Keys.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from typing import Dict, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
_UMLAUT_MAP = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss", "Ä": "ae", "Ö": "oe", "Ü": "ue"})
|
||||||
|
|
||||||
|
SlotPack = Dict[str, str]
|
||||||
|
|
||||||
|
# (catalog_kind, name_pattern_lower) — erste passende Regel gewinnt; * = Default pro Kind
|
||||||
|
_FALLBACK_RULES: Tuple[Tuple[str, str, SlotPack], ...] = (
|
||||||
|
# --- focus_area ---
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"gewaltschutz",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — "
|
||||||
|
"nicht auf Wettkampf-Perfektion oder Technik-Show."
|
||||||
|
),
|
||||||
|
"hints_on_progression": (
|
||||||
|
"Phasen: Wahrnehmung → Grenzen → Deeskalation → sichere Übungsformen; "
|
||||||
|
"keine Kumite-Perfektionsstufen erzwingen."
|
||||||
|
),
|
||||||
|
"hints_on_exercise": (
|
||||||
|
"Übungen mit Rollen, Kommunikation, Ausweichen; keine rein technischen Kick-Fokus-Inseln ohne Bezug."
|
||||||
|
),
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; "
|
||||||
|
"„Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"selbstverteidigung",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Praktische Selbstverteidigung: realistische Szenarien, Sicherheit und "
|
||||||
|
"anwendungsnahe Progression — nicht Show-Technik oder Wettkampf-Kata."
|
||||||
|
),
|
||||||
|
"hints_on_progression": (
|
||||||
|
"Von Wahrnehmung und Distanz zu einfachen Abwehrmustern und kontrollierter Anwendung."
|
||||||
|
),
|
||||||
|
"hints_on_exercise": "Partnerübungen mit klaren Sicherheitsregeln; Szenario-Bezug wichtiger als Stil-Show.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Lücken bei Szenario- oder Sicherheitsstufen sind relevant; "
|
||||||
|
"fehlende Kick-Varianten oder Wettkampftiefe sind kein Mangel."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine Wettkampf- oder Kata-Perfektion als QS-Maßstab.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"fitness",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Fitness- und Konditionsorientierung mit sicherer Belastungssteuerung; "
|
||||||
|
"Technikbezug nur wo fachlich sinnvoll."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Progression von niedriger zu moderater Belastung; klare Pausen und Technikhygiene.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Keine Wettkampf-Spezialisierung als Pflicht-Kriterium; "
|
||||||
|
"Belastungssteigerung ohne Technikbezug abwerten."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine Kumite-Perfektion oder Wettkampf-Kombinationen als QS-Maßstab verlangen.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"karate",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Technik-Curriculum im Karate-Kontext: aufeinander aufbauende Kihon-Progression "
|
||||||
|
"mit klaren Qualitätsankern (Stand, Hüfte, Kime)."
|
||||||
|
),
|
||||||
|
"hints_on_progression": (
|
||||||
|
"Typische Phasen: Einstieg → Grundlagen → Koordination/Kraft → Anwendung → optional Vertiefung; "
|
||||||
|
"Grundlagen vor Perfektion."
|
||||||
|
),
|
||||||
|
"hints_on_exercise": (
|
||||||
|
"Kihon und Partnerübungen mit Technikbezug; reine Kraft-/Ausdauer-Inseln nur mit klarer Begründung."
|
||||||
|
),
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Kohärente Progression Grundlagen → Anwendung → Vertiefung; "
|
||||||
|
"Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine pauschale Perfektions-Stufe verlangen, wenn Kontext Breitensport ist.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"*",
|
||||||
|
{
|
||||||
|
"description": "Technik- oder Themen-Curriculum mit didaktisch aufeinander aufbauenden Stufen.",
|
||||||
|
"hints_on_progression": "Grundlagen vor Anwendung; moderate Sprünge zwischen Stufen vermeiden.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Kohärente Progression zum Anfrage-Thema; "
|
||||||
|
"Lücken sind fehlende Zwischenstufen im Lernpfad, nicht fehlende Nebenthemen."
|
||||||
|
),
|
||||||
|
"hints_on_exercise": "Übungen mit klarem Bezug zum Pfad-Thema und zur Stufe.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# --- training_type ---
|
||||||
|
(
|
||||||
|
"training_type",
|
||||||
|
"breitensport",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Moderater Schwierigkeitsanstieg; Perfektionsphasen optional.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; "
|
||||||
|
"„Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke."
|
||||||
|
),
|
||||||
|
"rematch_guard": "Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"training_type",
|
||||||
|
"leistungssport",
|
||||||
|
{
|
||||||
|
"description": "Leistungsorientiertes Training mit höherer Anspruchskurve und Spezialisierung.",
|
||||||
|
"hints_on_progression": "Belastungs- und Kombinationsprogressionen sind erwünscht.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Höhere Anspruchskurven sind passend; Lücken in Spezialisierung können echte Hinweise sein."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"training_type",
|
||||||
|
"wettkampf",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Wettkampforientiertes Training mit höherer Anspruchskurve und belastungsnahen Phasen."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Anwendungs- und Druckphasen zeitig einplanen.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Spezialisierung, Kombination und Belastung unter Druck sind relevant; "
|
||||||
|
"Lücken in Anwendungs- oder Perfektionsphasen können echte Hinweise sein."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"training_type",
|
||||||
|
"*",
|
||||||
|
{
|
||||||
|
"hints_on_path_qa": "Didaktische Kohärenz wichtiger als maximale Spezialisierung — Kontext beachten.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# --- target_group ---
|
||||||
|
(
|
||||||
|
"target_group",
|
||||||
|
"kinder",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Kinder: kurze Einheiten, spielerische Einstiege, Sicherheit und altersgerechte Komplexität."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Spielerische Einstiege; kurze Abschnitte; Sicherheit vor Perfektion.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Didaktik ohne Überforderung; klare Regeln und Sicherheit vor Perfektion; "
|
||||||
|
"Lücken bei Spiel-/Rollenelementen wichtiger als Wettkampftiefe."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine Erwachsenen-Wettkampf-Perfektion als QS-Maßstab.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target_group",
|
||||||
|
"leistungssportler",
|
||||||
|
{
|
||||||
|
"description": "Leistungsgruppe: höhere Anspruchskurven und Spezialisierung sind fachlich passend.",
|
||||||
|
"hints_on_progression": "Anspruchskurve und Spezialisierung dürfen steiler sein.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; "
|
||||||
|
"Lücken in Spezialisierung können echte Hinweise sein."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target_group",
|
||||||
|
"breitensportler",
|
||||||
|
{
|
||||||
|
"description": "Breitensport: Partizipation und Verständlichkeit vor maximaler Spezialisierung.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Moderate Progression; Perfektions-Lücken sind selten echte Mängel."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine Leistungssport-Perfektion als Pflicht-Kriterium.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target_group",
|
||||||
|
"*",
|
||||||
|
{
|
||||||
|
"hints_on_path_qa": "Zielgruppe im Tempo und in der Komplexität berücksichtigen.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# --- style_direction ---
|
||||||
|
(
|
||||||
|
"style_direction",
|
||||||
|
"shotokan",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Shotokan-Linie: klare Kihon-Struktur, Hüft- und Standarbeit als wiederkehrende Qualitätsanker."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Nuancen in Stellung und Hüfttechnik; kein neuer Planungstyp.",
|
||||||
|
"hints_on_path_qa": "Konsistenz von Stand, Hüfte und Kime entlang des Pfads bewerten.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"style_direction",
|
||||||
|
"*",
|
||||||
|
{
|
||||||
|
"hints_on_progression": (
|
||||||
|
"Stil-spezifische Nuancen (Stand, Hüfte, Rhythmus) einbeziehen — ohne Stilwechsel zu erzwingen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_catalog_name_key(name: str) -> str:
|
||||||
|
s = unicodedata.normalize("NFKD", (name or "").translate(_UMLAUT_MAP))
|
||||||
|
s = s.encode("ascii", "ignore").decode("ascii").lower()
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "_", s).strip("_")
|
||||||
|
return s or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_fallback_slots_for_entry(catalog_kind: str, name: str) -> SlotPack:
|
||||||
|
kind = (catalog_kind or "").strip().lower()
|
||||||
|
norm = normalize_catalog_name_key(name)
|
||||||
|
default: SlotPack = {}
|
||||||
|
for rule_kind, pattern, pack in _FALLBACK_RULES:
|
||||||
|
if rule_kind != kind:
|
||||||
|
continue
|
||||||
|
if pattern == "*":
|
||||||
|
default = dict(pack)
|
||||||
|
continue
|
||||||
|
if pattern in norm or norm.startswith(pattern) or pattern in (name or "").lower():
|
||||||
|
return dict(pack)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def merge_stored_slots_with_fallbacks(
|
||||||
|
stored: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
catalog_kind: str,
|
||||||
|
name: str,
|
||||||
|
stammdaten_description: str = "",
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""DB + Stammdaten-Beschreibung + Namens-Fallback."""
|
||||||
|
fallbacks = get_fallback_slots_for_entry(catalog_kind, name)
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for key in (
|
||||||
|
"description",
|
||||||
|
"hints_on_progression",
|
||||||
|
"hints_on_exercise",
|
||||||
|
"hints_on_path_qa",
|
||||||
|
"anti_patterns",
|
||||||
|
"rematch_guard",
|
||||||
|
):
|
||||||
|
if key == "description":
|
||||||
|
out[key] = (
|
||||||
|
(stored.get(key) or "").strip()
|
||||||
|
or (fallbacks.get(key) or "").strip()
|
||||||
|
or (stammdaten_description or "").strip()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out[key] = (stored.get(key) or "").strip() or (fallbacks.get(key) or "").strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_fallback_slots_for_entry",
|
||||||
|
"merge_stored_slots_with_fallbacks",
|
||||||
|
"normalize_catalog_name_key",
|
||||||
|
]
|
||||||
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,
|
||||||
|
}
|
||||||
|
|
@ -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, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
|
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, 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, catalog_prompt_slots, 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,23 +252,34 @@ 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(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(catalog_prompt_slots.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>).
|
||||||
|
|
|
||||||
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';
|
||||||
172
backend/migrations/091_ai_prompt_catalog_guidance.sql
Normal file
172
backend/migrations/091_ai_prompt_catalog_guidance.sql
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
-- Migration 091: Planungs-KI H1 — Katalog-Guidance-Platzhalter in Progressions-Prompts
|
||||||
|
|
||||||
|
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}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
|
||||||
|
|
||||||
|
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}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
|
||||||
|
|
||||||
|
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$
|
||||||
|
WHERE slug = 'planning_exercise_path_qa';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET 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}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
|
||||||
|
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$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_goal_analysis';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $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}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
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).
|
||||||
|
Beachte Katalog-Roadmap-Hinweise, falls gesetzt.
|
||||||
|
|
||||||
|
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$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_roadmap';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET 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}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
{{catalog_context_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).
|
||||||
|
Beachte Katalog-QS-Kriterien und Anti-Patterns, falls gesetzt.
|
||||||
|
|
||||||
|
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$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec';
|
||||||
176
backend/migrations/092_catalog_prompt_slots.sql
Normal file
176
backend/migrations/092_catalog_prompt_slots.sql
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
-- Migration 092: Katalog-Prompt-Slots (H2) — Slot-Typ-Vokabular + Werte pro Stammdaten-Zeile
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_prompt_slot_types (
|
||||||
|
slot_key VARCHAR(64) PRIMARY KEY,
|
||||||
|
display_name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
applicable_kinds TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
sort_order INT DEFAULT 99,
|
||||||
|
for_llm BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
for_code BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_prompt_slots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
catalog_kind VARCHAR(32) NOT NULL,
|
||||||
|
catalog_id INT NOT NULL,
|
||||||
|
slot_key VARCHAR(64) NOT NULL REFERENCES catalog_prompt_slot_types(slot_key) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE (catalog_kind, catalog_id, slot_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_catalog_prompt_slots_kind_id
|
||||||
|
ON catalog_prompt_slots (catalog_kind, catalog_id);
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slot_types (slot_key, display_name, description, applicable_kinds, sort_order, for_llm, for_code)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'description',
|
||||||
|
'Allgemeine Beschreibung',
|
||||||
|
'Fachliche Einordnung des Katalog-Eintrags für Planungs-KI.',
|
||||||
|
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||||
|
10,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'hints_on_progression',
|
||||||
|
'Hinweise Progressionsgraph',
|
||||||
|
'Didaktik für Roadmap, Major Steps und Stufenspezifikation.',
|
||||||
|
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||||
|
20,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'hints_on_exercise',
|
||||||
|
'Hinweise Übungsanlage',
|
||||||
|
'Kontext für Gap-Fill, Übungs-KI und Schnellanlage.',
|
||||||
|
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||||
|
30,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'hints_on_path_qa',
|
||||||
|
'Hinweise Pfad-QS',
|
||||||
|
'Bewertungsmaßstäbe für Pfad-Qualitätssicherung.',
|
||||||
|
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||||
|
40,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'anti_patterns',
|
||||||
|
'Anti-Patterns',
|
||||||
|
'Explizite Fehlbewertungen vermeiden.',
|
||||||
|
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||||
|
50,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'rematch_guard',
|
||||||
|
'Rematch-Guard',
|
||||||
|
'Wann kein Auto-Rematch sinnvoll ist (primär Code-Logik).',
|
||||||
|
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||||
|
60,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (slot_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Seed aus H1-Registry (Name-Match auf Stammdaten)
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'focus_area', fa.id, 'description',
|
||||||
|
'Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.'
|
||||||
|
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'focus_area', fa.id, 'hints_on_path_qa',
|
||||||
|
'Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.'
|
||||||
|
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'focus_area', fa.id, 'anti_patterns',
|
||||||
|
'Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.'
|
||||||
|
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'training_type', tt.id, 'description',
|
||||||
|
'Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung.'
|
||||||
|
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'training_type', tt.id, 'hints_on_path_qa',
|
||||||
|
'Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; „Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke.'
|
||||||
|
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'training_type', tt.id, 'rematch_guard',
|
||||||
|
'Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.'
|
||||||
|
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'target_group', tg.id, 'description',
|
||||||
|
'Kinder: kurze Einheiten, spielerische Einstiege, Sicherheit und altersgerechte Komplexität.'
|
||||||
|
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'target_group', tg.id, 'hints_on_path_qa',
|
||||||
|
'Didaktik ohne Überforderung; klare Regeln und Sicherheit vor Perfektion; Lücken bei Spiel-/Rollenelementen wichtiger als Wettkampftiefe.'
|
||||||
|
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'target_group', tg.id, 'anti_patterns',
|
||||||
|
'Keine Erwachsenen-Wettkampf-Perfektion als QS-Maßstab.'
|
||||||
|
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'target_group', tg.id, 'description',
|
||||||
|
'Leistungsgruppe: höhere Anspruchskurven und Spezialisierung sind fachlich passend.'
|
||||||
|
FROM target_groups tg WHERE tg.name ILIKE 'Leistungssportler'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'target_group', tg.id, 'hints_on_path_qa',
|
||||||
|
'Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; Lücken in Spezialisierung können echte Hinweise sein.'
|
||||||
|
FROM target_groups tg WHERE tg.name ILIKE 'Leistungssportler'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'style_direction', sd.id, 'description',
|
||||||
|
'Shotokan-Linie: klare Kihon-Struktur, Hüft- und Standarbeit als wiederkehrende Qualitätsanker.'
|
||||||
|
FROM style_directions sd WHERE sd.name ILIKE 'Shotokan'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'style_direction', sd.id, 'hints_on_progression',
|
||||||
|
'Nuancen in Stellung und Hüfttechnik, kein neuer Planungstyp.'
|
||||||
|
FROM style_directions sd WHERE sd.name ILIKE 'Shotokan'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'training_type', tt.id, 'description',
|
||||||
|
'Wettkampforientiertes Training mit höherer Anspruchskurve und belastungsnahen Phasen.'
|
||||||
|
FROM training_types tt WHERE tt.name ILIKE 'Wettkampf'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'training_type', tt.id, 'hints_on_path_qa',
|
||||||
|
'Spezialisierung, Kombination und Belastung unter Druck sind relevant; Lücken in Anwendungs- oder Perfektionsphasen können echte Hinweise sein.'
|
||||||
|
FROM training_types tt WHERE tt.name ILIKE 'Wettkampf'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
-- Migration 093: Planungs-KI — granulare Katalog-Slot-Platzhalter in Prompt-Templates
|
||||||
|
|
||||||
|
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}}
|
||||||
|
|
||||||
|
Katalog-Kontext für Bewertung (Trainer-Auswahl — leere Zeilen ignorieren):
|
||||||
|
|
||||||
|
Primärfokus:
|
||||||
|
{{focus_area_description}}
|
||||||
|
QS: {{focus_area_hints_on_path_qa}}
|
||||||
|
Vermeiden: {{focus_area_anti_patterns}}
|
||||||
|
|
||||||
|
Trainingsstil:
|
||||||
|
{{training_type_description}}
|
||||||
|
QS: {{training_type_hints_on_path_qa}}
|
||||||
|
|
||||||
|
Zielgruppe:
|
||||||
|
{{target_group_description}}
|
||||||
|
QS: {{target_group_hints_on_path_qa}}
|
||||||
|
|
||||||
|
Stilrichtung:
|
||||||
|
{{style_direction_description}}
|
||||||
|
QS: {{style_direction_hints_on_path_qa}}
|
||||||
|
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Wichtig: Wenn Katalog-Slots gesetzt sind, haben diese Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
|
||||||
|
|
||||||
|
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 — gemäß Katalog-QS-Hinweisen, nicht pauschal „Perfektion“?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes.
|
||||||
|
Wenn wichtige Zwischenschritte fehlen: suggested_new_exercises mit Titel + Kurzskizze und insert_after_step_index.
|
||||||
|
|
||||||
|
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": []
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_exercise_path_qa';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET 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}}
|
||||||
|
|
||||||
|
Katalog-Kontext (Primärfokus / Trainingsstil / Zielgruppe / Stil — leere Zeilen ignorieren):
|
||||||
|
|
||||||
|
Primärfokus: {{focus_area_description}}
|
||||||
|
Progression: {{focus_area_hints_on_progression}}
|
||||||
|
|
||||||
|
Trainingsstil: {{training_type_description}}
|
||||||
|
Progression: {{training_type_hints_on_progression}}
|
||||||
|
|
||||||
|
Zielgruppe: {{target_group_description}}
|
||||||
|
|
||||||
|
Stilrichtung: {{style_direction_description}}
|
||||||
|
|
||||||
|
Wichtig: Keine Gruppenanalyse — nur didaktischer Pfad. Katalog-Hinweise beachten.
|
||||||
|
|
||||||
|
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$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_goal_analysis';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $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}}
|
||||||
|
|
||||||
|
Katalog-Kontext für Stufenlogik:
|
||||||
|
|
||||||
|
Primärfokus:
|
||||||
|
{{focus_area_description}}
|
||||||
|
Roadmap: {{focus_area_hints_on_progression}}
|
||||||
|
Vermeiden: {{focus_area_anti_patterns}}
|
||||||
|
|
||||||
|
Trainingsstil:
|
||||||
|
{{training_type_description}}
|
||||||
|
Roadmap: {{training_type_hints_on_progression}}
|
||||||
|
|
||||||
|
Zielgruppe:
|
||||||
|
{{target_group_description}}
|
||||||
|
Roadmap: {{target_group_hints_on_progression}}
|
||||||
|
|
||||||
|
Stilrichtung:
|
||||||
|
{{style_direction_description}}
|
||||||
|
Roadmap: {{style_direction_hints_on_progression}}
|
||||||
|
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Erzeuge zuerst 8–12 micro_objectives, dann konsolidiere auf genau N major_steps.
|
||||||
|
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion — Katalog-Roadmap-Hinweise beachten.
|
||||||
|
|
||||||
|
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$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_roadmap';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET 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}}
|
||||||
|
Intent-Kontext: {{intent_context_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Katalog-Kontext je Stufe:
|
||||||
|
|
||||||
|
Primärfokus — Progression: {{focus_area_hints_on_progression}}
|
||||||
|
Primärfokus — Vermeiden: {{focus_area_anti_patterns}}
|
||||||
|
|
||||||
|
Trainingsstil — Progression: {{training_type_hints_on_progression}}
|
||||||
|
Trainingsstil — Vermeiden: {{training_type_anti_patterns}}
|
||||||
|
|
||||||
|
Zielgruppe — Progression: {{target_group_hints_on_progression}}
|
||||||
|
Zielgruppe — Vermeiden: {{target_group_anti_patterns}}
|
||||||
|
|
||||||
|
Stilrichtung — Progression: {{style_direction_hints_on_progression}}
|
||||||
|
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Für jeden Major Step: messbares Lernziel, load_profile, exercise_type, success_criteria, anti_patterns — Katalog-Slots beachten.
|
||||||
|
|
||||||
|
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$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und extrahierst Start, Ziel und Ergänzungen für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Trainer-Notizen: {{user_notes}}
|
||||||
|
|
||||||
|
Katalog-Einordnung:
|
||||||
|
Primärfokus: {{focus_area_description}}
|
||||||
|
Trainingsstil: {{training_type_description}}
|
||||||
|
Zielgruppe: {{target_group_description}}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "…",
|
||||||
|
"start_situation": "…",
|
||||||
|
"target_state": "…",
|
||||||
|
"roadmap_notes": "…",
|
||||||
|
"extraction_notes": "…"
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_start_target';
|
||||||
167
backend/migrations/094_catalog_prompt_slots_full_seed.sql
Normal file
167
backend/migrations/094_catalog_prompt_slots_full_seed.sql
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
-- Migration 094: Vollständige Befüllung catalog_prompt_slots (H1-Inhalte + Defaults für alle Stammdaten)
|
||||||
|
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS _catalog_slot_seed (
|
||||||
|
catalog_kind VARCHAR(32) NOT NULL,
|
||||||
|
name_pattern TEXT NOT NULL,
|
||||||
|
slot_key VARCHAR(64) NOT NULL,
|
||||||
|
content TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
TRUNCATE _catalog_slot_seed;
|
||||||
|
|
||||||
|
-- Primärfokus Karate (häufigster Technik-Pfad)
|
||||||
|
INSERT INTO _catalog_slot_seed (catalog_kind, name_pattern, slot_key, content) VALUES
|
||||||
|
('focus_area', 'Karate', 'description',
|
||||||
|
'Technik-Curriculum im Karate-Kontext: aufeinander aufbauende Kihon-Progression mit klaren Qualitätsankern (Stand, Hüfte, Kime).'),
|
||||||
|
('focus_area', 'Karate', 'hints_on_progression',
|
||||||
|
'Typische Phasen: Einstieg → Grundlagen → Koordination/Kraft → Anwendung → optional Vertiefung; Grundlagen vor Perfektion.'),
|
||||||
|
('focus_area', 'Karate', 'hints_on_exercise',
|
||||||
|
'Kihon und Partnerübungen mit Technikbezug; reine Kraft-/Ausdauer-Inseln nur mit klarer Begründung.'),
|
||||||
|
('focus_area', 'Karate', 'hints_on_path_qa',
|
||||||
|
'Kohärente Progression Grundlagen → Anwendung → Vertiefung; Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten.'),
|
||||||
|
('focus_area', 'Karate', 'anti_patterns',
|
||||||
|
'Keine pauschale Perfektions-Stufe verlangen, wenn der Trainingsstil Breitensport ist.');
|
||||||
|
|
||||||
|
-- Selbstverteidigung
|
||||||
|
INSERT INTO _catalog_slot_seed VALUES
|
||||||
|
('focus_area', 'Selbstverteidigung', 'description',
|
||||||
|
'Praktische Selbstverteidigung: realistische Szenarien, Sicherheit und anwendungsnahe Progression — nicht Show-Technik oder Wettkampf-Kata.'),
|
||||||
|
('focus_area', 'Selbstverteidigung', 'hints_on_progression',
|
||||||
|
'Von Wahrnehmung und Distanz zu einfachen Abwehrmustern und kontrollierter Anwendung.'),
|
||||||
|
('focus_area', 'Selbstverteidigung', 'hints_on_exercise',
|
||||||
|
'Partnerübungen mit klaren Sicherheitsregeln; Szenario-Bezug wichtiger als Stil-Show.'),
|
||||||
|
('focus_area', 'Selbstverteidigung', 'hints_on_path_qa',
|
||||||
|
'Lücken bei Szenario- oder Sicherheitsstufen sind relevant; fehlende Kick-Varianten oder Wettkampftiefe sind kein Mangel.'),
|
||||||
|
('focus_area', 'Selbstverteidigung', 'anti_patterns',
|
||||||
|
'Keine Wettkampf- oder Kata-Perfektion als QS-Maßstab.');
|
||||||
|
|
||||||
|
-- Gewaltschutz (ergänzt 092)
|
||||||
|
INSERT INTO _catalog_slot_seed VALUES
|
||||||
|
('focus_area', 'Gewaltschutz', 'hints_on_progression',
|
||||||
|
'Phasen: Wahrnehmung → Grenzen → Deeskalation → sichere Übungsformen; keine Kumite-Perfektionsstufen erzwingen.'),
|
||||||
|
('focus_area', 'Gewaltschutz', 'hints_on_exercise',
|
||||||
|
'Übungen mit Rollen, Kommunikation, Ausweichen; keine rein technischen Kick-Fokus-Inseln ohne Bezug.');
|
||||||
|
|
||||||
|
-- Fitness (falls vorhanden)
|
||||||
|
INSERT INTO _catalog_slot_seed VALUES
|
||||||
|
('focus_area', 'Fitness', 'description',
|
||||||
|
'Fitness- und Konditionsorientierung mit sicherer Belastungssteuerung; Technikbezug nur wo fachlich sinnvoll.'),
|
||||||
|
('focus_area', 'Fitness', 'hints_on_progression',
|
||||||
|
'Progression von niedriger zu moderater Belastung; klare Pausen und Technikhygiene.'),
|
||||||
|
('focus_area', 'Fitness', 'hints_on_path_qa',
|
||||||
|
'Keine Wettkampf-Spezialisierung als Pflicht-Kriterium; Belastungssteigerung ohne Technikbezug abwerten.'),
|
||||||
|
('focus_area', 'Fitness', 'anti_patterns',
|
||||||
|
'Keine Kumite-Perfektion oder Wettkampf-Kombinationen als QS-Maßstab verlangen.');
|
||||||
|
|
||||||
|
-- Trainingsstile (global)
|
||||||
|
INSERT INTO _catalog_slot_seed VALUES
|
||||||
|
('training_type', 'Breitensport', 'hints_on_progression',
|
||||||
|
'Moderater Schwierigkeitsanstieg; Perfektionsphasen optional.'),
|
||||||
|
('training_type', 'Breitensport', 'anti_patterns',
|
||||||
|
'Keine Leistungssport-Perfektion als Pflicht-Lücke.'),
|
||||||
|
('training_type', 'Leistungssport', 'description',
|
||||||
|
'Leistungsorientiertes Training mit höherer Anspruchskurve und Spezialisierung.'),
|
||||||
|
('training_type', 'Leistungssport', 'hints_on_progression',
|
||||||
|
'Belastungs- und Kombinationsprogressionen sind erwünscht.'),
|
||||||
|
('training_type', 'Leistungssport', 'hints_on_path_qa',
|
||||||
|
'Höhere Anspruchskurven sind passend; Lücken in Spezialisierung können echte Hinweise sein.'),
|
||||||
|
('training_type', 'Wettkampf', 'hints_on_progression',
|
||||||
|
'Anwendungs- und Druckphasen zeitig einplanen.');
|
||||||
|
|
||||||
|
-- Zielgruppen
|
||||||
|
INSERT INTO _catalog_slot_seed VALUES
|
||||||
|
('target_group', 'Breitensportler', 'description',
|
||||||
|
'Breitensport: Partizipation und Verständlichkeit vor maximaler Spezialisierung.'),
|
||||||
|
('target_group', 'Breitensportler', 'hints_on_path_qa',
|
||||||
|
'Moderate Progression; Perfektions-Lücken sind selten echte Mängel.'),
|
||||||
|
('target_group', 'Breitensportler', 'anti_patterns',
|
||||||
|
'Keine Leistungssport-Perfektion als Pflicht-Kriterium.'),
|
||||||
|
('target_group', 'Kinder', 'hints_on_progression',
|
||||||
|
'Spielerische Einstiege; kurze Abschnitte; Sicherheit vor Perfektion.'),
|
||||||
|
('target_group', 'Leistungssportler', 'hints_on_progression',
|
||||||
|
'Anspruchskurve und Spezialisierung dürfen steiler sein.');
|
||||||
|
|
||||||
|
-- Stilrichtungen (generisch + Shotokan-Details via 092)
|
||||||
|
INSERT INTO _catalog_slot_seed VALUES
|
||||||
|
('style_direction', 'Goju-Ryu', 'hints_on_progression',
|
||||||
|
'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'),
|
||||||
|
('style_direction', 'Wado-Ryu', 'hints_on_progression',
|
||||||
|
'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'),
|
||||||
|
('style_direction', 'Shito-Ryu', 'hints_on_progression',
|
||||||
|
'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'),
|
||||||
|
('style_direction', 'Kyokushin', 'hints_on_progression',
|
||||||
|
'Stil-Nuancen (Stand, Belastung, Kime) einbeziehen — kein Stilwechsel erzwingen.');
|
||||||
|
|
||||||
|
-- Fokusbereiche: aus Seed-Tabelle
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT s.catalog_kind, fa.id, s.slot_key, s.content
|
||||||
|
FROM _catalog_slot_seed s
|
||||||
|
JOIN focus_areas fa ON fa.name ILIKE s.name_pattern
|
||||||
|
WHERE s.catalog_kind = 'focus_area'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key)
|
||||||
|
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT s.catalog_kind, tt.id, s.slot_key, s.content
|
||||||
|
FROM _catalog_slot_seed s
|
||||||
|
JOIN training_types tt ON tt.name ILIKE s.name_pattern
|
||||||
|
WHERE s.catalog_kind = 'training_type'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key)
|
||||||
|
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT s.catalog_kind, tg.id, s.slot_key, s.content
|
||||||
|
FROM _catalog_slot_seed s
|
||||||
|
JOIN target_groups tg ON tg.name ILIKE s.name_pattern
|
||||||
|
WHERE s.catalog_kind = 'target_group'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key)
|
||||||
|
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT s.catalog_kind, sd.id, s.slot_key, s.content
|
||||||
|
FROM _catalog_slot_seed s
|
||||||
|
JOIN style_directions sd ON sd.name ILIKE s.name_pattern
|
||||||
|
WHERE s.catalog_kind = 'style_direction'
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key)
|
||||||
|
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||||
|
|
||||||
|
-- Default-Technik-Pack für Fokusbereiche ohne hints_on_path_qa (außer Gewaltschutz/Fitness)
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'focus_area', fa.id, 'hints_on_path_qa',
|
||||||
|
'Kohärente Progression zum Anfrage-Thema; Lücken sind fehlende Zwischenstufen im Lernpfad, nicht fehlende Nebenthemen.'
|
||||||
|
FROM focus_areas fa
|
||||||
|
WHERE fa.name NOT ILIKE 'Gewaltschutz'
|
||||||
|
AND fa.name NOT ILIKE 'Fitness'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM catalog_prompt_slots cps
|
||||||
|
WHERE cps.catalog_kind = 'focus_area' AND cps.catalog_id = fa.id AND cps.slot_key = 'hints_on_path_qa'
|
||||||
|
AND TRIM(cps.content) <> ''
|
||||||
|
)
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'focus_area', fa.id, 'hints_on_progression',
|
||||||
|
'Grundlagen vor Anwendung; moderate Sprünge zwischen Stufen vermeiden.'
|
||||||
|
FROM focus_areas fa
|
||||||
|
WHERE fa.name NOT ILIKE 'Gewaltschutz'
|
||||||
|
AND fa.name NOT ILIKE 'Fitness'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM catalog_prompt_slots cps
|
||||||
|
WHERE cps.catalog_kind = 'focus_area' AND cps.catalog_id = fa.id AND cps.slot_key = 'hints_on_progression'
|
||||||
|
AND TRIM(cps.content) <> ''
|
||||||
|
)
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Stilrichtungen ohne Eintrag: generischer Progressions-Hinweis
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||||
|
SELECT 'style_direction', sd.id, 'hints_on_progression',
|
||||||
|
'Stil-spezifische Nuancen (Stand, Hüfte, Rhythmus) einbeziehen — ohne Stilwechsel zu erzwingen.'
|
||||||
|
FROM style_directions sd
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM catalog_prompt_slots cps
|
||||||
|
WHERE cps.catalog_kind = 'style_direction' AND cps.catalog_id = sd.id AND cps.slot_key = 'hints_on_progression'
|
||||||
|
AND TRIM(cps.content) <> ''
|
||||||
|
)
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS _catalog_slot_seed;
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
231
backend/openrouter_chat.py
Normal file
231
backend/openrouter_chat.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from planning_llm_usage import record_planning_llm_call
|
||||||
|
|
||||||
|
record_planning_llm_call(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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",
|
||||||
|
]
|
||||||
16
backend/planning_catalog_prompt_snippets.py
Normal file
16
backend/planning_catalog_prompt_snippets.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""
|
||||||
|
Katalog-Prompt-Snippets — Abwärtskompatibilität (H1-Importpfade).
|
||||||
|
|
||||||
|
Implementierung: catalog_prompt_slots.py (H2).
|
||||||
|
"""
|
||||||
|
from catalog_prompt_slots import (
|
||||||
|
build_catalog_guidance_for_prompt,
|
||||||
|
get_rematch_guard_for_catalog,
|
||||||
|
pick_active_catalog_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_catalog_guidance_for_prompt",
|
||||||
|
"get_rematch_guard_for_catalog",
|
||||||
|
"pick_active_catalog_item",
|
||||||
|
]
|
||||||
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",
|
||||||
|
]
|
||||||
788
backend/planning_exercise_path_ai_fill.py
Normal file
788
backend/planning_exercise_path_ai_fill.py
Normal file
|
|
@ -0,0 +1,788 @@
|
||||||
|
"""
|
||||||
|
Planungs-KI Phase E2/E3: KI-Neuanlage für Pfad-Lücken + strukturierte Angebote für die UI.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
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 planning_exercise_path_qa import find_step_pair_index
|
||||||
|
from planning_exercise_form_context import (
|
||||||
|
build_progression_entry_state,
|
||||||
|
build_progression_gap_snapshot,
|
||||||
|
enrich_gap_snapshot_with_entry_state,
|
||||||
|
prior_path_steps_before_major,
|
||||||
|
)
|
||||||
|
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_neighbor_steps_by_major_index(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
major_idx: int,
|
||||||
|
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
|
||||||
|
"""Nachbarn im Pfad anhand roadmap_major_step_index (nicht Array-Position)."""
|
||||||
|
step_before: Optional[Mapping[str, Any]] = None
|
||||||
|
step_after: Optional[Mapping[str, Any]] = None
|
||||||
|
for step in steps:
|
||||||
|
raw = step.get("roadmap_major_step_index")
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mi = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if mi < major_idx:
|
||||||
|
step_before = step
|
||||||
|
elif mi > major_idx and step_after is None:
|
||||||
|
step_after = step
|
||||||
|
return step_before, step_after
|
||||||
|
|
||||||
|
|
||||||
|
def _build_stage_ai_context(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
step_before: Optional[Mapping[str, Any]] = None,
|
||||||
|
step_after: Optional[Mapping[str, Any]] = None,
|
||||||
|
prior_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
start_situation: Optional[str] = None,
|
||||||
|
) -> ExerciseFormAiPromptContext:
|
||||||
|
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
|
||||||
|
gap = dict(spec.get("gap") or {})
|
||||||
|
phase = spec.get("phase") or gap.get("expected_phase") or "vertiefung"
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
learning_goal = (
|
||||||
|
gap.get("learning_goal")
|
||||||
|
or spec.get("title_hint")
|
||||||
|
or spec.get("sketch")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
title = (spec.get("title_hint") or f"{topic} — {phase}").strip()[:280]
|
||||||
|
major_idx = spec.get("roadmap_major_step_index")
|
||||||
|
entry: Dict[str, Any] = {}
|
||||||
|
if prior_steps is not None and major_idx is not None:
|
||||||
|
entry = build_progression_entry_state(
|
||||||
|
major_step_index=major_idx,
|
||||||
|
prior_steps=prior_steps,
|
||||||
|
start_situation=start_situation,
|
||||||
|
)
|
||||||
|
|
||||||
|
goal_parts = [
|
||||||
|
f"Planungsziel: {goal_query}",
|
||||||
|
f"Roadmap-Stufe ({phase}): {learning_goal}",
|
||||||
|
"Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.",
|
||||||
|
]
|
||||||
|
if entry.get("entry_state"):
|
||||||
|
goal_parts.append(
|
||||||
|
f"Eingangszustand (erreichte Voraussetzungen): {entry['entry_state']}"
|
||||||
|
)
|
||||||
|
if entry.get("entry_state_detail") and entry.get("entry_state_detail") != entry.get("entry_state"):
|
||||||
|
goal_parts.append(f"Bisheriger Pfad:\n{entry['entry_state_detail']}")
|
||||||
|
if step_before:
|
||||||
|
goal_parts.append(
|
||||||
|
f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}“"
|
||||||
|
)
|
||||||
|
if step_after:
|
||||||
|
goal_parts.append(
|
||||||
|
f"Nächste Stufe im Pfad: „{(step_after.get('title') or '').strip()}“"
|
||||||
|
)
|
||||||
|
sketch = (spec.get("sketch") or "").strip()
|
||||||
|
if sketch and sketch != learning_goal:
|
||||||
|
goal_parts.extend(["", f"Kontext: {sketch}"])
|
||||||
|
goal = "\n".join(goal_parts)
|
||||||
|
|
||||||
|
focus_hint = topic if brief.topic_type == "technique" else None
|
||||||
|
if brief.must_phrases:
|
||||||
|
focus_hint = ", ".join(brief.must_phrases[:2])
|
||||||
|
|
||||||
|
return ExerciseFormAiPromptContext(
|
||||||
|
title=title[:280],
|
||||||
|
goal=goal[:8000],
|
||||||
|
execution=None,
|
||||||
|
focus_hint=focus_hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def try_suggest_ai_stage_step(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""KI-Vorschlag für leere Roadmap-Stufe."""
|
||||||
|
major_idx = spec.get("roadmap_major_step_index")
|
||||||
|
if major_idx is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
mi = int(major_idx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi)
|
||||||
|
prior_steps = prior_path_steps_before_major(steps, mi)
|
||||||
|
gap = dict(spec.get("gap") or {})
|
||||||
|
if not gap.get("expected_phase"):
|
||||||
|
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
||||||
|
gap["roadmap_major_step_index"] = mi
|
||||||
|
if not gap.get("learning_goal"):
|
||||||
|
gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
|
||||||
|
|
||||||
|
ctx = _build_stage_ai_context(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
spec=spec,
|
||||||
|
step_before=step_before,
|
||||||
|
step_after=step_after,
|
||||||
|
prior_steps=prior_steps,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("roadmap_unfilled AI suggest failed")
|
||||||
|
return None
|
||||||
|
if not ai_payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
summary_text = ""
|
||||||
|
summary_obj = ai_payload.get("summary")
|
||||||
|
if isinstance(summary_obj, dict):
|
||||||
|
summary_text = str(summary_obj.get("text") or "").strip()
|
||||||
|
elif isinstance(summary_obj, str):
|
||||||
|
summary_text = summary_obj.strip()
|
||||||
|
|
||||||
|
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
|
||||||
|
title = (ctx.title or spec.get("title_hint") or "KI-Vorschlag").strip()
|
||||||
|
return {
|
||||||
|
"exercise_id": None,
|
||||||
|
"proposal_key": proposal_key,
|
||||||
|
"variant_id": None,
|
||||||
|
"title": title,
|
||||||
|
"summary": summary_text or None,
|
||||||
|
"score": None,
|
||||||
|
"semantic_score": None,
|
||||||
|
"reasons": ["KI-Neuanlage für Roadmap-Stufe ohne Bibliothekstreffer"],
|
||||||
|
"variants": [],
|
||||||
|
"is_bridge": False,
|
||||||
|
"is_ai_proposal": True,
|
||||||
|
"ai_suggestion": dict(ai_payload),
|
||||||
|
"roadmap_major_step_index": mi,
|
||||||
|
"roadmap_phase": gap.get("expected_phase"),
|
||||||
|
"roadmap_learning_goal": gap.get("learning_goal"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_gap_ai_context(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
step_a: Mapping[str, Any],
|
||||||
|
step_b: Mapping[str, Any],
|
||||||
|
gap: Mapping[str, Any],
|
||||||
|
title_hint: Optional[str] = None,
|
||||||
|
sketch_hint: Optional[str] = None,
|
||||||
|
) -> ExerciseFormAiPromptContext:
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
phase = gap.get("expected_phase") or "vertiefung"
|
||||||
|
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
|
||||||
|
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
|
||||||
|
|
||||||
|
title = (title_hint or f"Brücke {topic} ({phase})").strip()[:280]
|
||||||
|
sketch = (sketch_hint or "").strip()
|
||||||
|
goal_parts = [
|
||||||
|
f"Planungsziel: {goal_query}",
|
||||||
|
"",
|
||||||
|
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.",
|
||||||
|
f"Phase: {phase}. Thema: {topic}.",
|
||||||
|
"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor.",
|
||||||
|
]
|
||||||
|
if sketch:
|
||||||
|
goal_parts.extend(["", f"Hinweis: {sketch}"])
|
||||||
|
goal = "\n".join(goal_parts)
|
||||||
|
|
||||||
|
focus_hint = topic if brief.topic_type == "technique" else None
|
||||||
|
if brief.must_phrases:
|
||||||
|
focus_hint = ", ".join(brief.must_phrases[:2])
|
||||||
|
|
||||||
|
return ExerciseFormAiPromptContext(
|
||||||
|
title=title[:280],
|
||||||
|
goal=goal[:8000],
|
||||||
|
execution=None,
|
||||||
|
focus_hint=focus_hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ai_proposal_to_path_step(
|
||||||
|
*,
|
||||||
|
ai_payload: Mapping[str, Any],
|
||||||
|
ctx_title: str,
|
||||||
|
gap: Mapping[str, Any],
|
||||||
|
step_a: Mapping[str, Any],
|
||||||
|
step_b: Mapping[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
summary_text = ""
|
||||||
|
summary_obj = ai_payload.get("summary")
|
||||||
|
if isinstance(summary_obj, dict):
|
||||||
|
summary_text = str(summary_obj.get("text") or "").strip()
|
||||||
|
elif isinstance(summary_obj, str):
|
||||||
|
summary_text = summary_obj.strip()
|
||||||
|
|
||||||
|
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
|
||||||
|
title = (ctx_title or "").strip() or "KI-Vorschlag (Brücke)"
|
||||||
|
reasons = ["KI-Neuanlage-Vorschlag — Lücke ohne passende Bibliotheks-Übung"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"exercise_id": None,
|
||||||
|
"proposal_key": proposal_key,
|
||||||
|
"variant_id": None,
|
||||||
|
"title": title,
|
||||||
|
"summary": summary_text or None,
|
||||||
|
"score": None,
|
||||||
|
"semantic_score": None,
|
||||||
|
"reasons": reasons,
|
||||||
|
"variants": [],
|
||||||
|
"is_bridge": True,
|
||||||
|
"is_ai_proposal": True,
|
||||||
|
"ai_suggestion": dict(ai_payload),
|
||||||
|
"bridge_for_gap": {
|
||||||
|
"from_exercise_id": step_a.get("exercise_id"),
|
||||||
|
"to_exercise_id": step_b.get("exercise_id"),
|
||||||
|
"gap_score": gap.get("gap_score"),
|
||||||
|
"expected_phase": gap.get("expected_phase"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def try_suggest_ai_bridge_step(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
step_a: Mapping[str, Any],
|
||||||
|
step_b: Mapping[str, Any],
|
||||||
|
gap: Mapping[str, Any],
|
||||||
|
title_hint: Optional[str] = None,
|
||||||
|
sketch_hint: Optional[str] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
|
||||||
|
ctx = _build_gap_ai_context(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
gap=gap,
|
||||||
|
title_hint=title_hint,
|
||||||
|
sketch_hint=sketch_hint,
|
||||||
|
)
|
||||||
|
g_plain = strip_html_to_plain(ctx.goal)
|
||||||
|
if not g_plain.strip() and not (ctx.title or "").strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = run_exercise_form_ai_suggestion(
|
||||||
|
cur,
|
||||||
|
ctx,
|
||||||
|
want_summary=True,
|
||||||
|
want_skills=True,
|
||||||
|
want_instructions=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("KI-Lückenfüller fehlgeschlagen: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
return ai_proposal_to_path_step(
|
||||||
|
ai_payload=payload,
|
||||||
|
ctx_title=ctx.title or "",
|
||||||
|
gap=gap,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_sketch(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
step_a: Optional[Mapping[str, Any]],
|
||||||
|
step_b: Optional[Mapping[str, Any]],
|
||||||
|
phase: str,
|
||||||
|
rationale: str = "",
|
||||||
|
) -> str:
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
from_t = (step_a or {}).get("title") or "vorherigem Schritt"
|
||||||
|
to_t = (step_b or {}).get("title") or "nächstem Schritt"
|
||||||
|
parts = [
|
||||||
|
f"Planungsziel: {goal_query}",
|
||||||
|
f"Zwischenschritt für {topic} ({phase}) zwischen „{from_t}“ und „{to_t}“.",
|
||||||
|
]
|
||||||
|
if rationale:
|
||||||
|
parts.append(rationale)
|
||||||
|
return " ".join(parts)[:1200]
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
|
||||||
|
return (
|
||||||
|
spec.get("source"),
|
||||||
|
int(spec.get("insert_after_index") or 0),
|
||||||
|
str(spec.get("title_hint") or "")[:48],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _step_neighbors_at_index(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
idx: int,
|
||||||
|
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
|
||||||
|
"""Vorheriger/nächster Pfadschritt ohne IndexError (Rand-Slots, leere Stufen)."""
|
||||||
|
if idx < 0 or idx >= len(steps):
|
||||||
|
return None, None
|
||||||
|
step_a = steps[idx - 1] if idx > 0 else None
|
||||||
|
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
||||||
|
return step_a, step_b
|
||||||
|
|
||||||
|
|
||||||
|
def collect_gap_fill_specs(
|
||||||
|
*,
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
unfilled_gaps: Sequence[Mapping[str, Any]],
|
||||||
|
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||||
|
llm_specs: Sequence[Mapping[str, Any]],
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
goal_query: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Sammelt alle Lücken, für die ein KI-Anlege-Angebot sinnvoll ist."""
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
specs: List[Dict[str, Any]] = []
|
||||||
|
seen: set = set()
|
||||||
|
|
||||||
|
def add(spec: Dict[str, Any]) -> None:
|
||||||
|
key = _spec_dedupe_key(spec)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
specs.append(spec)
|
||||||
|
|
||||||
|
for gap in unfilled_gaps:
|
||||||
|
idx = find_step_pair_index(
|
||||||
|
steps,
|
||||||
|
int(gap["from_exercise_id"]),
|
||||||
|
int(gap["to_exercise_id"]),
|
||||||
|
)
|
||||||
|
if idx is None or idx + 1 >= len(steps):
|
||||||
|
continue
|
||||||
|
step_a = steps[idx]
|
||||||
|
step_b = steps[idx + 1]
|
||||||
|
phase = gap.get("expected_phase") or "vertiefung"
|
||||||
|
add(
|
||||||
|
{
|
||||||
|
"source": "unfilled_gap",
|
||||||
|
"insert_after_index": idx,
|
||||||
|
"gap": dict(gap),
|
||||||
|
"phase": phase,
|
||||||
|
"title_hint": f"{topic} — {phase}",
|
||||||
|
"sketch": _default_sketch(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
phase=str(phase),
|
||||||
|
rationale="Bibliothek enthält keine passende Brücke.",
|
||||||
|
),
|
||||||
|
"rationale": "Lücke zwischen benachbaren Schritten — keine passende Bibliotheks-Übung.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for ot in off_topic_steps:
|
||||||
|
major_idx = ot.get("roadmap_major_step_index")
|
||||||
|
idx: Optional[int] = None
|
||||||
|
if major_idx is not None:
|
||||||
|
try:
|
||||||
|
mi = int(major_idx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
mi = None
|
||||||
|
if mi is not None:
|
||||||
|
idx = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, s in enumerate(steps)
|
||||||
|
if s.get("roadmap_major_step_index") is not None
|
||||||
|
and int(s["roadmap_major_step_index"]) == mi
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if idx is None:
|
||||||
|
idx = int(ot.get("step_index") or 0)
|
||||||
|
if idx < 0 or idx >= len(steps):
|
||||||
|
continue
|
||||||
|
step_a, step_b = _step_neighbors_at_index(steps, idx)
|
||||||
|
phase = ot.get("expected_phase") or "vertiefung"
|
||||||
|
insert_after = max(idx - 1, -1)
|
||||||
|
stage_goal = str(ot.get("roadmap_learning_goal") or "").strip()
|
||||||
|
if str(ot.get("issue") or "") == "stage_mismatch" and stage_goal:
|
||||||
|
title_hint = stage_goal[:120]
|
||||||
|
rationale = (
|
||||||
|
f"Keine passende Bibliotheks-Übung für Stufen-Lernziel „{stage_goal[:100]}“."
|
||||||
|
)
|
||||||
|
sketch_rationale = (
|
||||||
|
f"Slot braucht Übung passend zu: {stage_goal[:200]}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
title_hint = f"{topic} — {phase} (Ersatz für themenfremden Schritt)"
|
||||||
|
rationale = f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema."
|
||||||
|
sketch_rationale = f"Ersetzt themenfremden Schritt „{ot.get('title')}“."
|
||||||
|
add(
|
||||||
|
{
|
||||||
|
"source": "off_topic" if ot.get("issue") != "stage_mismatch" else "stage_mismatch",
|
||||||
|
"insert_after_index": insert_after,
|
||||||
|
"replace_step_index": idx,
|
||||||
|
"roadmap_major_step_index": major_idx,
|
||||||
|
"gap": {
|
||||||
|
"expected_phase": phase,
|
||||||
|
"off_topic_title": ot.get("title"),
|
||||||
|
"off_topic_exercise_id": ot.get("exercise_id"),
|
||||||
|
"roadmap_learning_goal": stage_goal or None,
|
||||||
|
},
|
||||||
|
"phase": phase,
|
||||||
|
"title_hint": title_hint,
|
||||||
|
"sketch": _default_sketch(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
phase=str(phase),
|
||||||
|
rationale=sketch_rationale,
|
||||||
|
),
|
||||||
|
"rationale": rationale,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for spec in llm_specs:
|
||||||
|
add(dict(spec))
|
||||||
|
|
||||||
|
return specs[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def build_gap_fill_goal_text(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
step_a: Optional[Mapping[str, Any]] = None,
|
||||||
|
step_b: Optional[Mapping[str, Any]] = None,
|
||||||
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext."""
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
phase = spec.get("phase") or "vertiefung"
|
||||||
|
from_title = (step_a or {}).get("title") or spec.get("from_title") or "vorherigem Schritt"
|
||||||
|
to_title = (step_b or {}).get("title") or spec.get("to_title") or "nächstem Schritt"
|
||||||
|
arc = ", ".join(brief.development_arc or []) or "einstieg → grundlage → vertiefung → anwendung → perfektion"
|
||||||
|
snap = dict(roadmap_snapshot or {})
|
||||||
|
if not snap:
|
||||||
|
snap = build_progression_gap_snapshot(semantic_brief=brief_to_summary_dict(brief))
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f"Planungsziel (gesamter Pfad): {goal_query}",
|
||||||
|
f"Hauptthema: {snap.get('primary_topic') or topic}",
|
||||||
|
]
|
||||||
|
if snap.get("entry_state"):
|
||||||
|
parts.append(
|
||||||
|
f"Eingangszustand (erreichte Voraussetzungen aus Vorstufen): {snap['entry_state']}"
|
||||||
|
)
|
||||||
|
if snap.get("entry_state_detail") and snap.get("entry_state_detail") != snap.get("entry_state"):
|
||||||
|
parts.append(f"Bisheriger Pfad:\n{snap['entry_state_detail']}")
|
||||||
|
if snap.get("start_situation") and not snap.get("entry_state"):
|
||||||
|
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
|
||||||
|
elif snap.get("start_situation") and snap.get("prior_steps"):
|
||||||
|
parts.append(f"Ausgangsbasis des gesamten Pfads: {snap['start_situation']}")
|
||||||
|
if snap.get("target_state"):
|
||||||
|
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
|
||||||
|
if snap.get("roadmap_notes"):
|
||||||
|
parts.append(f"Ergänzender Kontext: {snap['roadmap_notes']}")
|
||||||
|
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
|
||||||
|
if stage_goal:
|
||||||
|
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
|
||||||
|
parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}")
|
||||||
|
parts.append(f"Erwarteter Entwicklungsbogen: {arc}")
|
||||||
|
if spec.get("source") == "roadmap_unfilled":
|
||||||
|
parts.append(
|
||||||
|
"Einordnung: Übung für diese Roadmap-Stufe — das Stufen-Lernziel steht im Vordergrund."
|
||||||
|
)
|
||||||
|
if step_a:
|
||||||
|
parts.append(f"Vorherige Stufe: „{from_title}“")
|
||||||
|
if step_b:
|
||||||
|
parts.append(f"Nächste Stufe: „{to_title}“")
|
||||||
|
else:
|
||||||
|
parts.append(
|
||||||
|
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“."
|
||||||
|
)
|
||||||
|
if snap.get("stage_load_profile"):
|
||||||
|
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
|
||||||
|
if snap.get("stage_success_criteria"):
|
||||||
|
parts.append(
|
||||||
|
"Erfolgskriterien dieser Stufe: "
|
||||||
|
+ "; ".join(str(x) for x in snap["stage_success_criteria"][:4])
|
||||||
|
)
|
||||||
|
if snap.get("stage_anti_patterns"):
|
||||||
|
parts.append(
|
||||||
|
"Vermeiden: " + "; ".join(str(x) for x in snap["stage_anti_patterns"][:3])
|
||||||
|
)
|
||||||
|
if snap.get("skill_hints"):
|
||||||
|
parts.append(
|
||||||
|
"Fähigkeiten-/Fokus-Hinweise: "
|
||||||
|
+ "; ".join(str(x) for x in snap["skill_hints"][:4])
|
||||||
|
)
|
||||||
|
expected = snap.get("expected_skills") or []
|
||||||
|
if expected:
|
||||||
|
names = [
|
||||||
|
str(s.get("skill_name") or "").strip()
|
||||||
|
for s in expected[:5]
|
||||||
|
if str(s.get("skill_name") or "").strip()
|
||||||
|
]
|
||||||
|
if names:
|
||||||
|
parts.append(
|
||||||
|
"Erwartete Fähigkeiten (Scoring): " + ", ".join(names)
|
||||||
|
)
|
||||||
|
if spec.get("rationale"):
|
||||||
|
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
|
||||||
|
if spec.get("sketch"):
|
||||||
|
parts.append(f"Skizze: {spec['sketch']}")
|
||||||
|
parts.append(
|
||||||
|
"Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, "
|
||||||
|
"Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. "
|
||||||
|
"Konkrete Durchführung, Ziel und Trainerhinweise ausformulieren."
|
||||||
|
)
|
||||||
|
return "\n\n".join(parts)[:8000]
|
||||||
|
|
||||||
|
|
||||||
|
def build_gap_fill_offer(
|
||||||
|
*,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
goal_query: str = "",
|
||||||
|
brief: Optional[PlanningSemanticBrief] = None,
|
||||||
|
proposal: Optional[Mapping[str, Any]] = None,
|
||||||
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
source = spec.get("source")
|
||||||
|
idx = int(spec.get("insert_after_index") or 0)
|
||||||
|
major_idx = spec.get("roadmap_major_step_index")
|
||||||
|
if source == "roadmap_unfilled" and major_idx is not None:
|
||||||
|
try:
|
||||||
|
mi = int(major_idx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
mi = idx
|
||||||
|
step_a, step_b = _resolve_neighbor_steps_by_major_index(steps, mi)
|
||||||
|
idx = mi
|
||||||
|
else:
|
||||||
|
step_a = steps[idx] if idx < len(steps) else None
|
||||||
|
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
||||||
|
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||||
|
enriched_snapshot = dict(roadmap_snapshot) if roadmap_snapshot else {}
|
||||||
|
major_raw = spec.get("roadmap_major_step_index")
|
||||||
|
if major_raw is not None:
|
||||||
|
enriched_snapshot = enrich_gap_snapshot_with_entry_state(
|
||||||
|
enriched_snapshot,
|
||||||
|
steps=steps,
|
||||||
|
major_step_index=major_raw,
|
||||||
|
)
|
||||||
|
goal_for_ai = ""
|
||||||
|
if brief and goal_query:
|
||||||
|
goal_for_ai = build_gap_fill_goal_text(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
spec=spec,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
roadmap_snapshot=enriched_snapshot or None,
|
||||||
|
)
|
||||||
|
ctx_preview = enriched_snapshot or None
|
||||||
|
offer: Dict[str, Any] = {
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"source": spec.get("source"),
|
||||||
|
"insert_after_index": idx,
|
||||||
|
"replace_step_index": spec.get("replace_step_index"),
|
||||||
|
"title_hint": spec.get("title_hint"),
|
||||||
|
"sketch": spec.get("sketch"),
|
||||||
|
"goal_for_ai": goal_for_ai or spec.get("sketch"),
|
||||||
|
"context_preview": ctx_preview,
|
||||||
|
"phase": spec.get("phase"),
|
||||||
|
"rationale": spec.get("rationale"),
|
||||||
|
"has_ai_payload": False,
|
||||||
|
"from_title": (step_a or {}).get("title"),
|
||||||
|
"to_title": (step_b or {}).get("title"),
|
||||||
|
"primary_topic": (brief.primary_topic if brief else None),
|
||||||
|
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
||||||
|
}
|
||||||
|
if proposal:
|
||||||
|
offer["has_ai_payload"] = True
|
||||||
|
offer["proposal_key"] = proposal.get("proposal_key")
|
||||||
|
offer["ai_suggestion"] = proposal.get("ai_suggestion")
|
||||||
|
offer["proposal_title"] = proposal.get("title")
|
||||||
|
offer["proposal_summary"] = proposal.get("summary")
|
||||||
|
return offer
|
||||||
|
|
||||||
|
|
||||||
|
def apply_gap_fill_after_qa(
|
||||||
|
cur,
|
||||||
|
steps: List[Dict[str, Any]],
|
||||||
|
specs: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
include_ai_calls: bool = True,
|
||||||
|
max_ai_proposals: int = 3,
|
||||||
|
auto_insert_proposals: bool = False,
|
||||||
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
|
||||||
|
Returns: (steps, ai_proposals, gap_fill_offers)
|
||||||
|
"""
|
||||||
|
if not specs:
|
||||||
|
return steps, [], []
|
||||||
|
|
||||||
|
out = list(steps)
|
||||||
|
proposals: List[Dict[str, Any]] = []
|
||||||
|
offers: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for spec in specs:
|
||||||
|
source = spec.get("source")
|
||||||
|
|
||||||
|
if source == "roadmap_unfilled":
|
||||||
|
proposal: Optional[Dict[str, Any]] = None
|
||||||
|
if include_ai_calls and len(proposals) < max_ai_proposals:
|
||||||
|
proposal = try_suggest_ai_stage_step(
|
||||||
|
cur,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
)
|
||||||
|
offer = build_gap_fill_offer(
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
proposal=proposal,
|
||||||
|
roadmap_snapshot=roadmap_snapshot,
|
||||||
|
)
|
||||||
|
offers.append(offer)
|
||||||
|
if proposal and auto_insert_proposals:
|
||||||
|
proposals.append(
|
||||||
|
{
|
||||||
|
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
||||||
|
"proposal_key": proposal.get("proposal_key"),
|
||||||
|
"proposal_title": proposal.get("title"),
|
||||||
|
"offer_id": offer.get("offer_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
idx = int(spec.get("insert_after_index") or 0)
|
||||||
|
if idx < 0 or idx >= len(out) - 1:
|
||||||
|
continue
|
||||||
|
step_a = out[idx]
|
||||||
|
step_b = out[idx + 1]
|
||||||
|
if step_a.get("is_ai_proposal") or step_b.get("is_ai_proposal"):
|
||||||
|
offer = build_gap_fill_offer(
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
proposal=None,
|
||||||
|
roadmap_snapshot=roadmap_snapshot,
|
||||||
|
)
|
||||||
|
offers.append(offer)
|
||||||
|
continue
|
||||||
|
|
||||||
|
gap = dict(spec.get("gap") or {})
|
||||||
|
if not gap.get("expected_phase"):
|
||||||
|
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
||||||
|
|
||||||
|
proposal = None
|
||||||
|
if include_ai_calls and len(proposals) < max_ai_proposals:
|
||||||
|
proposal = try_suggest_ai_bridge_step(
|
||||||
|
cur,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
gap=gap,
|
||||||
|
title_hint=str(spec.get("title_hint") or ""),
|
||||||
|
sketch_hint=str(spec.get("sketch") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
offer = build_gap_fill_offer(
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
proposal=proposal,
|
||||||
|
roadmap_snapshot=roadmap_snapshot,
|
||||||
|
)
|
||||||
|
offers.append(offer)
|
||||||
|
|
||||||
|
if proposal and auto_insert_proposals:
|
||||||
|
out.insert(idx + 1, proposal)
|
||||||
|
proposals.append(
|
||||||
|
{
|
||||||
|
"inserted_after_index": idx,
|
||||||
|
"proposal_key": proposal.get("proposal_key"),
|
||||||
|
"proposal_title": proposal.get("title"),
|
||||||
|
"gap": gap,
|
||||||
|
"offer_id": offer.get("offer_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return out, proposals, offers
|
||||||
|
|
||||||
|
|
||||||
|
def insert_ai_proposals_for_gaps(
|
||||||
|
cur,
|
||||||
|
steps: list,
|
||||||
|
unfilled_gaps: list,
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
max_proposals: int = 2,
|
||||||
|
) -> tuple[list, list]:
|
||||||
|
"""Legacy: Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
|
||||||
|
specs = collect_gap_fill_specs(
|
||||||
|
steps=steps,
|
||||||
|
unfilled_gaps=unfilled_gaps,
|
||||||
|
off_topic_steps=[],
|
||||||
|
llm_specs=[],
|
||||||
|
brief=brief,
|
||||||
|
goal_query=goal_query,
|
||||||
|
)
|
||||||
|
out, proposals, _offers = apply_gap_fill_after_qa(
|
||||||
|
cur,
|
||||||
|
steps,
|
||||||
|
specs,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
include_ai_calls=True,
|
||||||
|
max_ai_proposals=max_proposals,
|
||||||
|
auto_insert_proposals=True,
|
||||||
|
)
|
||||||
|
return out, proposals
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"apply_gap_fill_after_qa",
|
||||||
|
"build_gap_fill_goal_text",
|
||||||
|
"build_gap_fill_offer",
|
||||||
|
"collect_gap_fill_specs",
|
||||||
|
"insert_ai_proposals_for_gaps",
|
||||||
|
"try_suggest_ai_bridge_step",
|
||||||
|
"try_suggest_ai_stage_step",
|
||||||
|
]
|
||||||
4446
backend/planning_exercise_path_builder.py
Normal file
4446
backend/planning_exercise_path_builder.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user