feat: Add MediaWiki import functionality with tracking and mapping
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m55s

- Implemented a new SQL migration for wiki import tracking tables.
- Created an import router for handling MediaWiki imports of exercises, skills, and methods.
- Developed a Semantic MediaWiki API client for direct API interactions.
- Added a mapper to convert SMW properties to local database fields.
- Introduced background tasks for asynchronous import processing.
- Implemented logging and error handling for import operations.
- Added endpoints for previewing imports, checking import status, and managing import references.
This commit is contained in:
Lars 2026-04-24 14:41:52 +02:00
parent 0e0b709768
commit 6801c60604
19 changed files with 9472 additions and 5 deletions

View File

@ -0,0 +1,599 @@
# Review Prompt: Exercises System Specifications
**Zweck:** Vollständiger Review der erstellten Spezifikationen gegen ursprüngliche Anforderungen und User-Ergänzungen
**Reviewer:** Plan Agent (oder anderer spezialisierter Agent)
**Status:** ✅ Review abgeschlossen + Fixes implementiert
**Datum:** 2026-04-24
**Review-Datum:** 2026-04-24
**Ergebnis:** APPROVED WITH CHANGES alle BLOCKER und MAJOR Issues behoben
---
## 1. Kontext & Auftrag
Du bist ein Plan Agent, der die erstellten Spezifikationen für das Exercise System in Shinkan Jinkendo reviewt. Deine Aufgabe ist es, die 7 Core-Specs gegen die ursprünglichen Anforderungen und User-Ergänzungen zu prüfen und sicherzustellen, dass **keine Anforderungen vergessen** wurden und **keine architektonischen Drifts** entstanden sind.
---
## 2. Zu reviewende Spezifikationen
### 2.1 Core Specs (Phase 0 Foundation)
**Lokation:** `c:/Dev/shinkan-jinkendo/.claude/docs/technical/`
1. **EXERCISES_ARCHITECTURE.md** (ca. 800 Zeilen)
- Architektur-Entscheidungen
- Exercise/Variant/Block/Series Definitionen
- Media-Strategie
- API-Konventionen
- DB-Konventionen
- Decision Log
2. **UI_COMPONENTS_SPEC.md** (ca. 600 Zeilen)
- Base Components (Chip, MultiSelect, Accordion, DragDropList)
- Exercise Components (Card, QuickInfo, VariantCard, MediaGallery, MediaUploader)
- Layout Patterns
- Responsive Breakpoints
- Accessibility
3. **EXERCISES_API_SPEC.md** (ca. 574 Zeilen)
- Alle Endpoints (Exercises, Variants, Media)
- Request/Response Beispiele
- Validierung Rules
- Error Format
- HTTP Status Codes
4. **EXERCISES_FRONTEND_ROUTING.md** (ca. 400 Zeilen)
- Route-Struktur
- Navigation Patterns
- State Management (URL, Local, Context)
- Deep Linking
- Navigation Guards
- Performance (Lazy Loading, Prefetching)
5. **MEDIA_UPLOAD_SPEC.md** (ca. 500 Zeilen)
- Hybrid Upload-Strategie (Lokal + Embeds)
- Upload-Flow (Client + Server)
- Embed-Parsing (YouTube, Vimeo, Instagram, TikTok)
- Media-Galerie
- Drag & Drop Reordering
- Security & Performance
6. **SEARCH_FILTER_SPEC.md** (ca. 450 Zeilen)
- Multi-Layer Search (Keyword, Katalog, Meta)
- Volltext-Suche (tsvector)
- Frontend Filter-UI
- Erweiterte Filter (Multi-Select, Range, Equipment)
- Faceted Search
- Saved Searches
- Sortierung
7. **EXERCISES_DATABASE_FINAL.md** (ca. 600 Zeilen)
- Migration 014 (Variant Progression + Search Vector)
- Migration 015 (Semantic Matching - Optional)
- Migration 016 (Saved Searches)
- Vollständige Tabellenstruktur
- Indizes
- Constraints & Business Rules
- Trigger-Funktionen
---
## 3. Ursprüngliche Anforderungen
### 3.1 User-Anforderungen (Initial Message)
**Quelle:** Erste User-Message in Session
**Kern-Anforderungen:**
1. **Formatierte Ausgabe:** Übungen mit Embedded Videos, Zeichnungen, optimiert für verschiedene Use Cases
2. **Varianten:** Mehrere Varianten mit progressiven Schwierigkeitsstufen
3. **Exercise Series:** Progression durch Varianten derselben Übung
4. **Exercise Blocks:** Sammlungen verschiedener Übungen
5. **Multi-dimensionale Kategorisierung:** Stil, Fokus, Zielgruppe, Fähigkeiten-Matrix mit Levels
6. **Trainingsplan-Integration:** Muss mit Trainingsplan-System integrieren
7. **MediaWiki-Import:** Direct API Integration (NICHT export-basiert)
8. **Robustheit:** Kein ständiger Drift, skalierbar, umfassend
**Spec-First Approach gewählt:** Alle Specs VOR Implementation
### 3.2 User-Ergänzungen (Follow-up)
**Quelle:** Zweite User-Message mit 5 Klarstellungen
**Präzisierungen:**
1. **Priorisierung OK:** Spec-First Ansatz bestätigt
2. **Web + Mobile Priorität (NICHT Print/PDF):**
- Kurze vs. detaillierte Beschreibungen für Trainingspläne
3. **Media-Strategie:** Lokaler Upload + YouTube/Instagram Embeds
4. **Exercise Series vs. Blocks Klarstellung:**
- **Series:** Progressive Varianten derselben Übung (via Variant-Metadata)
- **Blocks:** Gruppierung VERSCHIEDENER Übungen (neue Entity)
- **Template Blocks:** Blocks mit Platzhaltern (gefüllt beim Planen)
5. **MediaWiki Import:** Via Direct API (NICHT Export)
### 3.3 Referenz-Dokumente
**Quelle:** Zu lesende Dokumente vor Spec-Erstellung
1. **shinkan_anforderungsdokument_entwurf.md** (1425 Zeilen)
- Fachliche Anforderungen
- Reifegradmodelle
- Übungen & Methoden
- Trainingsplanung
- MVP Scope: Trainer-fokussiert
2. **DOMAIN_MODEL.md** (Version 0.3.4)
- Hierarchische Struktur: Fokusbereich → Stil → Zielgruppen (M:N)
- Primary/Secondary Pattern für M:N
- Migration 009 deployed (Zielgruppen M:N Refactoring)
3. **DATABASE_SCHEMA.md** (Version 0.3.4)
- Existierende M:N Tabellen
- Migrations 001-013 deployed
4. **backend/routers/exercises.py** (454 Zeilen)
- Existierendes CRUD
- Enrichment-Pattern für M:N Relations
- Filter-Support
5. **backend/migrations/005_exercises.sql**
- Base exercises table
- exercise_skills, exercise_variants, exercise_media
6. **backend/migrations/008_mn_exercise_relations.sql**
- M:N Migration (focus_areas, styles, target_groups, age_groups)
7. **frontend/src/pages/ExercisesPage.jsx** (609 Zeilen)
- Existierende UI (Grid, Filters, Modal)
- Limitation: Nutzt M:N Backend-Daten NICHT
---
## 4. Review-Kriterien
### 4.1 Vollständigkeit (Completeness)
**Prüfe gegen ursprüngliche Anforderungen:**
- [ ] **Formatierte Ausgabe:** Sind Embedded Videos, Zeichnungen, verschiedene Use Cases abgedeckt?
- [ ] **Varianten mit Progression:** Sind progressive Schwierigkeitsstufen spezifiziert?
- [ ] **Exercise Series:** Ist Progression durch Varianten klar definiert (OHNE separate Entity)?
- [ ] **Exercise Blocks:** Ist Gruppierung verschiedener Übungen spezifiziert?
- [ ] **Template Blocks:** Sind Platzhalter-Blocks für Planung definiert?
- [ ] **Multi-dimensionale Kategorisierung:** Sind Stil, Fokus, Zielgruppe, Skills mit Levels abgedeckt?
- [ ] **Trainingsplan-Integration:** Ist Integration mit Training Plan System spezifiziert?
- [ ] **MediaWiki-Import:** Ist Direct API Integration (NICHT Export) spezifiziert?
- [ ] **Kurz vs. Detail:** Sind verschiedene Beschreibungslängen für Trainingspläne definiert?
### 4.2 Konsistenz (Consistency)
**Prüfe gegen Referenz-Dokumente:**
- [ ] **DOMAIN_MODEL.md:** Folgen Specs der hierarchischen Struktur (Fokusbereich → Stil → Zielgruppen)?
- [ ] **M:N Pattern:** Wird `is_primary` Flag korrekt genutzt?
- [ ] **DATABASE_SCHEMA.md:** Bauen Migrationen 014-016 auf 001-013 auf?
- [ ] **Existierender Code:** Sind Specs kompatibel mit `backend/routers/exercises.py`?
- [ ] **Migration-Pattern:** Folgen neue Migrationen dem idempotenten DO $$ Pattern?
### 4.3 Architektonische Korrektheit (No Drift)
**Prüfe gegen User-Ergänzungen:**
- [ ] **Series vs. Blocks:** Ist die Unterscheidung klar und korrekt umgesetzt?
- Series = Progression durch Varianten (Metadata in `exercise_variants`)
- Blocks = Neue Entity für Gruppierung verschiedener Übungen
- [ ] **Media-Strategie:** Hybrid (Lokal + Embeds) korrekt spezifiziert?
- [ ] **Keine Cloud-Only Annahme:** Lokaler Upload vorhanden?
- [ ] **Direct API statt Export:** MediaWiki-Import via API spezifiziert?
### 4.4 Technische Machbarkeit (Feasibility)
- [ ] **Performance:** Sind Performance-Targets realistisch (<50ms List, <150ms Search)?
- [ ] **Skalierbarkeit:** DB-Schema skaliert auf 5.000+ Übungen?
- [ ] **Security:** File-Upload-Validierung mit python-magic (nicht nur Extension)?
- [ ] **UX:** Mobile-Optimierung (Filter-Drawer, Sticky Search, Bottom-Nav) vorhanden?
### 4.5 Gaps & Fehlende Features
**Prüfe auf vergessene Features:**
- [ ] **Trainingscharakter (M:N):** Ist `exercise_training_characters` spezifiziert?
- [ ] **Fähigkeiten-Levels:** Sind `required_level` und `target_level` in `exercise_skills` definiert?
- [ ] **Visibility-Workflow:** Ist `private → club → official` Status-Flow spezifiziert?
- [ ] **Permissions:** Sind Owner-Checks (nur Ersteller darf bearbeiten) definiert?
- [ ] **Pagination:** Ist Pagination in List-Endpoints spezifiziert?
- [ ] **Reordering:** Ist Drag & Drop Reordering für Variants + Media definiert?
---
## 5. Review-Prozess
### 5.1 Schritt-für-Schritt Anleitung
**Schritt 1: Dokumente lesen**
1. Lies alle 7 Core-Specs vollständig durch
2. Mache dir Notizen zu Auffälligkeiten
3. Markiere unklare oder widersprüchliche Stellen
**Schritt 2: Checkliste durchgehen**
1. Gehe durch Abschnitt 4.1 (Vollständigkeit) - jede Anforderung einzeln prüfen
2. Gehe durch Abschnitt 4.2 (Konsistenz) - gegen Referenz-Docs prüfen
3. Gehe durch Abschnitt 4.3 (Architektur) - gegen User-Ergänzungen prüfen
4. Gehe durch Abschnitt 4.4 (Machbarkeit) - technische Risiken bewerten
5. Gehe durch Abschnitt 4.5 (Gaps) - fehlende Features identifizieren
**Schritt 3: Cross-Check zwischen Specs**
1. ARCHITECTURE.md ↔ API_SPEC.md: Sind Decisions in API umgesetzt?
2. API_SPEC.md ↔ DATABASE_FINAL.md: Matchen Endpoints zur DB-Struktur?
3. UI_COMPONENTS_SPEC.md ↔ FRONTEND_ROUTING.md: Passen Komponenten zu Routes?
4. MEDIA_UPLOAD_SPEC.md ↔ DATABASE_FINAL.md: Passt `exercise_media` Tabelle?
5. SEARCH_FILTER_SPEC.md ↔ DATABASE_FINAL.md: Ist `search_vector` definiert?
**Schritt 4: Gap-Analyse**
1. Erstelle Liste aller **fehlenden** Anforderungen
2. Erstelle Liste aller **widersprüchlichen** Definitionen
3. Erstelle Liste aller **unklaren** Spezifikationen
4. Erstelle Liste aller **technischen Risiken**
**Schritt 5: Review-Report erstellen**
1. Folge Template in Abschnitt 6
2. Fülle alle Abschnitte vollständig aus
3. Sei konkret: Datei + Zeile + Problem + Lösungsvorschlag
---
## 6. Review-Report Template
**Nutze dieses Template für deinen Review-Report:**
```markdown
# Exercise System Specs - Review Report
**Reviewer:** [Dein Agent-Name]
**Review-Datum:** [Datum]
**Review-Dauer:** [ca. X Minuten]
**Gesamtbewertung:** [APPROVED / APPROVED WITH CHANGES / REJECTED]
---
## 1. Executive Summary
[2-3 Sätze: Sind die Specs bereit für Implementation? Größte Probleme?]
---
## 2. Vollständigkeit (Completeness)
### 2.1 Erfüllte Anforderungen ✅
- [x] Formatierte Ausgabe: Abgedeckt in MEDIA_UPLOAD_SPEC.md (Abschnitt 4)
- [x] Varianten mit Progression: Abgedeckt in EXERCISES_DATABASE_FINAL.md (Migration 014)
- [... weitere ...]
### 2.2 Fehlende Anforderungen ❌
| Anforderung | Wo fehlt es? | Kritikalität | Lösungsvorschlag |
|-------------|--------------|--------------|------------------|
| Exercise Blocks Entity | EXERCISES_ARCHITECTURE.md erwähnt, aber nicht in DATABASE_FINAL.md | HIGH | Migration 017 für `exercise_blocks` Tabelle hinzufügen |
| Template Blocks Platzhalter | Nirgendwo spezifiziert | MEDIUM | Erweitere `exercise_blocks` um `is_template` + `placeholders` JSONB |
| ... | ... | ... | ... |
---
## 3. Konsistenz (Consistency)
### 3.1 Inkonsistenzen zwischen Specs
| Problem | Betroffene Dateien | Impact | Fix |
|---------|-------------------|--------|-----|
| API_SPEC.md definiert `age_groups_catalog` als JSONB, aber DOMAIN_MODEL.md sagt M:N | API_SPEC.md Zeile 104, DOMAIN_MODEL.md Abschnitt 3 | MEDIUM | Entscheide: JSONB ODER M:N (empfohlen: M:N für Konsistenz) |
| ... | ... | ... | ... |
### 3.2 Inkonsistenzen mit Referenz-Dokumenten
| Problem | Spec-Datei | Referenz-Dokument | Fix |
|---------|-----------|-------------------|-----|
| DOMAIN_MODEL.md sagt Zielgruppen sind global (M:N), aber API_SPEC.md zeigt `target_group_id` als FK | API_SPEC.md Zeile 267 | DOMAIN_MODEL.md Abschnitt 3.3 | Korrigiere API_SPEC zu M:N Array |
| ... | ... | ... | ... |
---
## 4. Architektur-Drift
### 4.1 Abweichungen von User-Ergänzungen
| User-Anforderung | Spec-Umsetzung | Konform? | Problem / Fix |
|-----------------|----------------|----------|---------------|
| "Series = Varianten-Progression OHNE separate Entity" | EXERCISES_ARCHITECTURE.md Abschnitt 2.2 definiert Series korrekt als Metadata | ✅ JA | - |
| "Blocks = neue Entity für verschiedene Übungen" | EXERCISES_ARCHITECTURE.md erwähnt, DATABASE_FINAL.md fehlt Migration | ❌ NEIN | Migration 017 fehlt |
| "Direct API statt Export" | Nirgendwo spezifiziert | ❌ NEIN | Neue Spec MEDIAWIKI_IMPORT_SPEC.md nötig |
| ... | ... | ... | ... |
---
## 5. Technische Risiken
| Risiko | Betroffene Spec | Wahrscheinlichkeit | Impact | Mitigation |
|--------|----------------|-------------------|--------|------------|
| tsvector auf großen Texten langsam | SEARCH_FILTER_SPEC.md | MEDIUM | HIGH | Teste Performance mit 10.000 Zeichen Execution-Text |
| 50MB File-Upload blockiert Server | MEDIA_UPLOAD_SPEC.md | HIGH | MEDIUM | Chunk-Upload oder separater Upload-Worker |
| pgvector Extension nicht verfügbar | DATABASE_FINAL.md Migration 015 | LOW | LOW | Migration 015 ist optional (Phase 2) |
| ... | ... | ... | ... | ... |
---
## 6. Gap-Analyse
### 6.1 Komplett fehlende Features
1. **Exercise Blocks Implementation**
- Wo benötigt: DATABASE_FINAL.md, API_SPEC.md, UI_COMPONENTS_SPEC.md
- Kritikalität: HIGH (User-Anforderung #4)
- Aufwand: 1 Migration + 3 Endpoints + 2 Komponenten
2. **Template Blocks mit Platzhaltern**
- Wo benötigt: DATABASE_FINAL.md, API_SPEC.md
- Kritikalität: MEDIUM (User-Anforderung #4 Teil 2)
- Aufwand: Erweitere Blocks-Tabelle + 1 Endpoint
3. **MediaWiki Direct API Import**
- Wo benötigt: Neue Spec MEDIAWIKI_IMPORT_SPEC.md
- Kritikalität: HIGH (User-Anforderung #7)
- Aufwand: Neue Spec + Backend-Integration
4. [... weitere ...]
### 6.2 Unvollständig spezifizierte Features
1. **Trainingsplan-Integration**
- Status: Erwähnt in ARCHITECTURE.md, aber keine konkreten Endpoints
- Fehlende Details: Wie werden Übungen in Pläne eingefügt? API-Struktur?
- Fix: Erweitere API_SPEC.md um `/training-plans/{id}/exercises` Endpoints
2. [... weitere ...]
---
## 7. Kritische Findings (Must-Fix vor Implementation)
### 🔴 BLOCKER (Implementation NICHT starten ohne Fix)
1. **Exercise Blocks fehlen komplett**
- Impact: Kern-Anforderung #4 nicht erfüllt
- Fix: Migration 017 + API-Endpoints + UI-Komponenten spezifizieren
2. **MediaWiki-Import nicht spezifiziert**
- Impact: Kern-Anforderung #7 nicht erfüllt
- Fix: Neue Spec MEDIAWIKI_IMPORT_SPEC.md erstellen
### 🟠 MAJOR (Fix vor Phase 1 Abschluss)
1. **age_groups: JSONB vs. M:N Inkonsistenz**
- Impact: Daten-Modell unklar, spätere Migration schwierig
- Fix: Entscheide für M:N (Konsistenz) oder JSONB (Einfachheit)
2. [... weitere ...]
### 🟡 MINOR (Fix in Phase 2 akzeptabel)
1. **Semantic Matching (Migration 015) optional**
- Impact: Nice-to-have Feature, kein MVP-Blocker
- Fix: Kein Fix nötig, als Phase 2 markieren
2. [... weitere ...]
---
## 8. Empfehlungen
### 8.1 Vor Implementation starten
1. **Fixe alle BLOCKER:** Exercise Blocks + MediaWiki-Import spezifizieren
2. **Kläre MAJOR Issues:** age_groups Inkonsistenz auflösen
3. **Ergänze Extended Specs:** VARIANTS_PROGRESSION_SPEC.md, EXERCISE_BLOCKS_SPEC.md, PERMISSIONS_VISIBILITY_SPEC.md
4. **User-Review:** Zeige Specs dem User, hole Feedback zu Blocks/Template-Blocks
### 8.2 Implementation-Reihenfolge (falls APPROVED)
**Phase 1A - Foundation:**
1. Migration 014 (Search Vector + Variant Progression)
2. Migration 016 (Saved Searches)
3. Backend: GET/POST/PUT/DELETE `/exercises` + `/exercises/{id}/variants`
4. Frontend: ExercisesListPage + ExerciseDetailPage (ohne Blocks)
**Phase 1B - Media:**
1. Backend: `/exercises/{id}/media` Endpoints
2. Frontend: MediaUploader + MediaGallery Komponenten
**Phase 1C - Search:**
1. Backend: Search + Filter in GET `/exercises`
2. Frontend: SearchFilterBar Komponente
**Phase 2 - Blocks (nach BLOCKER-Fix):**
1. Migration 017 (Exercise Blocks)
2. Backend: `/exercise-blocks` Endpoints
3. Frontend: BlockEditor Komponente
**Phase 3 - Import:**
1. MEDIAWIKI_IMPORT_SPEC.md erstellen
2. Backend: MediaWiki API Connector
3. Frontend: Import-UI
---
## 9. Positive Findings ✅
[Was ist gut gelaufen? Was ist besonders gut spezifiziert?]
1. **Sehr detaillierte API-Spec:** Alle Request/Response-Beispiele vorhanden
2. **Konsistentes M:N Pattern:** is_primary Flag durchgängig genutzt
3. **Performance-bewusst:** Indizes, Caching, Lazy Loading spezifiziert
4. **Accessibility:** ARIA, Keyboard-Nav, Skip-Links definiert
5. [... weitere ...]
---
## 10. Nächste Schritte
### Für Spec-Autor (Original Agent):
1. [ ] Fixe BLOCKER #1: Exercise Blocks spezifizieren
2. [ ] Fixe BLOCKER #2: MediaWiki-Import spezifizieren
3. [ ] Kläre MAJOR Issue: age_groups JSONB vs. M:N
4. [ ] Aktualisiere betroffene Specs (siehe Abschnitt 7)
### Für User:
1. [ ] Lese Executive Summary + Kritische Findings
2. [ ] Entscheide über Blocks/Template-Blocks Umsetzung
3. [ ] Priorisiere MediaWiki-Import (jetzt oder später?)
4. [ ] Gib Feedback zu age_groups Entscheidung
### Für Implementation:
- **WARTE** bis alle BLOCKER gefixt sind
- **DANACH:** Starte mit Phase 1A (Foundation)
---
**Review abgeschlossen:** [Datum/Uhrzeit]
**Nächster Review:** [Nach BLOCKER-Fixes]
```
---
## 7. Wichtige Hinweise für Reviewer
### 7.1 Sei konkret
**SCHLECHT:** "Die API-Spec ist inkonsistent"
**GUT:** "API_SPEC.md Zeile 267 zeigt `target_group_id` als FK, aber DOMAIN_MODEL.md Abschnitt 3.3 definiert Zielgruppen als M:N"
### 7.2 Priorisiere nach Impact
- **BLOCKER:** Verhindert Implementation-Start
- **MAJOR:** Muss vor Phase 1 Abschluss gefixt werden
- **MINOR:** Kann in Phase 2 gefixt werden
### 7.3 Schlage Lösungen vor
Nicht nur Probleme nennen, sondern auch:
- Konkrete Fix-Vorschläge
- Alternative Ansätze
- Trade-offs aufzeigen
### 7.4 Sei fair
- Lobe gute Spezifikationen
- Erkenne schwierige Trade-offs an
- Berücksichtige MVP-Scope (nicht alles muss perfekt sein)
---
## 8. Erfolgs-Kriterien für APPROVED
Specs sind **APPROVED**, wenn:
1. ✅ Alle User-Anforderungen (1-8) spezifiziert sind
2. ✅ Alle User-Ergänzungen (1-5) korrekt umgesetzt sind
3. ✅ Keine architektonischen Widersprüche existieren
4. ✅ Specs konsistent mit Referenz-Dokumenten sind
5. ✅ Keine BLOCKER-Issues offen sind
6. ✅ Technische Machbarkeit gegeben ist
7. ✅ Implementation-Reihenfolge klar definiert ist
Specs sind **APPROVED WITH CHANGES**, wenn:
- 1-2 MAJOR Issues existieren (aber klar, wie zu fixen)
- Mehrere MINOR Issues existieren
- Implementation kann starten, während Fixes parallel gemacht werden
Specs sind **REJECTED**, wenn:
- 3+ BLOCKER Issues existieren
- Fundamentale architektonische Probleme vorhanden
- Große Teile der Anforderungen fehlen
---
## 9. Beispiel-Review-Snippet
**So sollte ein gutes Review-Finding aussehen:**
```markdown
### 🔴 BLOCKER #1: Exercise Blocks fehlen in DB-Schema
**Problem:**
- EXERCISES_ARCHITECTURE.md Abschnitt 2.3 definiert Exercise Blocks als "Gruppierung verschiedener Übungen"
- EXERCISES_DATABASE_FINAL.md (Migration 014-016) enthält KEINE `exercise_blocks` Tabelle
- API_SPEC.md spezifiziert KEINE `/exercise-blocks` Endpoints
**Impact:**
- User-Anforderung #4 ("Exercise Blocks: Sammlungen verschiedener Übungen") NICHT erfüllt
- Trainingsplan-Integration (Anforderung #6) ohne Blocks schwierig
**Betroffene Dateien:**
- EXERCISES_DATABASE_FINAL.md (fehlt Migration 017)
- EXERCISES_API_SPEC.md (fehlt Abschnitt "Exercise Blocks")
- UI_COMPONENTS_SPEC.md (fehlt "BlockEditor" Komponente)
**Lösungsvorschlag:**
1. **Migration 017: Exercise Blocks**
```sql
CREATE TABLE exercise_blocks (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
is_template BOOLEAN DEFAULT FALSE,
placeholders JSONB, -- für Template-Blocks
created_by INT REFERENCES profiles(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE exercise_block_items (
id SERIAL PRIMARY KEY,
block_id INT REFERENCES exercise_blocks(id) ON DELETE CASCADE,
exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE,
sequence_order INT NOT NULL,
notes TEXT,
UNIQUE(block_id, sequence_order)
);
```
2. **API-Endpoints ergänzen:**
- `GET /exercise-blocks` - List blocks
- `POST /exercise-blocks` - Create block
- `PUT /exercise-blocks/{id}` - Update block
- `POST /exercise-blocks/{id}/items` - Add exercise to block
- `PUT /exercise-blocks/{id}/items/reorder` - Reorder exercises
3. **UI-Komponente spezifizieren:**
- BlockEditor mit Drag & Drop für Exercise-Reihenfolge
- Template-Modus mit Platzhalter-UI
**Aufwand:** ~4-6h (1 Migration + 5 Endpoints + 1 Komponente spezifizieren)
**Kritikalität:** BLOCKER - Implementation kann ohne Blocks NICHT User-Anforderungen erfüllen
```
---
## 10. Review starten
**Bereit? Dann:**
1. Lies alle 7 Spec-Dokumente gründlich
2. Gehe die Checkliste durch (Abschnitt 4)
3. Erstelle deinen Review-Report (Template Abschnitt 6)
4. Sei konkret, fair und konstruktiv
5. Priorisiere nach Impact (BLOCKER > MAJOR > MINOR)
**Viel Erfolg beim Review! 🚀**
---
**Letzte Aktualisierung:** 2026-04-24
**Version:** 1.0
**Autor:** Claude Code (Spec-Ersteller)

View File

@ -0,0 +1,602 @@
# KI-Prompt-System Universelle Admin-Konfiguration
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT
**Autor:** Claude Code
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
---
## 1. Konzept
### 1.1 Ziel
Alle KI-Aufrufe in Shinkan sind durch **admin-konfigurierbare Prompt-Templates**
steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.
**Admins können:**
- Prompt-Texte anpassen (ohne Code-Änderung)
- Neue Prompt-Typen hinzufügen
- Prompts aktivieren/deaktivieren
- Prompts testen (Preview mit aufgelösten Platzhaltern)
- Platzhalter-Katalog einsehen
### 1.2 Anwendungsfälle in Shinkan
| Prompt-Slug | Verwendung |
|-------------|-----------|
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
| `wiki_import_cleanup` | Bereinigt importierten Wikitext in lesbares Deutsch |
### 1.3 Architektur (aus Mitai übernommen, vereinfacht)
```
Admin konfiguriert Prompt-Template in DB (ai_prompts)
KI-Aufruf: Backend lädt Template, löst {{Platzhalter}} auf
OpenRouter API → Antwort
Antwort wird dem Trainer als Vorschlag angeboten (nicht blind gespeichert)
```
---
## 2. Datenbank
### 2.1 Migration (020_ai_prompts.sql)
```sql
-- Migration 020: AI Prompt System (admin-konfigurierbar)
-- Basis: Mitai Jinkendo prompts system (vereinfacht für Shinkan MVP)
-- Autor: Claude Code
DO $$
BEGIN
-- ============================================================================
-- AI PROMPTS (Admin-verwaltete Prompt-Templates)
-- ============================================================================
CREATE TABLE IF NOT EXISTS ai_prompts (
id SERIAL PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE, -- z.B. 'exercise_summary'
display_name VARCHAR(200) NOT NULL,
description TEXT,
-- Template mit {{Platzhalter}}-Syntax
template TEXT NOT NULL,
-- Kategorie: welcher Bereich der App nutzt diesen Prompt?
category VARCHAR(50) DEFAULT 'exercise'
CHECK (category IN ('exercise', 'training', 'matrix', 'import', 'admin')),
-- Output-Format: was gibt die KI zurück?
output_format VARCHAR(10) DEFAULT 'text'
CHECK (output_format IN ('text', 'json')),
-- JSON Schema für Validierung des KI-Outputs (nur wenn output_format='json')
output_schema JSONB,
-- System-Default: kann auf Original zurückgesetzt werden
is_system_default BOOLEAN DEFAULT false,
default_template TEXT, -- Backup des originalen Templates
-- Admin-Controls
active BOOLEAN DEFAULT true,
sort_order INT DEFAULT 0,
-- Meta
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);
-- ============================================================================
-- TRIGGER
-- ============================================================================
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();
-- ============================================================================
-- SYSTEM-DEFAULT PROMPTS
-- ============================================================================
INSERT INTO ai_prompts (slug, display_name, description, template, category, output_format, is_system_default, default_template, sort_order) VALUES
-- 1. Exercise Summary
('exercise_summary',
'Übungs-Zusammenfassung',
'Generiert eine kurze Zusammenfassung (2-3 Sätze) einer Übung für Listen und Trainingspläne.',
$$Du bist Assistent für einen Kampfsport-Trainer.
Erstelle eine prägnante Zusammenfassung dieser Übung für die Anzeige in Listen und Trainingsplänen.
Die Zusammenfassung soll:
- 2-3 Sätze lang sein (maximal 200 Zeichen)
- Das Wesentliche: Was trainiert die Übung? Wie läuft sie ab?
- Sachlich und klar (keine Werbebotschaften)
- Auf Deutsch
Übung: {{exercise_title}}
Fokusbereich: {{exercise_focus_area}}
Ziel: {{exercise_goal}}
Durchführung: {{exercise_execution}}
Antworte NUR mit dem Zusammenfassungstext, ohne Anführungszeichen.$$,
'exercise', 'text', true,
$$Du bist Assistent für einen Kampfsport-Trainer.
Erstelle eine prägnante Zusammenfassung dieser Übung für die Anzeige in Listen und Trainingsplänen.
Die Zusammenfassung soll:
- 2-3 Sätze lang sein (maximal 200 Zeichen)
- Das Wesentliche: Was trainiert die Übung? Wie läuft sie ab?
- Sachlich und klar (keine Werbebotschaften)
- Auf Deutsch
Übung: {{exercise_title}}
Fokusbereich: {{exercise_focus_area}}
Ziel: {{exercise_goal}}
Durchführung: {{exercise_execution}}
Antworte NUR mit dem Zusammenfassungstext, ohne Anführungszeichen.$$,
1),
-- 2. Skill Suggestions
('exercise_skill_suggestions',
'Fähigkeiten-Empfehlungen',
'Empfiehlt passende Fähigkeiten + Stufen aus dem Katalog für eine Übung.',
$$Du bist Assistent für einen Kampfsport-Trainer.
Analysiere diese Übung und empfehle passende Fähigkeiten aus dem Katalog.
Übung: {{exercise_title}}
Fokusbereich: {{exercise_focus_area}}
Ziel: {{exercise_goal}}
Durchführung: {{exercise_execution}}
Verfügbare Fähigkeiten:
{{skills_catalog}}
Wähle maximal 5 passende Fähigkeiten. Für jede gib an:
- skill_id (aus der Liste)
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
- intensity: Trainingsintensität (niedrig|mittel|hoch)
- is_primary: true wenn Hauptfähigkeit
Antworte NUR als JSON-Array:
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
Wenn keine Fähigkeit passt, antworte mit [].$$,
'exercise', 'json', true, NULL, 2),
-- 3. Category Suggestions
('exercise_category_suggestions',
'Kategorie-Empfehlungen',
'Empfiehlt Fokusbereich, Stilrichtung und Zielgruppe für eine Übung.',
$$Du bist Assistent für einen Kampfsport-Trainer.
Ordne diese Übung in die Katalog-Struktur ein.
Übung: {{exercise_title}}
Beschreibung: {{exercise_goal}}
Durchführung: {{exercise_execution}}
Verfügbare Fokusbereiche: {{focus_areas_catalog}}
Verfügbare Stilrichtungen: {{style_directions_catalog}}
Verfügbare Zielgruppen: {{target_groups_catalog}}
Wähle die passendsten Zuordnungen. Antworte NUR als JSON:
{
"focus_areas": [{"id": 1, "is_primary": true}],
"style_directions": [{"id": 2, "is_primary": true}],
"target_groups": [{"id": 5, "is_primary": true}]
}$$,
'exercise', 'json', true, NULL, 3),
-- 4. Matrix Level Description
('model_skill_level_description',
'Fähigkeitsmatrix-Stufenbeschreibung',
'Generiert Beschreibungen für Stufen in der Fähigkeitsmatrix.',
$$Du bist Fachexperte für {{focus_area}} und erstellst Lernziel-Beschreibungen.
Modell: {{model_name}}
Fokusbereich: {{focus_area}}
Zielgruppe: {{target_group}}
Fähigkeit: {{skill_name}}
Stufenanzahl: {{level_count}}
Stufen: {{level_names}}
Beschreibe für JEDE Stufe konkret und beobachtbar, was ein Schüler auf dieser Stufe
der Fähigkeit "{{skill_name}}" können soll.
Antworte NUR als JSON-Array ({{level_count}} Einträge):
[
{"level_number": 1, "description": "...", "observable_criteria": "..."},
{"level_number": 2, "description": "...", "observable_criteria": "..."}
]$$,
'matrix', 'json', true, NULL, 4),
-- 5. Wiki Import Cleanup
('wiki_import_cleanup',
'Wiki-Text-Bereinigung',
'Bereinigt importierten Wikitext in lesbares, strukturiertes Deutsch.',
$$Bereinige folgenden Text aus einem Trainings-Wiki für die Darstellung in einer modernen App.
Original-Text:
{{wiki_raw_text}}
Regeln:
- Entferne alle Wiki-Markup-Syntax ([[Links]], {{Templates}}, ==Überschriften==)
- Behalte den fachlichen Inhalt vollständig
- Schreibe in klarem, professionellem Deutsch
- Strukturiere mit Absätzen (nicht mit Wiki-Markup)
- Maximal 1000 Zeichen
Antworte NUR mit dem bereinigten Text.$$,
'import', 'text', true, NULL, 5)
ON CONFLICT (slug) DO NOTHING;
RAISE NOTICE 'Migration 020 completed successfully (AI Prompt System)';
END $$;
```
---
## 3. Platzhalter-Katalog
### 3.1 Verfügbare Platzhalter
Alle `{{Platzhalter}}` werden vom Backend-Service `prompt_resolver.py` aufgelöst.
**Kontext: exercise** (für exercise_summary, skill_suggestions, category_suggestions)
| Platzhalter | Beschreibung | Beispielwert |
|-------------|--------------|--------------|
| `{{exercise_title}}` | Titel der Übung | "Maai - Distanzübung" |
| `{{exercise_goal}}` | Ziel (erste 500 Zeichen) | "Distanzgefühl entwickeln..." |
| `{{exercise_execution}}` | Durchführung (erste 500 Zeichen) | "1. Partnerwahl..." |
| `{{exercise_preparation}}` | Vorbereitung | "Matten auslegen..." |
| `{{exercise_focus_area}}` | Primärer Fokusbereich | "Karate" |
| `{{exercise_duration}}` | Dauer | "15-20 min" |
| `{{exercise_group_size}}` | Gruppengröße | "8-12 Personen" |
| `{{skills_catalog}}` | Liste aller Skills: "ID Name (Kategorie)" | "- ID 1: Dachi Waza (Kihon)\n..." |
| `{{focus_areas_catalog}}` | Liste aller Fokusbereiche | "- ID 1: Karate\n..." |
| `{{style_directions_catalog}}` | Liste aller Stilrichtungen | "- ID 1: Shotokan\n..." |
| `{{target_groups_catalog}}` | Liste aller Zielgruppen | "- ID 1: Breitensportler\n..." |
**Kontext: matrix** (für model_skill_level_description)
| Platzhalter | Beschreibung | Beispielwert |
|-------------|--------------|--------------|
| `{{model_name}}` | Name des Reifegradmodells | "Karate Shotokan Breitensport" |
| `{{focus_area}}` | Fokusbereich des Modells | "Karate" |
| `{{style_direction}}` | Stilrichtung | "Shotokan" |
| `{{target_group}}` | Zielgruppe | "Breitensportler" |
| `{{skill_name}}` | Fähigkeitsname | "Distanzgefühl" |
| `{{skill_description}}` | Fähigkeitsbeschreibung | "Kontrolle der Kampfdistanz..." |
| `{{level_count}}` | Anzahl der Stufen | "5" |
| `{{level_names}}` | Stufen-Namen kommagetrennt | "Einsteiger, Grundlagen, Aufbau, Fortgeschritten, Experte" |
**Kontext: import**
| Platzhalter | Beschreibung |
|-------------|--------------|
| `{{wiki_raw_text}}` | Roher Wikitext aus MediaWiki |
### 3.2 Platzhalter-Auflösung (Backend)
```python
# backend/prompt_resolver.py
class ExercisePromptContext:
"""Kontext für exercise-bezogene Prompts."""
def resolve(self, template: str, exercise_data: dict, db) -> str:
variables = {
"exercise_title": exercise_data.get("title", ""),
"exercise_goal": exercise_data.get("goal", "")[:500],
"exercise_execution": exercise_data.get("execution", "")[:500],
"exercise_preparation": exercise_data.get("preparation", ""),
"exercise_focus_area": self._get_primary_focus_area(exercise_data, db),
"exercise_duration": self._format_duration(exercise_data),
"exercise_group_size": self._format_group_size(exercise_data),
"skills_catalog": self._format_skills_catalog(db),
"focus_areas_catalog": self._format_catalog(db, "focus_areas"),
"style_directions_catalog":self._format_catalog(db, "style_directions"),
"target_groups_catalog": self._format_catalog(db, "target_groups"),
}
return self._replace_placeholders(template, variables)
```
### 3.3 Unbekannte Platzhalter
Wenn ein Platzhalter nicht aufgelöst werden kann:
- `{{unknown_key}}` → bleibt als `[NICHT VERFÜGBAR]` im Template
- Warnung im API-Response
- Kein Abbruch des KI-Aufrufs
---
## 4. API-Endpoints
### 4.1 Übersicht
| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| GET | `/admin/ai-prompts` | Liste aller Prompts (Admin) |
| GET | `/admin/ai-prompts/{id}` | Prompt-Detail |
| POST | `/admin/ai-prompts` | Neuen Prompt anlegen |
| PUT | `/admin/ai-prompts/{id}` | Prompt bearbeiten |
| DELETE | `/admin/ai-prompts/{id}` | Prompt löschen (nur custom, nicht system) |
| POST | `/admin/ai-prompts/{id}/reset` | Auf System-Default zurücksetzen |
| POST | `/admin/ai-prompts/{id}/preview` | Platzhalter auflösen ohne KI-Call |
| GET | `/admin/ai-prompts/placeholders` | Platzhalter-Katalog mit Beschreibungen |
| POST | `/admin/ai-prompts/test` | Prompt mit Beispiel-Daten testen (echter KI-Call) |
### 4.2 `GET /admin/ai-prompts`
**Response:** `200 OK`
```json
[
{
"id": 1,
"slug": "exercise_summary",
"display_name": "Übungs-Zusammenfassung",
"description": "Generiert eine kurze Zusammenfassung...",
"category": "exercise",
"output_format": "text",
"active": true,
"is_system_default": true,
"is_modified": false,
"sort_order": 1
}
]
```
`is_modified`: true wenn `template != default_template`
---
### 4.3 `PUT /admin/ai-prompts/{id}`
**Request Body:**
```json
{
"template": "Du bist ein erfahrener Karate-Trainer...\n{{exercise_title}}...",
"active": true,
"display_name": "Übungs-Zusammenfassung (angepasst)"
}
```
**Response:** `200 OK` (Prompt-Objekt)
**Constraints:**
- Nur `template`, `display_name`, `description`, `active`, `sort_order` änderbar
- `slug`, `output_format`, `category` nicht änderbar (würde Code brechen)
- System-Default-Backup bleibt immer erhalten
---
### 4.4 `POST /admin/ai-prompts/{id}/preview`
Löst Platzhalter auf und zeigt das finale Prompt **OHNE KI-Call**.
**Request Body:**
```json
{
"context": "exercise",
"example_data": {
"exercise_id": 42
}
}
```
**Response:** `200 OK`
```json
{
"resolved_template": "Du bist Assistent für einen Kampfsport-Trainer.\nÜbung: Maai - Distanzübung\n...",
"placeholders_resolved": ["exercise_title", "exercise_goal"],
"placeholders_missing": [],
"estimated_tokens": 320
}
```
---
### 4.5 `GET /admin/ai-prompts/placeholders`
Liefert den vollständigen Platzhalter-Katalog.
**Response:** `200 OK`
```json
{
"categories": {
"exercise": [
{
"key": "exercise_title",
"placeholder": "{{exercise_title}}",
"description": "Titel der Übung",
"example": "Maai - Distanzübung",
"required_context": "exercise_id oder title direkt"
}
],
"matrix": [...],
"import": [...]
}
}
```
---
## 5. Verwendung in Backend-Code
### 5.1 Standard-Pattern für KI-Aufrufe
```python
# backend/services/ai_service.py
async def run_ai_prompt(
slug: str,
context_data: dict,
db,
openrouter_key: str
) -> dict:
"""
Lädt Prompt aus DB, löst Platzhalter auf, ruft KI auf.
Wirft AiNotConfiguredError wenn key fehlt oder Prompt inaktiv.
"""
# 1. Prompt laden
prompt = db.fetchrow("SELECT * FROM ai_prompts WHERE slug=$1 AND active=true", slug)
if not prompt:
raise AiNotConfiguredError(f"Prompt '{slug}' nicht gefunden oder inaktiv")
# 2. Platzhalter auflösen
resolved = resolve_placeholders(
template=prompt["template"],
context=context_data,
db=db
)
# 3. KI-Call
response = await call_openrouter(
prompt=resolved,
model=settings.openrouter_model,
api_key=openrouter_key
)
# 4. JSON validieren (wenn output_format='json')
if prompt["output_format"] == "json":
response = parse_and_validate_json(response, prompt["output_schema"])
return {
"output": response,
"prompt_slug": slug,
"ai_generated": True,
"model": settings.openrouter_model
}
```
### 5.2 Aufruf in exercises Router
```python
# backend/routers/exercises.py
@router.post("/exercises/ai/suggest")
async def suggest_for_exercise(data: ExerciseSuggestRequest, session=Depends(require_auth)):
result = {}
# Summary
try:
summary = await ai_service.run_ai_prompt(
slug="exercise_summary",
context_data={"exercise": data.dict()},
db=db,
openrouter_key=settings.openrouter_api_key
)
result["summary"] = summary
except AiNotConfiguredError:
result["summary"] = None
# Skills
try:
skills = await ai_service.run_ai_prompt(
slug="exercise_skill_suggestions",
context_data={"exercise": data.dict()},
db=db,
openrouter_key=settings.openrouter_api_key
)
result["skills"] = skills
except AiNotConfiguredError:
result["skills"] = None
return result
```
---
## 6. Frontend: Admin-UI
### 6.1 Route
```
/admin/ai-prompts → AdminAiPromptsPage (Liste)
/admin/ai-prompts/{id} → AdminAiPromptDetailPage (Edit)
```
### 6.2 Layout (Prompt-Editor)
```
┌───────────────────────────────────────────────┐
│ ✨ KI-Prompts [+ Neu] │
│ ───────────────────────────────────────────── │
│ exercise_summary [Aktiv] [Bearbeiten] │
│ exercise_skill_suggestions [Aktiv] [Bearbeiten]│
│ model_skill_level_description [Aktiv] │
└───────────────────────────────────────────────┘
── Detailansicht ──────────────────────────────────
┌───────────────────────────────────────────────┐
│ Übungs-Zusammenfassung │
│ slug: exercise_summary · Kategorie: exercise │
│ ───────────────────────────────────────────── │
│ Template: │
│ ┌────────────────────────────────────────────┐ │
│ │ Du bist Assistent für einen Kampfsport- │ │
│ │ Trainer. ... │ │
│ │ {{exercise_title}} │ │ ← Syntaxhighlight
│ │ {{exercise_goal}} │ │
│ └────────────────────────────────────────────┘ │
│ [Verfügbare Platzhalter ▼] [Vorschau] │
│ ───────────────────────────────────────────── │
│ [Mit Beispiel testen] [Original wiederherstellen]│
│ [Speichern] │
└───────────────────────────────────────────────┘
```
**Syntax-Highlighting:** `{{placeholder}}` in Farbe hervorheben.
**Platzhalter-Panel:** Ausklappbare Liste aller verfügbaren Platzhalter (aus `/admin/ai-prompts/placeholders`).
---
## 7. Zusammenspiel KI_FEATURES_SPEC ↔ AI_PROMPT_SYSTEM_SPEC
Die `KI_FEATURES_SPEC.md` beschreibt die **User-Flows** (wann wird KI angeboten,
wie sieht die Bestätigung aus).
Die `AI_PROMPT_SYSTEM_SPEC.md` (diese Datei) beschreibt die **technische Basis**
(DB-Schema, Platzhalter-System, Admin-UI).
```
KI_FEATURES_SPEC: POST /exercises/ai/suggest
AI_PROMPT_SYSTEM_SPEC: ai_service.run_ai_prompt("exercise_summary", ...)
DB: ai_prompts WHERE slug='exercise_summary'
Template + {{Platzhalter}} auflösen
OpenRouter API
```
---
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT

View File

@ -0,0 +1,934 @@
# Exercises API Specification
**Version:** 1.2
**Datum:** 2026-04-24
**Status:** REVIEWED - Pending Implementation
**Autor:** Claude Code
**Ä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
---
## Base URL
```
Production: https://shinkan.jinkendo.de/api
Development: https://dev.shinkan.jinkendo.de/api
```
---
## Authentication
**Header:** `X-Auth-Token: <token>`
**Alle Endpoints** (außer `/auth/*`) erfordern Authentication.
---
## Endpoints Overview
| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| **Exercises** |
| GET | `/exercises` | List exercises mit Filtern |
| GET | `/exercises/{id}` | Exercise Detail mit Enrichment |
| POST | `/exercises` | Create exercise |
| PUT | `/exercises/{id}` | Update exercise |
| DELETE | `/exercises/{id}` | Delete exercise |
| **Variants** |
| POST | `/exercises/{id}/variants` | Create variant |
| PUT | `/exercises/{id}/variants/{variant_id}` | Update variant |
| DELETE | `/exercises/{id}/variants/{variant_id}` | Delete variant |
| PUT | `/exercises/{id}/variants/reorder` | Reorder variants (DnD) |
| **Media** |
| POST | `/exercises/{id}/media` | Upload/Embed media |
| PUT | `/exercises/{id}/media/{media_id}` | Update media metadata |
| DELETE | `/exercises/{id}/media/{media_id}` | Delete media |
| PUT | `/exercises/{id}/media/reorder` | Reorder media (DnD) |
| **KI-Assistenz** |
| POST | `/exercises/ai/suggest` | KI-Vorschläge (Summary + Skills) für neues Formular |
| POST | `/exercises/{id}/ai/regenerate` | KI-Vorschläge neu generieren für bestehende Übung |
| **Exercise Blocks** |
| GET | `/exercise-blocks` | List blocks (mit Filtern) |
| GET | `/exercise-blocks/{id}` | Block Detail mit Items |
| POST | `/exercise-blocks` | Create block |
| PUT | `/exercise-blocks/{id}` | Update block |
| DELETE | `/exercise-blocks/{id}` | Delete block |
| POST | `/exercise-blocks/{id}/items` | Add item to block |
| PUT | `/exercise-blocks/{id}/items/{item_id}` | Update item |
| DELETE | `/exercise-blocks/{id}/items/{item_id}` | Remove item |
| PUT | `/exercise-blocks/{id}/items/reorder` | Reorder items (DnD) |
---
## Exercises
### `GET /exercises`
**Query Parameters:**
- `focus_area` (int, optional) - Focus Area ID
- `visibility` (enum, optional) - `private | club | official`
- `status` (enum, optional) - `draft | in_review | approved | archived`
- `skill_id` (int, optional) - Skill ID
- `search` (string, optional) - Volltext (title, summary, execution)
- `limit` (int, optional, default: 50, max: 100)
- `offset` (int, optional, default: 0)
**Response:** `200 OK`
```json
[
{
"id": 1,
"title": "Maai - Distanzübung",
"summary": "Distanzgefühl entwickeln...",
"focus_area": "karate",
"visibility": "club",
"status": "approved",
"created_by": 1,
"creator_name": "Lars",
"club_id": 1,
"club_name": "Dojo Berlin",
"created_at": "2026-04-20T10:00:00Z",
"updated_at": "2026-04-22T14:30:00Z"
}
]
```
**Errors:**
- `401` - Unauthorized
- `403` - Forbidden
---
### `GET /exercises/{id}`
**Path Parameters:**
- `id` (int, required)
**Response:** `200 OK`
```json
{
"id": 1,
"title": "Maai - Distanzübung",
"summary": "Kurzbeschreibung...",
"goal": "Distanzgefühl entwickeln durch Partnerübungen...",
"execution": "1. Partnerwahl\n2. Ausgangsstellung Zenkutsu Dachi...",
"preparation": "Matten auslegen, Pratzen bereitlegen",
"trainer_notes": "Auf korrekten Abstand achten!",
"equipment": ["Matten", "Pratzen"],
"duration_min": 15,
"duration_max": 20,
"group_size_min": 8,
"group_size_max": 12,
"focus_areas": [
{
"id": 1,
"focus_area_id": 1,
"name": "Karate",
"abbreviation": "KAR",
"color": "#E63946",
"icon": "🥋",
"is_primary": true
}
],
"training_styles": [
{
"id": 1,
"training_style_id": 2,
"name": "Shotokan",
"abbreviation": "SKA",
"is_primary": true
},
{
"id": 2,
"training_style_id": 3,
"name": "Goju-Ryu",
"abbreviation": "GJR",
"is_primary": false
}
],
"target_groups": [
{
"id": 1,
"target_group_id": 5,
"name": "Breitensportler",
"description": "Karate für Freizeit und Fitness",
"is_primary": true
}
],
"age_groups": ["Kinder", "Teenager"],
"skills": [
{
"id": 1,
"skill_id": 10,
"skill_name": "Distanzgefühl",
"skill_category": "Kumite",
"is_primary": true,
"intensity": "hoch",
"required_level": "grundlagen",
"target_level": "aufbau",
"ai_suggested": false
}
],
"variants": [
{
"id": 1,
"variant_name": "Ohne Partner",
"description": "Solo-Variante mit Schattenboxen",
"execution_changes": "Stelle dir einen imaginären Partner vor...",
"duration_min": 10,
"duration_max": 15,
"equipment_changes": [],
"difficulty_adjustment": "easier",
"progression_level": 1,
"sequence_order": 1,
"prerequisite_variant_id": null
},
{
"id": 2,
"variant_name": "Mit Pratzen",
"description": "Fortgeschrittene Variante mit Pratzen-Training",
"execution_changes": "Partner hält Pratzen, Ausführung wie Haupt...",
"duration_min": 15,
"duration_max": 25,
"equipment_changes": ["+ Pratzen"],
"difficulty_adjustment": "harder",
"progression_level": 3,
"sequence_order": 3,
"prerequisite_variant_id": 1
}
],
"media": [
{
"id": 1,
"media_type": "video",
"file_path": "/media/exercises/a1b2c3d4_demo.mp4",
"file_size": 5242880,
"mime_type": "video/mp4",
"original_filename": "demo.mp4",
"embed_url": null,
"embed_platform": null,
"title": "Durchführung Demo",
"description": "Zeigt korrekten Abstand",
"sort_order": 1,
"is_primary": true,
"context": "ablauf"
},
{
"id": 2,
"media_type": "video",
"file_path": null,
"file_size": null,
"mime_type": null,
"original_filename": null,
"embed_url": "https://www.youtube.com/watch?v=abc123",
"embed_platform": "youtube",
"title": "Erweiterte Techniken",
"description": "YouTube-Tutorial von Sensei XY",
"sort_order": 2,
"is_primary": false,
"context": "detail"
}
],
"visibility": "club",
"status": "approved",
"created_by": 1,
"creator_name": "Lars",
"club_id": 1,
"club_name": "Dojo Berlin",
"import_source": null,
"import_id": null,
"created_at": "2026-04-20T10:00:00Z",
"updated_at": "2026-04-22T14:30:00Z"
}
```
**Errors:**
- `401` - Unauthorized
- `403` - Forbidden (private + not owner)
- `404` - Not found
---
### `POST /exercises`
**Request Body:**
```json
{
"title": "Neue Übung",
"summary": "Kurzbeschreibung...",
"goal": "Ziel der Übung...",
"execution": "Durchführung Schritt für Schritt...",
"preparation": "Aufbau und Vorbereitung...",
"trainer_notes": "Hinweise für Trainer...",
"equipment": ["Matten", "Pratzen"],
"duration_min": 15,
"duration_max": 20,
"group_size_min": 8,
"group_size_max": 12,
"focus_areas_multi": [
{"focus_area_id": 1, "is_primary": true},
{"focus_area_id": 2, "is_primary": false}
],
"training_styles_multi": [
{"training_style_id": 2, "is_primary": true}
],
"target_groups_multi": [
{"target_group_id": 5, "is_primary": true}
],
"age_groups": ["Kinder", "Teenager"],
"skills": [
{
"skill_id": 10,
"is_primary": true,
"intensity": "hoch",
"required_level": "grundlagen",
"target_level": "aufbau"
}
],
"visibility": "private",
"status": "draft",
"club_id": 1
}
```
**Required Fields:**
- `title` (3-300 chars)
- `goal` (10-5000 chars)
- `execution` (10-10000 chars)
**Response:** `201 Created` (full exercise object wie GET)
**Errors:**
- `400` - Bad Request (validation error)
- `401` - Unauthorized
- `403` - Forbidden
---
### `PUT /exercises/{id}`
**Request Body:** Same as POST (all fields optional except id)
**Response:** `200 OK` (full exercise object)
**Errors:**
- `400` - Bad Request
- `401` - Unauthorized
- `403` - Forbidden (not owner)
- `404` - Not found
---
### `DELETE /exercises/{id}`
**Response:** `200 OK`
```json
{"ok": true}
```
**Errors:**
- `401` - Unauthorized
- `403` - Forbidden (not owner or admin)
- `404` - Not found
- `409` - Conflict (used in training units)
---
## Variants
### `POST /exercises/{id}/variants`
**Request Body:**
```json
{
"variant_name": "Mit Pratzen",
"description": "Fortgeschrittene Variante...",
"execution_changes": "Statt freier Distanz mit Pratzen...",
"duration_min": 15,
"duration_max": 25,
"equipment_changes": ["+ Pratzen"],
"difficulty_adjustment": "harder",
"progression_level": 3,
"sequence_order": 3,
"prerequisite_variant_id": 1
}
```
**Required Fields:**
- `variant_name` (3-200 chars)
**Response:** `201 Created`
```json
{
"id": 2,
"exercise_id": 1,
"variant_name": "Mit Pratzen",
"description": "...",
"progression_level": 3,
"sequence_order": 3,
"prerequisite_variant_id": 1,
"created_at": "2026-04-24T10:00:00Z"
}
```
**Errors:**
- `400` - Bad Request
- `401` - Unauthorized
- `403` - Forbidden (not owner)
- `404` - Exercise not found
- `409` - Conflict (prerequisite_variant_id nicht zur gleichen Übung)
---
### `PUT /exercises/{id}/variants/{variant_id}`
**Request Body:** Same as POST
**Response:** `200 OK` (variant object)
---
### `DELETE /exercises/{id}/variants/{variant_id}`
**Response:** `200 OK`
```json
{"ok": true}
```
**Errors:**
- `409` - Conflict (andere Varianten referenzieren diese als Prerequisite)
---
### `PUT /exercises/{id}/variants/reorder`
**Request Body:**
```json
{
"variant_ids": [3, 1, 2]
}
```
**Response:** `200 OK`
```json
{"ok": true, "reordered": 3}
```
**Errors:**
- `400` - variant_ids nicht vollständig oder falsche Exercise
---
## Media
### `POST /exercises/{id}/media`
**Content-Type:** `multipart/form-data`
**Form Fields:**
- `file` (File, optional) - Image/Video file
- `embed_url` (string, optional) - YouTube/Instagram/Vimeo URL
- `media_type` (enum, required) - `image | video | document | sketch`
- `title` (string, optional)
- `description` (string, optional)
- `context` (enum, optional) - `ablauf | detail | trainer_hint`
**Entweder `file` ODER `embed_url` (nicht beides).**
**Response:** `201 Created`
```json
{
"id": 5,
"exercise_id": 1,
"media_type": "video",
"file_path": "/media/exercises/a1b2c3d4_demo.mp4",
"file_size": 5242880,
"mime_type": "video/mp4",
"original_filename": "demo.mp4",
"embed_url": null,
"embed_platform": null,
"title": "Demo",
"description": null,
"sort_order": 3,
"is_primary": false,
"context": "ablauf",
"created_at": "2026-04-24T10:00:00Z"
}
```
**Errors:**
- `400` - Bad Request (invalid file type, missing file/embed_url)
- `401` - Unauthorized
- `403` - Forbidden
- `413` - File too large (> 50MB)
**Supported Formats:**
- **Images:** `image/jpeg`, `image/png`, `image/gif`
- **Videos:** `video/mp4`
- **Documents:** `application/pdf`
- **Embeds:** `youtube.com`, `youtu.be`, `instagram.com`, `vimeo.com`
---
### `PUT /exercises/{id}/media/{media_id}`
**Request Body:**
```json
{
"title": "Neuer Titel",
"description": "Neue Beschreibung",
"is_primary": true,
"context": "detail"
}
```
**Response:** `200 OK` (media object)
---
### `DELETE /exercises/{id}/media/{media_id}`
**Response:** `200 OK`
```json
{"ok": true}
```
**Deletes:** DB-Eintrag + Datei (wenn `file_path` gesetzt)
---
### `PUT /exercises/{id}/media/reorder`
**Request Body:**
```json
{
"media_ids": [2, 1, 3]
}
```
**Response:** `200 OK`
```json
{"ok": true, "reordered": 3}
```
---
## KI-Assistenz
### `POST /exercises/ai/suggest`
Generiert KI-Vorschläge für eine **noch nicht gespeicherte** Übung.
Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
**Request Body:**
```json
{
"title": "Maai - Distanzübung",
"goal": "Distanzgefühl entwickeln...",
"execution": "1. Partnerwahl\n2. Ausgangsstellung..."
}
```
**Mindestanforderung:** `goal` oder `execution` muss vorhanden sein (min. 50 Zeichen)
**Response:** `200 OK`
```json
{
"summary": {
"text": "Partnerübung zur Entwicklung des Distanzgefühls. Trainiert räumliche Wahrnehmung und reaktives Verhalten.",
"ai_generated": true,
"model": "anthropic/claude-sonnet-4"
},
"skills": [
{
"skill_id": 10,
"skill_name": "Distanzgefühl",
"skill_category": "Kumite",
"required_level": "grundlagen",
"target_level": "aufbau",
"intensity": "hoch",
"is_primary": true,
"confidence": 0.92
},
{
"skill_id": 15,
"skill_name": "Reaktionsschnelligkeit",
"skill_category": "Athletik",
"required_level": "einsteiger",
"target_level": "grundlagen",
"intensity": "mittel",
"is_primary": false,
"confidence": 0.74
}
]
}
```
**Errors:**
- `400` - Zu wenig Text (< 50 Zeichen)
- `503` - KI nicht verfügbar (OPENROUTER_API_KEY nicht konfiguriert)
---
### `POST /exercises/{id}/ai/regenerate`
Generiert Vorschläge für eine **bestehende** Übung neu.
**Request Body:**
```json
{
"regenerate": ["summary", "skills"]
}
```
**Response:** `200 OK` (gleiche Struktur wie `/ai/suggest`)
**Hinweis:** Gibt nur Vorschläge zurück nichts wird automatisch gespeichert.
Trainer muss im Frontend aktiv übernehmen.
---
## Permissions
### Sichtbarkeits-Workflow
| Von → Nach | Wer darf das? |
|-----------|---------------|
| `draft → in_review` | Ersteller, Club-Admin |
| `in_review → approved` | Club-Admin, Super-Admin |
| `approved → archived` | Club-Admin, Super-Admin |
| `* → draft` | Super-Admin |
### Sichtbarkeit (`visibility`)
| Änderung | Wer darf das? |
|----------|---------------|
| `private → club` | Ersteller, Club-Admin |
| `club → official` | Club-Admin, Super-Admin |
| `official → club` | Super-Admin |
### Owner-Checks
- **Bearbeiten** (PUT): Nur Ersteller oder Club-Admin
- **Löschen** (DELETE): Nur Ersteller oder Super-Admin
- **Lesen** (`private`): Nur Ersteller
**403 Fehler-Beispiel:**
```json
{"detail": "Keine Berechtigung. Nur der Ersteller oder ein Admin kann diese Übung bearbeiten."}
```
---
## Exercise Blocks
### `GET /exercise-blocks`
**Query Parameters:**
- `is_template` (bool, optional) - nur Templates oder nur reguläre Blocks
- `visibility` (enum, optional) - `private | club | official`
- `club_id` (int, optional) - Filter nach Verein
- `search` (string, optional) - Suche in name
- `limit` (int, optional, default: 50)
- `offset` (int, optional, default: 0)
**Response:** `200 OK`
```json
[
{
"id": 1,
"name": "Aufwärmblock Kumite",
"description": "Standard-Aufwärmprogramm für Kumite",
"is_template": false,
"item_count": 4,
"club_id": 1,
"club_name": "Dojo Berlin",
"created_by": 1,
"creator_name": "Lars",
"visibility": "club",
"created_at": "2026-04-20T10:00:00Z"
}
]
```
---
### `GET /exercise-blocks/{id}`
**Response:** `200 OK`
```json
{
"id": 1,
"name": "Aufwärmblock Kumite",
"description": "Standard-Aufwärmprogramm für Kumite",
"goal": "Sportler auf Kumite-Training vorbereiten",
"is_template": false,
"club_id": 1,
"club_name": "Dojo Berlin",
"created_by": 1,
"creator_name": "Lars",
"visibility": "club",
"items": [
{
"id": 1,
"sequence_order": 1,
"is_placeholder": false,
"exercise_id": 5,
"exercise_title": "Maai - Distanzübung",
"exercise_summary": "Distanzgefühl entwickeln...",
"variant_id": null,
"variant_name": null,
"notes": "Leicht anfangen, nur 5min",
"placeholder_criteria": null,
"placeholder_label": null
},
{
"id": 2,
"sequence_order": 2,
"is_placeholder": true,
"exercise_id": null,
"exercise_title": null,
"variant_id": null,
"notes": "Hier eine Schlag-Übung einsetzen",
"placeholder_criteria": {"focus_area_id": 1, "max_duration": 10},
"placeholder_label": "Schlag-Übung (max. 10 min)"
}
],
"created_at": "2026-04-20T10:00:00Z",
"updated_at": "2026-04-22T14:30:00Z"
}
```
**Errors:**
- `403` - Forbidden (private Block, nicht Ersteller)
- `404` - Not found
---
### `POST /exercise-blocks`
**Request Body:**
```json
{
"name": "Aufwärmblock Kumite",
"description": "Standard-Aufwärmprogramm",
"goal": "Sportler vorbereiten",
"is_template": false,
"visibility": "private",
"club_id": 1
}
```
**Required Fields:**
- `name` (3-200 chars)
**Response:** `201 Created` (full block object wie GET)
---
### `PUT /exercise-blocks/{id}`
**Request Body:** Same as POST (all fields optional)
**Response:** `200 OK` (full block object)
**Errors:**
- `403` - Forbidden (nicht Ersteller/Admin)
- `404` - Not found
---
### `DELETE /exercise-blocks/{id}`
**Response:** `200 OK`
```json
{"ok": true}
```
**Errors:**
- `403` - Forbidden
- `404` - Not found
- `409` - Conflict (Block in Trainingsplan verwendet)
---
### `POST /exercise-blocks/{id}/items`
**Request Body (konkretes Exercise):**
```json
{
"exercise_id": 5,
"variant_id": null,
"sequence_order": 3,
"notes": "Leicht anfangen"
}
```
**Request Body (Platzhalter):**
```json
{
"is_placeholder": true,
"placeholder_label": "Schlag-Übung (max. 10 min)",
"placeholder_criteria": {
"focus_area_id": 1,
"max_duration": 10
},
"sequence_order": 4,
"notes": "Variabel je nach Gruppe"
}
```
**Response:** `201 Created`
```json
{
"id": 5,
"block_id": 1,
"sequence_order": 3,
"is_placeholder": false,
"exercise_id": 5,
"exercise_title": "Maai - Distanzübung",
"variant_id": null,
"notes": "Leicht anfangen",
"created_at": "2026-04-24T10:00:00Z"
}
```
**Errors:**
- `400` - Bad Request (weder exercise_id noch is_placeholder=true)
- `404` - Block oder Exercise nicht gefunden
- `409` - Conflict (sequence_order bereits vergeben)
---
### `PUT /exercise-blocks/{id}/items/{item_id}`
**Request Body:** Gleiche Felder wie POST, alle optional
**Response:** `200 OK` (item object)
---
### `DELETE /exercise-blocks/{id}/items/{item_id}`
**Response:** `200 OK`
```json
{"ok": true}
```
---
### `PUT /exercise-blocks/{id}/items/reorder`
**Request Body:**
```json
{
"item_ids": [3, 1, 2, 4]
}
```
**Response:** `200 OK`
```json
{
"ok": true,
"reordered": 4,
"items": [
{"id": 3, "sequence_order": 1},
{"id": 1, "sequence_order": 2},
{"id": 2, "sequence_order": 3},
{"id": 4, "sequence_order": 4}
]
}
```
**Errors:**
- `400` - item_ids unvollständig oder gehören nicht zu diesem Block
---
## Validation Rules
### Exercise
- `title`: 3-300 chars, unique per club
- `goal`: 10-5000 chars
- `execution`: 10-10000 chars
- `duration_min/max`: 0-480 (8 hours)
- `group_size_min/max`: 1-100
- `visibility`: enum (private, club, official)
- `status`: enum (draft, in_review, approved, archived)
### Variant
- `variant_name`: 3-200 chars
- `progression_level`: 1-10
- `prerequisite_variant_id`: must exist, must be same exercise
### Media
- File size: max 50MB
- Mime types: `image/jpeg, image/png, image/gif, video/mp4, application/pdf`
- Embed platforms: `youtube, instagram, vimeo`
### Exercise Block
- `name`: 3-200 chars
### Exercise Skills
- `required_level`: enum `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
- `target_level`: enum gleiche Werte (optional/nullable)
- `intensity`: enum `niedrig | mittel | hoch` (optional/nullable)
- `target_level` sollte >= `required_level` sein (Warnung, kein Fehler)
### Exercise Block Item
- `sequence_order`: muss unique pro Block sein
- `exercise_id`: muss existieren und zugänglich sein (visibility check)
- `is_placeholder = true`: `exercise_id` muss NULL sein
- `placeholder_criteria`: bekannte Keys nur (focus_area_id, training_style_id, target_group_id, skill_ids, max_duration, min_duration, difficulty, visibility)
---
## Error Response Format
**Standard:**
```json
{
"detail": "Human-readable error message"
}
```
**Validation (detailliert):**
```json
{
"detail": "Validation failed",
"errors": [
{"field": "title", "message": "Titel muss mindestens 3 Zeichen lang sein"},
{"field": "goal", "message": "Ziel ist ein Pflichtfeld"}
]
}
```
---
## HTTP Status Codes
- `200 OK` - Erfolgreiche GET/PUT/DELETE
- `201 Created` - Erfolgreiche POST
- `400 Bad Request` - Validierung fehlgeschlagen
- `401 Unauthorized` - Kein/ungültiges Token
- `403 Forbidden` - Keine Berechtigung
- `404 Not Found` - Ressource nicht gefunden
- `409 Conflict` - Duplikat, Constraint
- `413 Payload Too Large` - Datei zu groß
- `500 Internal Server Error` - Server-Fehler
---
**Version:** 1.0
**Letzte Änderung:** 2026-04-24
**Status:** DRAFT - Awaiting Review

View File

@ -0,0 +1,638 @@
# Exercise System Architecture
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT - Awaiting Review
**Autor:** Claude Code
---
## 1. Datenmodell-Entscheidungen
### 1.1 Übung vs. Variante vs. Block vs. Serie
**Definitionen:**
- **Übung (Exercise):** Atomare Trainingseinheit mit Ziel, Durchführung, Vorbereitung
- Tabelle: `exercises`
- Beispiel: "Maai - Distanzübung"
- **Variante (Variant):** Angepasste Ausführung EINER Übung
- Tabelle: `exercise_variants`
- Beispiel: "Maai - Ohne Partner", "Maai - Mit Pratzen"
- Beziehung: 1:N (eine Übung hat 0-n Varianten)
- **Serie (Series):** Progression durch Varianten derselben Übung
- **KEINE eigene Tabelle** - Progression via Metadata in `exercise_variants`
- Felder: `progression_level`, `sequence_order`, `prerequisite_variant_id`
- Beispiel: Liegestütz → Level 1 (Knie), Level 2 (Normal), Level 3 (Erhöht), Level 4 (Einarmig)
- **Block (Block):** Sammlung UNTERSCHIEDLICHER Übungen als zusammenhängender Trainingsabschnitt
- Tabelle: `exercise_blocks` + `exercise_block_items`
- Beispiel: "Zirkel Oberkörper" → Liegestütz + Klimmzüge + Dips + Planks
- Beziehung: M:N (ein Block enthält viele Übungen, eine Übung kann in vielen Blöcken sein)
- **Template-Block:** Block mit Platzhaltern für spätere Befüllung
- Flag: `is_template = true` in `exercise_blocks`
- Beispiel: "Zirkeltraining (6 Stationen)" → Station 1: [Platzhalter: Push-Übung], Station 2: [Platzhalter: Pull-Übung], etc.
**Architektur-Diagramm:**
```
Exercise (1) ──────┬──── (N) Variants
│ ├── progression_level
│ ├── sequence_order
│ └── prerequisite_variant_id
└──── (M:N) Skills
└──── (M:N) Focus Areas
└──── (M:N) Training Styles
└──── (M:N) Target Groups
└──── (1:N) Media
Exercise Block ──── (N) Block Items ──── (1) Exercise
└── (1) Variant (optional)
└── is_placeholder (for templates)
```
---
### 1.2 Medien-Strategie
**Zwei Medien-Typen:**
1. **Lokale Medien** (eigene Uploads)
- Storage: `/app/media/exercises/{uuid}_{filename}`
- Unterstützte Formate:
- Bilder: `image/jpeg`, `image/png`, `image/gif`
- Videos: `video/mp4`
- Dokumente: `application/pdf` (für Skizzen)
- Max Size: **50MB pro Datei**
- Max Count: **10 Dateien pro Übung**
2. **Embeds** (externe Plattformen)
- Unterstützte Plattformen:
- YouTube: `youtube.com/watch?v=...`, `youtu.be/...`
- Instagram: `instagram.com/p/...`, `instagram.com/reel/...`
- Vimeo: `vimeo.com/...`
- Speicherung: Nur URL + Platform-Identifier
- Rendering: iframe-Embed im Frontend
**Medien-Felder:**
- `file_path` (lokale Medien) **XOR** `embed_url` (externe Medien)
- `mime_type` (nur bei lokalen Medien)
- `embed_platform` (nur bei Embeds: 'youtube', 'instagram', 'vimeo')
- `sort_order` (für Reorder via Drag & Drop)
- `is_primary` (Hauptmedium, z.B. Vorschau-Bild)
- `context` (Kontext: 'ablauf', 'detail', 'trainer_hint')
**Storage-Management:**
- Uploads via FastAPI `UploadFile`
- Speicherort: Docker Volume `/app/media/exercises/`
- Cleanup: Bei Exercise-Deletion automatisch via CASCADE + Cleanup-Job
---
### 1.3 M:N Beziehungen (Primary/Secondary Pattern)
**Regel:** Alle Katalog-Zuordnungen nutzen M:N mit `is_primary` Flag.
**Betroffene Relationen:**
- `exercise_focus_areas` (Übung ↔ Fokusbereiche)
- `exercise_styles` (Übung ↔ Trainingsstile)
- `exercise_target_groups` (Übung ↔ Zielgruppen)
- `exercise_training_characters` (Übung ↔ Trainingscharaktere)
- `exercise_skills` (Übung ↔ Fähigkeiten)
**Primary/Secondary Semantik:**
- **Primary:** Hauptzuordnung, entscheidend für Filter/Suche
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension
- **UI:** Primary wird visuell hervorgehoben (z.B. fett, farbig)
**Legacy-Felder (DEPRECATED):**
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
- `exercises.training_style_id` → Ignorieren, nutze `exercise_styles`
- `exercises.training_character_id` → Ignorieren, nutze `exercise_training_characters`
- **NICHT löschen** (Rückwärtskompatibilität), aber **nicht verwenden**
---
### 1.4 Sichtbarkeit & Freigabe
**3 Sichtbarkeits-Ebenen:**
- `private` - Nur Ersteller sieht Übung
- `club` - Alle Vereinsmitglieder sehen Übung
- `official` - Alle Nutzer sehen Übung (globale Standards)
**4 Status-Stufen:**
- `draft` - Entwurf, in Bearbeitung
- `in_review` - Zur Prüfung eingereicht
- `approved` - Freigegeben, produktiv nutzbar
- `archived` - Archiviert, nicht mehr aktiv
**Berechtigungsmatrix:**
| Aktion | Private | Club | Official |
|--------|---------|------|----------|
| **Sehen** | Nur Owner | Alle Club-Member | Alle Nutzer |
| **Bearbeiten** | Nur Owner | Nur Owner | Nur Superadmin |
| **Löschen** | Nur Owner | Nur Owner + Club-Admin | Nur Superadmin |
| **Freigeben** | Owner → Club | Club-Admin → Official | Superadmin |
**Freigabe-Workflow (vereinfacht in Phase 1):**
1. Trainer erstellt Übung (`private`, `draft`)
2. Trainer setzt Status auf `in_review`
3. Club-Admin prüft, setzt `approved` + `visibility = 'club'`
4. Superadmin kann auf `visibility = 'official'` setzen
**Phase 2+:** Explizite Freigabe-Requests (`content_change_requests` Tabelle)
---
## 2. API-Konventionen
### 2.1 Naming
**URL-Struktur:**
- **Collections:** Plural (`/api/exercises`)
- **Items:** Singular + ID (`/api/exercises/{id}`)
- **Sub-Resources:** Nested (`/api/exercises/{id}/variants`, `/api/exercises/{id}/media`)
- **Actions:** HTTP-Verben (POST, PUT, DELETE, GET)
**HTTP-Methoden:**
- `GET` - Read (Liste oder Detail)
- `POST` - Create (neue Ressource)
- `PUT` - Update (bestehende Ressource)
- `DELETE` - Delete
**Query-Parameter:**
- Snake-case: `focus_area`, `skill_id`, `search`
- Optional Filter: `?focus_area=karate&status=approved`
- Pagination: `?limit=50&offset=0`
---
### 2.2 Response-Format
**Erfolgreiche Responses:**
```json
// Liste (Array)
[
{"id": 1, "title": "...", ...},
{"id": 2, "title": "...", ...}
]
// Detail (Object mit Enrichment)
{
"id": 1,
"title": "...",
"skills": [...], // enriched M:N
"variants": [...], // enriched 1:N
"media": [...], // enriched 1:N
"focus_areas": [...], // enriched M:N
"training_styles": [...], // enriched M:N
"target_groups": [...], // enriched M:N
"age_groups_catalog": ["Kinder", "Teenager"]
}
// Create/Update (Created Object)
{
"id": 42,
... // full object wie GET Detail
}
// Delete
{"ok": true}
```
**Fehler-Responses:**
```json
{
"detail": "Human-readable error message"
}
```
---
### 2.3 Error-Handling
**HTTP-Status-Codes:**
- `200 OK` - Erfolgreiche GET/PUT/DELETE
- `201 Created` - Erfolgreiche POST
- `400 Bad Request` - Validierung fehlgeschlagen, fehlende Pflichtfelder
- `401 Unauthorized` - Kein oder ungültiges Auth-Token
- `403 Forbidden` - Keine Berechtigung (Sichtbarkeit/Ownership)
- `404 Not Found` - Ressource nicht gefunden
- `409 Conflict` - Duplikat, Constraint-Verletzung
- `413 Payload Too Large` - Datei zu groß
- `500 Internal Server Error` - Server-Fehler
**Error-Detail-Format:**
```json
{
"detail": "Titel, Ziel und Durchführung sind Pflichtfelder"
}
```
**Validation Errors (detailliert):**
```json
{
"detail": "Validation failed",
"errors": [
{"field": "title", "message": "Titel muss mindestens 3 Zeichen lang sein"},
{"field": "goal", "message": "Ziel ist ein Pflichtfeld"}
]
}
```
---
## 3. Frontend-Architektur
### 3.1 Route-Struktur
**Übungen-Routes:**
- `/exercises` → Grid/List mit Filtern (ExercisesPage)
- `/exercises/:id` → Detail-Ansicht (ExerciseDetailPage)
- `/exercises/:id/edit` → Edit-Formular (ExerciseEditPage - eigene Route, kein Modal)
- `/exercises/new` → Create-Formular (ExerciseCreatePage - eigene Route, kein Modal)
**Rationale für eigene Routes statt Modals:**
- Bessere Deep-Linking (shareable URLs)
- Browser-Navigation (Back-Button)
- State-Management einfacher
- Mobile-freundlicher
---
### 3.2 Komponenten-Hierarchie
```
pages/
ExercisesPage.jsx (Grid + Filters)
ExerciseDetailPage.jsx (Formatted View)
ExerciseEditPage.jsx (Edit Form)
ExerciseCreatePage.jsx (Create Form)
components/
exercise/
ExerciseCard.jsx (Grid-Item)
ExerciseQuickInfo.jsx (Summary-Box)
ExerciseSection.jsx (Collapsible Section)
VariantsList.jsx (Varianten-Akkordeon)
VariantCard.jsx (Einzelne Variante)
MediaGallery.jsx (Medien-Display)
MediaUploader.jsx (Upload/Embed-UI)
SkillsList.jsx (Fähigkeiten-Chips)
CatalogAssignments.jsx (Zuordnungen-Display)
forms/
ExerciseForm.jsx (Haupt-Formular, wiederverwendbar)
VariantForm.jsx (Varianten-Formular)
MediaUploadForm.jsx (Upload/Embed-Tabs)
MultiSelect.jsx (M:N Auswahl mit Primary-Toggle)
SkillsSelector.jsx (Skills mit Intensity/Level)
common/
Chip.jsx (Badge/Tag)
Badge.jsx (Status/Visibility)
Accordion.jsx (Collapsible Section)
Modal.jsx (Generisches Modal)
DragDropList.jsx (Reorder-Support)
```
---
### 3.3 State-Management
**Strategie: Minimal, lokal, server-zentriert**
- **Local State:** `useState` für UI (filter open/close, modal visible)
- **Server State:** `useEffect` + API-Calls (keine globale Cache)
- **Kein Redux/Zustand:** Context nur für Auth + Profile
- **Optimistic Updates:** Nur bei unkritischen Actions (reorder)
**Beispiel (ExercisesPage):**
```jsx
const [exercises, setExercises] = useState([])
const [filters, setFilters] = useState({focus_area: '', status: ''})
const [loading, setLoading] = useState(true)
useEffect(() => {
loadExercises(filters)
}, [filters])
const loadExercises = async (filters) => {
setLoading(true)
try {
const data = await api.listExercises(filters)
setExercises(data)
} catch (err) {
alert(err.message)
} finally {
setLoading(false)
}
}
```
---
## 4. Datenbank-Konventionen
### 4.1 Naming
- **Tabellen:** Plural, snake_case (`exercises`, `exercise_variants`)
- **Spalten:** snake_case (`created_at`, `is_primary`)
- **IDs:** `{table}_id` (`exercise_id`, `skill_id`)
- **Flags:** `is_*`, `has_*` (`is_primary`, `is_template`, `has_media`)
- **Timestamps:** `created_at`, `updated_at`
---
### 4.2 Standard-Spalten (IMMER)
**Pflicht für alle Tabellen:**
```sql
id SERIAL PRIMARY KEY
created_at TIMESTAMP DEFAULT NOW()
updated_at TIMESTAMP DEFAULT NOW() -- nur bei mutablen Tabellen
```
**Mutable vs. Immutable:**
- **Mutable:** `exercises`, `exercise_variants`, `exercise_blocks` → haben `updated_at`
- **Immutable:** `exercise_skills`, `exercise_focus_areas` → kein `updated_at`
---
### 4.3 Foreign Keys
**Regeln:**
- **Immer mit ON DELETE:** `CASCADE` oder `RESTRICT` (nie ohne!)
- **Immer mit Index:** Für alle FK-Spalten
- **Naming:** `fk_{from_table}_{to_table}` (optional, für Debugging)
**Beispiele:**
```sql
-- CASCADE: Löschen der Parent-Ressource löscht auch Children
exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE
-- RESTRICT: Löschen der Parent-Ressource verhindert wenn Children existieren
skill_id INT REFERENCES skills(id) ON DELETE RESTRICT
-- SET NULL: Löschen setzt FK auf NULL (für optionale Beziehungen)
prerequisite_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL
```
---
### 4.4 JSONB vs. Tabellen
**JSONB verwenden wenn:**
- Daten unstrukturiert
- Selten gefiltert/gesucht
- Flexible Schema-Änderungen nötig
**Beispiele:**
- `equipment` JSONB - Liste von Geräten (["Matten", "Pratzen"])
- `secondary_method_ids` JSONB - Liste von IDs ([2, 5, 7])
- `placeholder_criteria` JSONB - Filter-Kriterien für Platzhalter
**Tabelle verwenden wenn:**
- Daten strukturiert
- Oft gefiltert/gesucht
- Referentielle Integrität nötig (Foreign Keys)
**Beispiele:**
- `exercise_skills` - M:N Beziehung, oft gefiltert
- `exercise_variants` - Strukturierte Daten, oft einzeln abgerufen
- `exercise_media` - Referentielle Integrität (ON DELETE CASCADE)
---
## 5. Migrations-Strategie
### 5.1 Naming
**Format:** `{NNN}_{descriptive_name}.sql`
- NNN = laufende Nummer (3-stellig mit führenden Nullen)
- Beschreibend, nicht kryptisch
- Beispiele:
- `014_variants_progression.sql`
- `015_media_upload.sql`
- `016_exercise_blocks.sql`
---
### 5.2 Idempotenz
**Regel:** Migrationen müssen mehrfach ausführbar sein ohne Fehler.
**Immer verwenden:**
```sql
-- Tabellen
CREATE TABLE IF NOT EXISTS ...
-- Spalten
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='...' AND column_name='...') THEN
ALTER TABLE ... ADD COLUMN ...;
END IF;
END $$;
-- Indizes
CREATE INDEX IF NOT EXISTS ...
-- Constraints
ALTER TABLE ... ADD CONSTRAINT ... IF NOT EXISTS ...
```
**Niemals:**
```sql
DROP TABLE ... -- ohne IF EXISTS
ALTER TABLE ... DROP COLUMN ... -- ohne Check
```
---
### 5.3 Data Migration
**Reihenfolge bei Schema-Änderungen:**
1. **Backup:** Alte Daten sichern (z.B. `_backup` Suffix)
2. **Schema-Änderung:** Neue Spalten/Tabellen hinzufügen
3. **Data-Migration:** Alte Daten in neues Schema überführen
4. **Validation:** Daten-Integrität prüfen
5. **Cleanup:** Alte Spalten/Tabellen als deprecated markieren (nicht sofort löschen!)
**Beispiel (Migration 008):**
```sql
-- 1. Backup (implizit via alte Spalten behalten)
-- 2. Schema
CREATE TABLE exercise_focus_areas ...
-- 3. Data Migration
INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
SELECT id, focus_area_id, true
FROM exercises
WHERE focus_area_id IS NOT NULL
ON CONFLICT DO NOTHING;
-- 4. Validation (manuell via Query)
-- 5. Cleanup (später, wenn sicher)
-- ALTER TABLE exercises DROP COLUMN focus_area_id; -- NICHT JETZT!
```
---
### 5.4 Rollback-Plan
**Jede Migration braucht:**
- Dokumentierter Rollback-Plan (im Kommentar)
- Test auf Dev-System
- Validierung nach Ausführung
**Beispiel:**
```sql
-- Migration 014: Variants Progression
-- Rollback: DROP COLUMNS progression_level, sequence_order, prerequisite_variant_id
-- Risk: LOW (nur neue Spalten, kein Data Loss)
-- Validation: SELECT COUNT(*) FROM exercise_variants WHERE progression_level IS NOT NULL;
ALTER TABLE exercise_variants
ADD COLUMN IF NOT EXISTS progression_level INT;
```
---
## 6. Testing-Strategie (Phase 2)
### 6.1 Backend
**Unit-Tests:**
- Resolver-Funktionen (z.B. `get_exercise()`)
- Validation-Logik (z.B. `validate_exercise()`)
- Helper-Funktionen (z.B. `parse_embed_url()`)
**Integration-Tests:**
- API-Endpoints (Request → Response)
- DB-Transaktionen (Create → Read → Update → Delete)
- Permissions (Auth + Visibility)
**Migration-Tests:**
- Up/Down-Migrationen
- Data-Integrity nach Migration
---
### 6.2 Frontend
**Component-Tests (Jest + React Testing Library):**
- ExerciseCard rendert korrekt
- ExerciseForm validiert Inputs
- MultiSelect verhält sich korrekt
**E2E-Tests (Playwright):**
- User kann Übung erstellen
- User kann Übung bearbeiten
- User kann Varianten hinzufügen
- User kann Medien hochladen
**Visual Regression (optional):**
- Chromatic / Percy
- Screenshots vergleichen
---
## 7. Dokumentations-Konventionen
### 7.1 Code-Kommentare
**Python Docstrings:**
```python
def get_exercise(exercise_id: int, session: dict) -> dict:
"""
Get exercise by ID with all enriched data.
Args:
exercise_id: Exercise ID
session: Auth session from require_auth()
Returns:
dict: Exercise object with skills, variants, media, catalog assignments
Raises:
HTTPException: 404 if not found, 403 if forbidden
"""
```
**JSDoc (für komplexe Komponenten):**
```jsx
/**
* ExerciseCard - Grid item für Übungsdarstellung
*
* @param {Object} exercise - Exercise object from API
* @param {Function} onClick - Handler für Detail-Klick
* @param {Function} onEdit - Handler für Edit-Klick
* @param {Function} onDelete - Handler für Delete-Klick
* @param {boolean} compact - Kompakte Darstellung (optional)
*/
export function ExerciseCard({ exercise, onClick, onEdit, onDelete, compact = false }) {
// ...
}
```
**Inline-Kommentare:**
- Nur für nicht-offensichtliche Logik
- Warum, nicht Was
- Beispiel: `// Prerequisite muss zur gleichen Übung gehören`
---
### 7.2 Markdown-Docs
**Struktur:**
- `functional/` - Fachliche Specs (WAS soll gebaut werden)
- `technical/` - Technische Specs (WIE wird es gebaut)
- `working/` - Temporäre Analysen, Notizen (kann veraltet sein)
- `audit/` - Reviews, Matrizen, Checklisten
**Update-Regel:**
- Nach jedem Feature: Doku aktualisieren
- Version im Header erhöhen
- Änderungslog im Dokument
---
## 8. Entscheidungs-Log
| Datum | Entscheidung | Rationale | Impact |
|-------|--------------|-----------|--------|
| 2026-04-24 | M:N statt 1:1 für Katalog-Zuordnungen | Flexibilität, keine Duplikate, bessere Filter | Alle Übungen müssen migriert werden (Migration 008) |
| 2026-04-24 | Varianten für Progression statt eigene Serie-Tabelle | Einfacher, weniger Joins, direkte Beziehung | Variants-Tabelle erweitern (Migration 014) |
| 2026-04-24 | Lokale Medien + Embeds (nicht Cloud) | Kostenkontrolle, Datenschutz, keine Vendor-Lock-in | Storage-Management nötig, Backup-Strategie |
| 2026-04-24 | Eigene Routes statt Modals für Edit/Create | Deep-Linking, Browser-Navigation, Mobile-UX | Mehr Routes, aber bessere UX |
| 2026-04-24 | MediaWiki API statt Export | Live-Sync möglich, aktuellere Daten | API-Client implementieren, Semantic MediaWiki verstehen |
| 2026-04-24 | JSONB für Equipment, nicht eigene Tabelle | Flexibel, selten gefiltert, keine Overhead | Keine Referentielle Integrität, Full-Text-Suche schwieriger |
---
## 9. Offene Punkte / TODOs
**Für Review:**
- [ ] Sichtbarkeits-Matrix OK? (private/club/official)
- [ ] Freigabe-Workflow ausreichend einfach?
- [ ] Medien-Limits OK? (50MB, 10 Files)
- [ ] JSONB vs. Tabellen für Equipment richtig entschieden?
**Für Phase 2:**
- [ ] Explizite Freigabe-Requests (content_change_requests)
- [ ] Saved Searches
- [ ] Advanced Filters (Kombination mehrerer Kriterien)
- [ ] Batch-Operations (Multi-Select + Delete/Archive)
---
**Version:** 1.0
**Letzte Änderung:** 2026-04-24
**Status:** DRAFT - Awaiting Review

View File

@ -0,0 +1,910 @@
# Database Schema Final - Exercises System
**Version:** 1.2
**Datum:** 2026-04-24
**Status:** REVIEWED - Pending Implementation
**Autor:** Claude Code
**Änderungen v1.2:** Skill-Level auf benannte Stufen (einsteigerexperte), intensity auf niedrig/mittel/hoch, ai_suggested Felder, summary_ai_generated
**Änderungen v1.1:** age_groups JSONB entfernt, Legacy-DROP ergänzt, Migration 017 (Exercise Blocks)
---
## 1. Übersicht
**Neue Migrationen:**
- **Migration 014:** Variant Progression System + Search Vector + Legacy-Cleanup
- **Migration 015:** Semantic Matching (OPTIONAL - Phase 2)
- **Migration 016:** Saved Searches
- **Migration 017:** Exercise Blocks + Template Blocks
**Basis:** Migrationen 001-013 (bereits deployed)
---
## 2. Migration 014: Variant Progression + Search
### 2.1 Vollständige Migration
```sql
-- Migration 014: Variant Progression System + Search Vector
-- Autor: Claude Code
-- Datum: 2026-04-24
DO $$
BEGIN
-- ============================================================================
-- VARIANT PROGRESSION
-- ============================================================================
-- Erweitere exercise_variants Tabelle
ALTER TABLE exercise_variants
ADD COLUMN IF NOT EXISTS progression_level INT DEFAULT 1 CHECK (progression_level BETWEEN 1 AND 10),
ADD COLUMN IF NOT EXISTS sequence_order INT,
ADD COLUMN IF NOT EXISTS prerequisite_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL;
-- Index für Prerequisites
CREATE INDEX IF NOT EXISTS idx_exercise_variants_prerequisite
ON exercise_variants(prerequisite_variant_id);
-- ============================================================================
-- SEARCH VECTOR (Volltext-Suche)
-- ============================================================================
-- Füge search_vector zu exercises hinzu
ALTER TABLE exercises
ADD COLUMN IF NOT EXISTS search_vector tsvector;
-- Index für Volltext-Suche
CREATE INDEX IF NOT EXISTS idx_exercises_search
ON exercises USING gin(search_vector);
-- Funktion für automatisches Update
CREATE OR REPLACE FUNCTION update_exercises_search_vector()
RETURNS trigger AS $func$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('german', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('german', COALESCE(NEW.summary, '')), 'B') ||
setweight(to_tsvector('german', COALESCE(NEW.execution, '')), 'C') ||
setweight(to_tsvector('german', COALESCE(NEW.trainer_notes, '')), 'D');
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
-- Trigger
DROP TRIGGER IF EXISTS exercises_search_update ON exercises;
CREATE TRIGGER exercises_search_update
BEFORE INSERT OR UPDATE ON exercises
FOR EACH ROW EXECUTE FUNCTION update_exercises_search_vector();
-- Initiales Befüllen (für existierende Zeilen)
UPDATE exercises SET search_vector = (
setweight(to_tsvector('german', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('german', COALESCE(summary, '')), 'B') ||
setweight(to_tsvector('german', COALESCE(execution, '')), 'C') ||
setweight(to_tsvector('german', COALESCE(trainer_notes, '')), 'D')
) WHERE search_vector IS NULL;
-- ============================================================================
-- LEGACY COLUMN CLEANUP
-- Deprecated Felder aus exercises (ersetzt durch M:N Tabellen in Migration 008+)
-- ============================================================================
-- age_groups JSONB → ersetzt durch exercise_age_groups M:N (seit Migration 008)
ALTER TABLE exercises DROP COLUMN IF EXISTS age_groups;
-- focus_area VARCHAR → ersetzt durch exercise_focus_areas M:N (seit Migration 008)
ALTER TABLE exercises DROP COLUMN IF EXISTS focus_area;
-- secondary_areas JSONB → ersetzt durch exercise_focus_areas M:N
ALTER TABLE exercises DROP COLUMN IF EXISTS secondary_areas;
-- training_character VARCHAR → ersetzt durch exercise_training_characters M:N (seit Migration 012)
ALTER TABLE exercises DROP COLUMN IF EXISTS training_character;
-- ============================================================================
-- ADDITIONAL INDEXES (Performance)
-- ============================================================================
-- Häufige Filter
CREATE INDEX IF NOT EXISTS idx_exercises_visibility ON exercises(visibility);
CREATE INDEX IF NOT EXISTS idx_exercises_status ON exercises(status);
CREATE INDEX IF NOT EXISTS idx_exercises_created_at ON exercises(created_at DESC);
-- M:N Relations (falls noch nicht vorhanden)
CREATE INDEX IF NOT EXISTS idx_exercise_focus_areas_focus
ON exercise_focus_areas(focus_area_id);
CREATE INDEX IF NOT EXISTS idx_exercise_styles_style
ON exercise_training_styles(training_style_id);
CREATE INDEX IF NOT EXISTS idx_exercise_target_groups_group
ON exercise_target_groups(target_group_id);
CREATE INDEX IF NOT EXISTS idx_exercise_skills_skill
ON exercise_skills(skill_id);
RAISE NOTICE 'Migration 014 completed successfully';
END $$;
```
---
## 3. Migration 015: Semantic Matching (Optional Phase 2)
**Diese Migration ist optional für MVP Phase 1**
```sql
-- Migration 015: Semantic Exercise Matching (OPTIONAL - Phase 2)
-- Nutzt pgvector extension für Similarity-Matching
-- Autor: Claude Code
-- Datum: 2026-04-24
DO $$
BEGIN
-- ============================================================================
-- SEMANTIC EMBEDDINGS (Optional)
-- ============================================================================
-- Benötigt pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Erweitere exercises Tabelle
ALTER TABLE exercises
ADD COLUMN IF NOT EXISTS embedding vector(1536); -- OpenAI ada-002 embedding size
-- Index für Ähnlichkeitssuche
CREATE INDEX IF NOT EXISTS idx_exercises_embedding
ON exercises USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- Funktion für Similarity-Search
CREATE OR REPLACE FUNCTION find_similar_exercises(
query_embedding vector(1536),
limit_count INT DEFAULT 10
)
RETURNS TABLE (
exercise_id INT,
title VARCHAR,
similarity FLOAT
) AS $func$
BEGIN
RETURN QUERY
SELECT
id,
title,
1 - (embedding <=> query_embedding) AS similarity
FROM exercises
WHERE embedding IS NOT NULL
ORDER BY embedding <=> query_embedding
LIMIT limit_count;
END;
$func$ LANGUAGE plpgsql;
RAISE NOTICE 'Migration 015 completed successfully (Semantic Matching - OPTIONAL)';
END $$;
```
**Hinweis:** Embeddings werden über separaten Background-Job befüllt, nicht beim INSERT.
---
## 4. Migration 016: Saved Searches
```sql
-- Migration 016: Saved Exercise Searches
-- Autor: Claude Code
-- Datum: 2026-04-24
DO $$
BEGIN
-- ============================================================================
-- SAVED SEARCHES
-- ============================================================================
CREATE TABLE IF NOT EXISTS saved_exercise_searches (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
filters JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Index für User-Zugriff
CREATE INDEX IF NOT EXISTS idx_saved_searches_profile
ON saved_exercise_searches(profile_id);
-- Trigger für updated_at
CREATE OR REPLACE FUNCTION update_saved_searches_timestamp()
RETURNS trigger AS $func$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS saved_searches_update ON saved_exercise_searches;
CREATE TRIGGER saved_searches_update
BEFORE UPDATE ON saved_exercise_searches
FOR EACH ROW EXECUTE FUNCTION update_saved_searches_timestamp();
RAISE NOTICE 'Migration 016 completed successfully';
END $$;
```
---
## 5. Migration 017: Exercise Blocks + Template Blocks
```sql
-- Migration 017: Exercise Blocks + Template Blocks
-- Autor: Claude Code
-- Datum: 2026-04-24
-- Zweck: Gruppierung verschiedener Übungen in Blöcken (User-Anforderung #4)
-- Series = Varianten-Progression (via exercise_variants, KEINE eigene Tabelle)
-- Blocks = Verschiedene Übungen in Reihenfolge (DIESE Migration)
DO $$
BEGIN
-- ============================================================================
-- EXERCISE BLOCKS
-- Eine Sammlung verschiedener Übungen in definierter Reihenfolge
-- ============================================================================
CREATE TABLE IF NOT EXISTS exercise_blocks (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
goal TEXT,
-- Template-Modus: Block mit Platzhaltern für flexible Planung
is_template BOOLEAN DEFAULT false,
-- Ownership & Sichtbarkeit
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
visibility VARCHAR(20) DEFAULT 'private' CHECK (visibility IN ('private', 'club', 'official')),
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- ============================================================================
-- EXERCISE BLOCK ITEMS
-- Einzelne Positionen innerhalb eines Blocks
-- ============================================================================
CREATE TABLE IF NOT EXISTS exercise_block_items (
id SERIAL PRIMARY KEY,
block_id INT NOT NULL REFERENCES exercise_blocks(id) ON DELETE CASCADE,
-- Konkrete Übung (NULL wenn is_placeholder = true)
exercise_id INT REFERENCES exercises(id) ON DELETE RESTRICT,
-- Optionale Variante (kann NULL sein → Haupt-Übung wird genutzt)
variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
-- Reihenfolge innerhalb des Blocks
sequence_order INT NOT NULL,
-- Template-Modus: Platzhalter statt konkreter Übung
is_placeholder BOOLEAN DEFAULT false,
-- Kriterien für Platzhalter-Auflösung (nur relevant wenn is_placeholder = true)
-- Schema: {"focus_area_id": 1, "max_duration": 10, "skill_ids": [3, 7], "difficulty": "easier"}
-- Alle Felder optional, werden als AND-Filter bei der Übungssuche genutzt
placeholder_criteria JSONB,
-- Platzhalter-Beschriftung (für UX im Template-Modus)
placeholder_label VARCHAR(100), -- z.B. "Aufwärmübung Schlag", "Hauptübung Kumite"
-- Zusätzliche Notizen für diese Position
notes TEXT,
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
-- Constraints
UNIQUE(block_id, sequence_order),
-- Entweder exercise_id ODER is_placeholder=true
CHECK (
(is_placeholder = false AND exercise_id IS NOT NULL) OR
(is_placeholder = true AND exercise_id IS NULL)
)
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_exercise_blocks_club ON exercise_blocks(club_id);
CREATE INDEX IF NOT EXISTS idx_exercise_blocks_creator ON exercise_blocks(created_by);
CREATE INDEX IF NOT EXISTS idx_exercise_blocks_visibility ON exercise_blocks(visibility);
CREATE INDEX IF NOT EXISTS idx_exercise_blocks_template ON exercise_blocks(is_template) WHERE is_template = true;
CREATE INDEX IF NOT EXISTS idx_exercise_block_items_block ON exercise_block_items(block_id);
CREATE INDEX IF NOT EXISTS idx_exercise_block_items_exercise ON exercise_block_items(exercise_id);
CREATE INDEX IF NOT EXISTS idx_exercise_block_items_placeholder ON exercise_block_items(is_placeholder) WHERE is_placeholder = true;
-- ============================================================================
-- UPDATED_AT TRIGGER
-- ============================================================================
DROP TRIGGER IF EXISTS exercise_blocks_update ON exercise_blocks;
CREATE TRIGGER exercise_blocks_update
BEFORE UPDATE ON exercise_blocks
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
RAISE NOTICE 'Migration 017 completed successfully (Exercise Blocks)';
END $$;
```
### 5.0 Placeholder Criteria Schema
Das `placeholder_criteria` JSONB-Feld in `exercise_block_items` erlaubt folgende Schlüssel (alle optional, werden als AND-Filter kombiniert):
```json
{
"focus_area_id": 1, // INT: Fokusbereich-ID
"training_style_id": 2, // INT: Stil-ID
"target_group_id": 5, // INT: Zielgruppen-ID
"skill_ids": [3, 7], // INT[]: Mindestens eine dieser Fähigkeiten
"max_duration": 15, // INT: Maximale Dauer in Minuten
"min_duration": 5, // INT: Minimale Dauer in Minuten
"difficulty": "easier", // "easier" | "same" | "harder"
"visibility": "club" // "private" | "club" | "official"
}
```
**Validierung:** Backend prüft, dass alle vorhandenen Schlüssel bekannte Felder sind und die Werte dem erwarteten Typ entsprechen.
---
## 6. Vollständige Tabellenstruktur (Final State)
### 5.1 exercises (Kern-Tabelle)
```sql
CREATE TABLE exercises (
id SERIAL PRIMARY KEY,
-- Basis-Info
title VARCHAR(300) NOT NULL,
summary TEXT,
goal TEXT NOT NULL,
execution TEXT NOT NULL,
preparation TEXT,
trainer_notes TEXT,
-- Dauer & Gruppengröße
duration_min INT,
duration_max INT,
group_size_min INT,
group_size_max INT,
-- JSONB-Felder
equipment JSONB DEFAULT '[]'::jsonb,
-- HINWEIS: age_groups werden via exercise_age_groups M:N Tabelle verwaltet (nicht JSONB)
-- Suche
search_vector tsvector, -- NEU in Migration 014
-- Semantic Matching (optional)
embedding vector(1536), -- NEU in Migration 015 (optional)
-- Sichtbarkeit & Status
visibility VARCHAR(20) DEFAULT 'private' CHECK (visibility IN ('private', 'club', 'official')),
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'in_review', 'approved', 'archived')),
-- Ownership
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
-- Import-Tracking
import_source VARCHAR(50), -- 'mediawiki', 'csv', etc.
import_id VARCHAR(100), -- Original-ID aus Quellsystem
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
### 5.2 exercise_variants
```sql
CREATE TABLE exercise_variants (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
-- Variant-Details
variant_name VARCHAR(200) NOT NULL,
description TEXT,
execution_changes TEXT,
-- Dauer (Override)
duration_min INT,
duration_max INT,
-- Equipment-Änderungen
equipment_changes JSONB DEFAULT '[]'::jsonb,
-- Schwierigkeit
difficulty_adjustment VARCHAR(20) CHECK (difficulty_adjustment IN ('easier', 'same', 'harder')),
-- Progression (NEU in Migration 014)
progression_level INT DEFAULT 1 CHECK (progression_level BETWEEN 1 AND 10),
sequence_order INT,
prerequisite_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
### 5.3 exercise_media
```sql
CREATE TABLE exercise_media (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
-- Media-Type
media_type VARCHAR(20) CHECK (media_type IN ('image', 'video', 'document', 'sketch')),
-- Lokale Datei (exklusiv mit embed_url)
file_path VARCHAR(500),
file_size INT,
mime_type VARCHAR(100),
original_filename VARCHAR(300),
-- Embed (exklusiv mit file_path)
embed_url TEXT,
embed_platform VARCHAR(50), -- 'youtube', 'vimeo', 'instagram', 'tiktok'
-- Metadata
title VARCHAR(200),
description TEXT,
sort_order INT DEFAULT 1,
is_primary BOOLEAN DEFAULT FALSE,
context VARCHAR(50) DEFAULT 'ablauf' CHECK (context IN ('ablauf', 'detail', 'trainer_hint')),
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
-- Constraint: Entweder file_path ODER embed_url
CHECK (
(file_path IS NOT NULL AND embed_url IS NULL) OR
(file_path IS NULL AND embed_url IS NOT NULL)
)
);
```
### 5.4 M:N Relation Tables
**exercise_focus_areas:**
```sql
CREATE TABLE exercise_focus_areas (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
focus_area_id INT NOT NULL REFERENCES focus_areas(id) ON DELETE RESTRICT,
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(exercise_id, focus_area_id)
);
```
**exercise_training_styles:**
```sql
CREATE TABLE exercise_training_styles (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
training_style_id INT NOT NULL REFERENCES training_styles(id) ON DELETE RESTRICT,
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(exercise_id, training_style_id)
);
```
**exercise_target_groups:**
```sql
CREATE TABLE exercise_target_groups (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
target_group_id INT NOT NULL REFERENCES target_groups(id) ON DELETE RESTRICT,
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(exercise_id, target_group_id)
);
```
**exercise_age_groups:**
```sql
CREATE TABLE exercise_age_groups (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
age_group_name VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(exercise_id, age_group_name)
);
```
**exercise_skills:**
```sql
CREATE TABLE exercise_skills (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
skill_id INT NOT NULL REFERENCES skills(id) ON DELETE RESTRICT,
is_primary BOOLEAN DEFAULT FALSE,
-- Kompetenzmodell: benannte Stufen (nicht numerisch 1-10)
-- NULL = nicht definiert / nicht relevant
required_level VARCHAR(20) CHECK (required_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')),
target_level VARCHAR(20) CHECK (target_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')),
-- Trainingsintensität: Wie stark wird diese Fähigkeit in der Übung trainiert
intensity VARCHAR(10) CHECK (intensity IN ('niedrig', 'mittel', 'hoch')),
-- KI-generierte Zuordnung (false = manuell bestätigt)
ai_suggested BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(exercise_id, skill_id)
);
```
**Skill-Level-Definitionen (Kompetenzmodell):**
| Stufe | Wert | Beschreibung | Beispiel Distanzgefühl |
|-------|------|--------------|----------------------|
| 1 | `einsteiger` | Erste Berührung mit der Fähigkeit, kein Vorwissen nötig | Versteht das Konzept Distanz |
| 2 | `grundlagen` | Grundprinzipien bekannt, in einfachen Situationen anwendbar | Hält in ruhigen Übungen Distanz |
| 3 | `aufbau` | Sicher in Standard-Situationen, braucht noch Korrektur | Distanzkontrolle in Partnerübungen |
| 4 | `fortgeschritten` | Zuverlässig auch unter Druck, wenig Fehler | Stabile Distanz im Sparring |
| 5 | `experte` | Automatisiert, intuitiv, auch in komplexen Situationen | Feines Distanzgefühl im Wettkampf |
**Für Migration 014 ergänzen:**
```sql
-- In Migration 014 nach den Variant-Columns ergänzen:
-- Skill-Level auf VARCHAR umstellen (bestehende INT-Werte migrieren)
ALTER TABLE exercise_skills
ALTER COLUMN required_level TYPE VARCHAR(20) USING
CASE required_level
WHEN 0 THEN NULL
WHEN 1 THEN 'einsteiger'
WHEN 2 THEN 'grundlagen'
WHEN 3 THEN 'aufbau'
WHEN 4 THEN 'fortgeschritten'
WHEN 5 THEN 'experte'
ELSE NULL
END,
ALTER COLUMN target_level TYPE VARCHAR(20) USING
CASE target_level
WHEN 0 THEN NULL
WHEN 1 THEN 'einsteiger'
WHEN 2 THEN 'grundlagen'
WHEN 3 THEN 'aufbau'
WHEN 4 THEN 'fortgeschritten'
WHEN 5 THEN 'experte'
ELSE NULL
END,
ALTER COLUMN intensity TYPE VARCHAR(10) USING
CASE
WHEN intensity <= 3 THEN 'niedrig'
WHEN intensity <= 7 THEN 'mittel'
ELSE 'hoch'
END;
-- Neue Constraints hinzufügen
ALTER TABLE exercise_skills
ADD CONSTRAINT ck_required_level CHECK (required_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')),
ADD CONSTRAINT ck_target_level CHECK (target_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')),
ADD CONSTRAINT ck_intensity CHECK (intensity IN ('niedrig', 'mittel', 'hoch'));
-- KI-Tracking Feld
ALTER TABLE exercises
ADD COLUMN IF NOT EXISTS summary_ai_generated BOOLEAN DEFAULT false;
ALTER TABLE exercise_skills
ADD COLUMN IF NOT EXISTS ai_suggested BOOLEAN DEFAULT false;
```
**exercise_training_characters:**
```sql
CREATE TABLE exercise_training_characters (
id SERIAL PRIMARY KEY,
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
training_character_id INT NOT NULL REFERENCES training_characters(id) ON DELETE RESTRICT,
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(exercise_id, training_character_id)
);
```
### 6.5 Exercise Blocks (Migration 017)
```sql
CREATE TABLE exercise_blocks (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
goal TEXT,
is_template BOOLEAN DEFAULT false,
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
visibility VARCHAR(20) DEFAULT 'private' CHECK (visibility IN ('private', 'club', 'official')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE exercise_block_items (
id SERIAL PRIMARY KEY,
block_id INT NOT NULL REFERENCES exercise_blocks(id) ON DELETE CASCADE,
exercise_id INT REFERENCES exercises(id) ON DELETE RESTRICT,
variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
sequence_order INT NOT NULL,
is_placeholder BOOLEAN DEFAULT false,
placeholder_criteria JSONB,
placeholder_label VARCHAR(100),
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(block_id, sequence_order),
CHECK (
(is_placeholder = false AND exercise_id IS NOT NULL) OR
(is_placeholder = true AND exercise_id IS NULL)
)
);
```
### 6.6 Saved Searches (Migration 016)
```sql
CREATE TABLE saved_exercise_searches (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
filters JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
---
## 6. Indizes (Komplett-Übersicht)
```sql
-- Volltext-Suche
CREATE INDEX idx_exercises_search ON exercises USING gin(search_vector);
-- Semantic Matching (optional)
CREATE INDEX idx_exercises_embedding ON exercises USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- Häufige Filter
CREATE INDEX idx_exercises_visibility ON exercises(visibility);
CREATE INDEX idx_exercises_status ON exercises(status);
CREATE INDEX idx_exercises_created_at ON exercises(created_at DESC);
CREATE INDEX idx_exercises_club ON exercises(club_id);
CREATE INDEX idx_exercises_creator ON exercises(created_by);
-- M:N Relations
CREATE INDEX idx_exercise_focus_areas_exercise ON exercise_focus_areas(exercise_id);
CREATE INDEX idx_exercise_focus_areas_focus ON exercise_focus_areas(focus_area_id);
CREATE INDEX idx_exercise_styles_exercise ON exercise_training_styles(exercise_id);
CREATE INDEX idx_exercise_styles_style ON exercise_training_styles(training_style_id);
CREATE INDEX idx_exercise_target_groups_exercise ON exercise_target_groups(exercise_id);
CREATE INDEX idx_exercise_target_groups_group ON exercise_target_groups(target_group_id);
CREATE INDEX idx_exercise_skills_exercise ON exercise_skills(exercise_id);
CREATE INDEX idx_exercise_skills_skill ON exercise_skills(skill_id);
CREATE INDEX idx_exercise_characters_exercise ON exercise_training_characters(exercise_id);
CREATE INDEX idx_exercise_characters_character ON exercise_training_characters(training_character_id);
-- Variants
CREATE INDEX idx_exercise_variants_exercise ON exercise_variants(exercise_id);
CREATE INDEX idx_exercise_variants_prerequisite ON exercise_variants(prerequisite_variant_id);
-- Media
CREATE INDEX idx_exercise_media_exercise ON exercise_media(exercise_id);
CREATE INDEX idx_exercise_media_primary ON exercise_media(is_primary) WHERE is_primary = true;
CREATE INDEX idx_exercise_media_context ON exercise_media(context);
-- Saved Searches
CREATE INDEX idx_saved_searches_profile ON saved_exercise_searches(profile_id);
-- Exercise Blocks
CREATE INDEX idx_exercise_blocks_club ON exercise_blocks(club_id);
CREATE INDEX idx_exercise_blocks_creator ON exercise_blocks(created_by);
CREATE INDEX idx_exercise_blocks_visibility ON exercise_blocks(visibility);
CREATE INDEX idx_exercise_blocks_template ON exercise_blocks(is_template) WHERE is_template = true;
CREATE INDEX idx_exercise_block_items_block ON exercise_block_items(block_id);
CREATE INDEX idx_exercise_block_items_exercise ON exercise_block_items(exercise_id);
CREATE INDEX idx_exercise_block_items_placeholder ON exercise_block_items(is_placeholder) WHERE is_placeholder = true;
```
---
## 7. Constraints & Business Rules
### 7.1 Data Integrity
**Exercises:**
- `title` mindestens 3 Zeichen
- `goal` mindestens 10 Zeichen
- `execution` mindestens 10 Zeichen
- `duration_min <= duration_max`
- `group_size_min <= group_size_max`
**Variants:**
- `variant_name` mindestens 3 Zeichen
- `prerequisite_variant_id` muss zum gleichen Exercise gehören
**Media:**
- Max. 50 MB per file (enforced in Backend)
- Max. 10 media items per exercise (enforced in Backend)
- Entweder `file_path` ODER `embed_url` (enforced in CHECK)
### 7.2 Cascading Delete
**ON DELETE CASCADE:**
- exercise → variants
- exercise → media
- exercise → M:N relations (focus_areas, styles, etc.)
- profile → saved searches
**ON DELETE RESTRICT:**
- focus_area → exercise_focus_areas (verhindert Löschen genutzter Katalog-Einträge)
- skill → exercise_skills
- target_group → exercise_target_groups
**ON DELETE SET NULL:**
- profile (creator) → exercises
- club → exercises
- variant (prerequisite) → variants
---
## 8. Trigger-Funktionen
### 8.1 Search Vector Auto-Update
```sql
CREATE OR REPLACE FUNCTION update_exercises_search_vector()
RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('german', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('german', COALESCE(NEW.summary, '')), 'B') ||
setweight(to_tsvector('german', COALESCE(NEW.execution, '')), 'C') ||
setweight(to_tsvector('german', COALESCE(NEW.trainer_notes, '')), 'D');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER exercises_search_update
BEFORE INSERT OR UPDATE ON exercises
FOR EACH ROW EXECUTE FUNCTION update_exercises_search_vector();
```
### 8.2 Updated_At Timestamp
```sql
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS trigger AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger für alle relevanten Tabellen
CREATE TRIGGER exercises_update_timestamp
BEFORE UPDATE ON exercises
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
CREATE TRIGGER exercise_variants_update_timestamp
BEFORE UPDATE ON exercise_variants
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
CREATE TRIGGER exercise_media_update_timestamp
BEFORE UPDATE ON exercise_media
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
CREATE TRIGGER saved_searches_update_timestamp
BEFORE UPDATE ON saved_exercise_searches
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
```
---
## 9. Migrations-Tracking
**Tabelle: schema_migrations**
```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
migration_id VARCHAR(50) PRIMARY KEY,
description TEXT,
applied_at TIMESTAMP DEFAULT NOW()
);
```
**Nach jeder Migration:**
```sql
INSERT INTO schema_migrations (migration_id, description) VALUES
('014', 'Variant Progression System + Search Vector + Legacy-Cleanup'),
('015', 'Semantic Exercise Matching (OPTIONAL)'),
('016', 'Saved Exercise Searches'),
('017', 'Exercise Blocks + Template Blocks');
```
---
## 10. Datenbank-Größenabschätzung
**Annahmen:**
- 5.000 Übungen
- Durchschnittlich 2 Varianten pro Übung
- Durchschnittlich 3 Media-Items pro Übung
- Durchschnittlich 5 M:N Zuordnungen pro Übung
**Geschätzte Zeilen:**
```
exercises: 5.000
exercise_variants: 10.000
exercise_media: 15.000
exercise_focus_areas: 10.000
exercise_training_styles: 10.000
exercise_target_groups: 10.000
exercise_skills: 25.000
exercise_blocks: 1.000
exercise_block_items: 5.000
─────────────────────────────────────
TOTAL: 101.000 Zeilen
```
**Speicherbedarf (ohne Media-Files):**
- exercises Tabelle: ~50 MB (mit search_vector + embedding)
- Alle M:N Tabellen: ~10 MB
- Variants + Media: ~5 MB
- **Total DB:** ~65 MB
**Media-Files (lokal gespeichert):**
- 15.000 Items × 5 MB Durchschnitt = ~75 GB
---
## 11. Performance-Benchmarks
**Target Query Performance:**
- List Exercises (ohne Filter): < 50ms
- List Exercises (mit 3 Filtern): < 100ms
- Volltext-Suche: < 150ms
- Exercise Detail (enriched): < 80ms
- Semantic Similarity: < 200ms (optional)
**Optimierungen:**
- GIN-Index für tsvector → 10x schnellere Suche
- IVFFlat-Index für Embeddings → 50x schnellere Similarity-Search
- Partial Index für `is_primary` Media → schnellere Primary-Lookups
---
**Version:** 1.0
**Letzte Änderung:** 2026-04-24
**Status:** DRAFT - Awaiting Review

View File

@ -0,0 +1,681 @@
# Frontend Routing & Navigation Specification
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT - Awaiting Review
**Autor:** Claude Code
---
## 1. Route-Struktur
### 1.1 Route-Übersicht
```
/exercises → ExercisesListPage (Grid + Filter)
/exercises/new → ExerciseFormPage (Create)
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
/exercises/{id}/edit → ExerciseFormPage (Edit)
/exercises/{id}/variants/new → VariantFormPage (Create)
/exercises/{id}/variants/{vid}/edit → VariantFormPage (Edit)
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
/exercise-blocks/new → ExerciseBlockFormPage (Create)
/exercise-blocks/{id} → ExerciseBlockDetailPage (mit BlockEditor)
/exercise-blocks/{id}/edit → ExerciseBlockFormPage (Edit)
/admin/exercises/catalog → AdminCatalogPage (Fokusbereiche, Stile, Zielgruppen, Altersgruppen)
/admin/exercises/skills → AdminSkillsPage (Fähigkeiten-Matrix)
/admin/exercises/methods → AdminMethodsPage (Methoden-Katalog)
```
**URL-Parameter-Konventionen:**
- IDs immer numerisch: `/exercises/42` (nicht `/exercises/uuid`)
- Query-Parameter für Filter: `/exercises?focus_area=1&visibility=club`
- Pagination: `/exercises?limit=50&offset=100`
- Sortierung: `/exercises?sort=created_at&order=desc`
---
## 2. Navigation-Patterns
### 2.1 Haupt-Navigation (Desktop + Tablet)
**Primäre Nav (Top-Level):**
```
┌─────────────────────────────────────────┐
│ Shinkan │ Übungen │ Planung │ Admin │
└─────────────────────────────────────────┘
```
**Übungen-Submenu (Dropdown):**
```
Übungen
├─ Alle Übungen → /exercises
├─ Neue Übung → /exercises/new
├─ Meine Blocks → /exercise-blocks
├─ Neuer Block → /exercise-blocks/new
└─ Katalog verwalten → /admin/exercises/catalog (nur Admin)
```
### 2.2 Mobile Navigation (Hamburger-Menu)
```
☰ Menu
├─ Übungen
│ ├─ Alle anzeigen
│ ├─ Neue Übung
│ └─ Filter & Suche
├─ Planung
└─ Admin
└─ Katalog
```
### 2.3 Breadcrumbs (Detail/Edit-Seiten)
**Pattern:**
```
Home → Übungen → [Übungsname] → Bearbeiten
Home → Übungen → [Übungsname] → Variante anlegen
```
**Implementation:**
- Breadcrumbs dynamisch aus Route-Parametern generiert
- Letztes Element (aktuelle Seite) nicht klickbar
- Max. 4 Ebenen, dann `...` für gekürzte Pfade
---
## 3. State Management
### 3.1 URL-State (Query-Parameter)
**Filter-State in URL persistieren:**
```javascript
// ExercisesListPage.jsx
const [filters, setFilters] = useState({
focus_area: parseInt(searchParams.get('focus_area')) || null,
visibility: searchParams.get('visibility') || null,
status: searchParams.get('status') || null,
search: searchParams.get('search') || '',
limit: parseInt(searchParams.get('limit')) || 50,
offset: parseInt(searchParams.get('offset')) || 0,
})
// Bei Filter-Änderung → URL aktualisieren
const updateFilters = (newFilters) => {
const params = new URLSearchParams()
Object.entries(newFilters).forEach(([key, val]) => {
if (val) params.set(key, val)
})
navigate(`/exercises?${params.toString()}`)
}
```
**Vorteile:**
- Sharable URLs (Filter-Zustand übertragbar)
- Browser-Back funktioniert korrekt
- Keine verlorenen Filter beim Reload
### 3.2 Local State (React useState)
**Form-State (nicht in URL):**
```javascript
// ExerciseFormPage.jsx
const [formData, setFormData] = useState({
title: '',
summary: '',
goal: '',
execution: '',
// ... weitere Felder
})
```
**UI-State (nicht in URL):**
```javascript
// ExerciseDetailPage.jsx
const [expandedSections, setExpandedSections] = useState({
basics: true,
execution: false,
variants: false,
media: false,
})
```
### 3.3 Context (Global State)
**Katalog-Daten (einmalig laden, global verfügbar):**
```javascript
// CatalogContext.jsx
const CatalogContext = createContext()
export function CatalogProvider({ children }) {
const [focusAreas, setFocusAreas] = useState([])
const [trainingStyles, setTrainingStyles] = useState([])
const [targetGroups, setTargetGroups] = useState([])
const [ageGroups, setAgeGroups] = useState([])
const [skills, setSkills] = useState([])
useEffect(() => {
// Kataloge einmalig beim App-Start laden
Promise.all([
api.getFocusAreas(),
api.getTrainingStyles(),
api.getTargetGroups(),
api.getAgeGroups(),
api.getSkills(),
]).then(([fa, ts, tg, ag, sk]) => {
setFocusAreas(fa)
setTrainingStyles(ts)
setTargetGroups(tg)
setAgeGroups(ag)
setSkills(sk)
})
}, [])
return (
<CatalogContext.Provider value={{
focusAreas, trainingStyles, targetGroups, ageGroups, skills
}}>
{children}
</CatalogContext.Provider>
)
}
export const useCatalog = () => useContext(CatalogContext)
```
**Usage in Komponenten:**
```javascript
// ExerciseFormPage.jsx
const { focusAreas, trainingStyles } = useCatalog()
```
---
## 4. Deep Linking
### 4.1 Detail-Seite mit Accordion-State
**Problem:** Link teilen → Empfänger soll direkt zur "Varianten"-Sektion springen
**Lösung:** Hash-Fragment in URL:
```
/exercises/42#variants
/exercises/42#media
```
**Implementation:**
```javascript
// ExerciseDetailPage.jsx
useEffect(() => {
const hash = window.location.hash.replace('#', '')
if (hash && expandedSections[hash] !== undefined) {
setExpandedSections(prev => ({ ...prev, [hash]: true }))
// Scroll zur Sektion
setTimeout(() => {
document.getElementById(hash)?.scrollIntoView({ behavior: 'smooth' })
}, 100)
}
}, [])
```
### 4.2 Externe Verknüpfung aus Trainingsplan
**Szenario:** Trainingsplan-Editor soll Übung verlinken
**URL-Format:**
```
/exercises/42?from=training_plan&plan_id=7
```
**Navigation-Logik:**
```javascript
// ExerciseDetailPage.jsx
const searchParams = useSearchParams()
const returnUrl = searchParams.get('from') === 'training_plan'
? `/training-plans/${searchParams.get('plan_id')}`
: '/exercises'
// "Zurück"-Button zeigt entsprechendes Ziel
<button onClick={() => navigate(returnUrl)}>
← {returnUrl.includes('training') ? 'Zum Trainingsplan' : 'Zur Übersicht'}
</button>
```
---
## 5. Navigation Guards
### 5.1 Unsaved Changes Warning
**Pattern:**
```javascript
// ExerciseFormPage.jsx
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
// Bei Form-Änderung
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
setHasUnsavedChanges(true)
}
// Browser-Warning bei Reload/Close
useEffect(() => {
const handleBeforeUnload = (e) => {
if (hasUnsavedChanges) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [hasUnsavedChanges])
// React-Router Warning bei Navigation
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
hasUnsavedChanges &&
currentLocation.pathname !== nextLocation.pathname
)
useEffect(() => {
if (blocker.state === 'blocked') {
const confirmed = window.confirm(
'Du hast ungespeicherte Änderungen. Seite wirklich verlassen?'
)
if (confirmed) {
blocker.proceed()
} else {
blocker.reset()
}
}
}, [blocker])
```
### 5.2 Permission-Check (Admin-Routes)
**Pattern:**
```javascript
// ProtectedRoute.jsx
function ProtectedRoute({ children, requiredRole = 'admin' }) {
const { profile } = useAuth()
if (!profile) {
return <Navigate to="/login" />
}
if (profile.role !== requiredRole) {
return <Navigate to="/exercises" replace />
}
return children
}
// App.jsx
<Route path="/admin/exercises/*" element={
<ProtectedRoute requiredRole="admin">
<AdminExerciseRoutes />
</ProtectedRoute>
} />
```
---
## 6. Performance-Optimierung
### 6.1 Lazy Loading von Routen
**Pattern:**
```javascript
// App.jsx
const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/exercises" element={<ExercisesListPage />} />
<Route path="/exercises/:id" element={<ExerciseDetailPage />} />
<Route path="/exercises/:id/edit" element={<ExerciseFormPage />} />
</Routes>
</Suspense>
)
}
```
**Vorteil:** Initial Bundle kleiner, Routen nur bei Bedarf nachgeladen
### 6.2 Prefetching von Detail-Daten
**Pattern:**
```javascript
// ExercisesListPage.jsx - Hover-Prefetch
const handleCardHover = (exerciseId) => {
// Prefetch Detail-Daten beim Hover
queryClient.prefetchQuery(['exercise', exerciseId], () =>
api.getExercise(exerciseId)
)
}
<ExerciseCard
exercise={exercise}
onMouseEnter={() => handleCardHover(exercise.id)}
onClick={() => navigate(`/exercises/${exercise.id}`)}
/>
```
**Vorteil:** Detail-Seite lädt sofort, da Daten bereits im Cache
---
## 7. Error-Handling
### 7.1 404 - Exercise Not Found
**Pattern:**
```javascript
// ExerciseDetailPage.jsx
const { data: exercise, error, isLoading } = useQuery(
['exercise', exerciseId],
() => api.getExercise(exerciseId)
)
if (error?.response?.status === 404) {
return (
<NotFoundPage
title="Übung nicht gefunden"
message="Diese Übung existiert nicht oder wurde gelöscht."
backLink="/exercises"
backLabel="Zur Übersicht"
/>
)
}
```
### 7.2 403 - No Permission
**Pattern:**
```javascript
if (error?.response?.status === 403) {
return (
<ForbiddenPage
title="Keine Berechtigung"
message="Du hast keinen Zugriff auf diese Übung."
backLink="/exercises"
/>
)
}
```
### 7.3 Network Error
**Pattern:**
```javascript
if (error?.message === 'Network Error') {
return (
<ErrorPage
title="Verbindungsfehler"
message="Server nicht erreichbar. Bitte prüfe deine Internetverbindung."
retryAction={() => refetch()}
/>
)
}
```
---
## 8. Mobile-Spezifische Navigation
### 8.1 Bottom Navigation (Mobile)
**Pattern:**
```
┌─────────────────────────────────────┐
│ │
│ (Content Area) │
│ │
├─────────────────────────────────────┤
│ 🏠 Home │ 📋 Übungen │ 📅 Plan │
└─────────────────────────────────────┘
```
**Implementation:**
```javascript
// MobileBottomNav.jsx
<nav className="mobile-bottom-nav">
<NavLink to="/" className={({ isActive }) => isActive ? 'active' : ''}>
<Icon name="home" />
<span>Home</span>
</NavLink>
<NavLink to="/exercises">
<Icon name="list" />
<span>Übungen</span>
</NavLink>
<NavLink to="/training-plans">
<Icon name="calendar" />
<span>Pläne</span>
</NavLink>
</nav>
```
**CSS:**
```css
.mobile-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
z-index: 100;
}
@media (min-width: 768px) {
.mobile-bottom-nav {
display: none; /* Desktop nutzt Top-Nav */
}
}
```
### 8.2 Swipe-Navigation (Detail-Seite)
**Pattern:** Swipe left/right zwischen Übungen (gleicher Filter)
**Implementation:**
```javascript
// ExerciseDetailPage.jsx
const { exercises } = useExercisesList() // aus Context/Query
const currentIndex = exercises.findIndex(e => e.id === parseInt(exerciseId))
const swipeHandlers = useSwipeable({
onSwipedLeft: () => {
const nextExercise = exercises[currentIndex + 1]
if (nextExercise) navigate(`/exercises/${nextExercise.id}`)
},
onSwipedRight: () => {
const prevExercise = exercises[currentIndex - 1]
if (prevExercise) navigate(`/exercises/${prevExercise.id}`)
},
preventDefaultTouchmoveEvent: true,
trackMouse: false,
})
<div {...swipeHandlers}>
{/* Exercise Detail Content */}
</div>
```
---
## 9. Accessibility
### 9.1 Skip Links
**Pattern:**
```javascript
// App.jsx
<a href="#main-content" className="skip-link">
Zum Hauptinhalt springen
</a>
<main id="main-content">
{/* Content */}
</main>
```
**CSS:**
```css
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--accent);
color: white;
padding: 8px;
z-index: 1000;
}
.skip-link:focus {
top: 0;
}
```
### 9.2 Focus Management
**Pattern:** Bei Navigation → Focus auf Hauptüberschrift setzen
```javascript
// ExerciseDetailPage.jsx
const titleRef = useRef(null)
useEffect(() => {
titleRef.current?.focus()
}, [exerciseId])
<h1 ref={titleRef} tabIndex={-1}>
{exercise.title}
</h1>
```
### 9.3 ARIA Live-Region für Filter
**Pattern:**
```javascript
// ExercisesListPage.jsx
<div aria-live="polite" aria-atomic="true" className="sr-only">
{exercises.length} Übungen gefunden
</div>
```
---
## 10. Testing-Strategie
### 10.1 Route-Tests (Playwright)
```javascript
// exercises.routing.spec.js
test('Navigation zu Detail-Seite funktioniert', async ({ page }) => {
await page.goto('/exercises')
const firstCard = page.locator('.exercise-card').first()
await firstCard.click()
await expect(page).toHaveURL(/\/exercises\/\d+/)
await expect(page.locator('h1')).toBeVisible()
})
test('Filter-State bleibt in URL erhalten', async ({ page }) => {
await page.goto('/exercises?focus_area=1')
await page.reload()
const url = new URL(page.url())
expect(url.searchParams.get('focus_area')).toBe('1')
})
```
### 10.2 Navigation-Guard-Tests
```javascript
test('Unsaved Changes Warning', async ({ page }) => {
await page.goto('/exercises/new')
await page.fill('[name="title"]', 'Test Übung')
page.on('dialog', dialog => {
expect(dialog.message()).toContain('ungespeicherte')
dialog.accept()
})
await page.goto('/exercises')
})
```
---
## 11. Route-Konfiguration (React Router)
### 11.1 Vollständige Route-Definitionen
```javascript
// App.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
function App() {
return (
<BrowserRouter>
<CatalogProvider>
<Routes>
{/* Exercises Routes */}
<Route path="/exercises">
<Route index element={<ExercisesListPage />} />
<Route path="new" element={<ExerciseFormPage />} />
<Route path=":id" element={<ExerciseDetailPage />} />
<Route path=":id/edit" element={<ExerciseFormPage />} />
<Route path=":id/variants/new" element={<VariantFormPage />} />
<Route path=":id/variants/:variantId/edit" element={<VariantFormPage />} />
</Route>
{/* Exercise Blocks Routes */}
<Route path="/exercise-blocks">
<Route index element={<ExerciseBlocksListPage />} />
<Route path="new" element={<ExerciseBlockFormPage />} />
<Route path=":id" element={<ExerciseBlockDetailPage />} />
<Route path=":id/edit" element={<ExerciseBlockFormPage />} />
</Route>
{/* Admin Routes */}
<Route path="/admin/exercises" element={
<ProtectedRoute requiredRole="admin">
<Outlet />
</ProtectedRoute>
}>
<Route path="catalog" element={<AdminCatalogPage />} />
<Route path="skills" element={<AdminSkillsPage />} />
<Route path="methods" element={<AdminMethodsPage />} />
</Route>
{/* Redirects */}
<Route path="/" element={<Navigate to="/exercises" replace />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</CatalogProvider>
</BrowserRouter>
)
}
```
---
**Version:** 1.1
**Letzte Änderung:** 2026-04-24
**Status:** REVIEWED - Pending Implementation
**Review-Änderungen:** Exercise Blocks Routes + Navigation hinzugefügt

View File

@ -0,0 +1,370 @@
# KI-Funktionen Specification Exercises
**Version:** 1.1
**Datum:** 2026-04-24
**Status:** DRAFT
**Autor:** Claude Code
**Ä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)
---
## 1. Übersicht
Zwei KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen:
| Funktion | Ziel |
|---------|------|
| **KI-Zusammenfassung** | Generiert `summary` aus `goal` + `execution` (für Listen + Trainingspläne) |
| **KI-Skill-Empfehlung** | Schlägt passende Fähigkeiten + Stufen vor (reduziert manuelle Zuordungsarbeit) |
**Abhängigkeit:** OpenRouter API (`OPENROUTER_API_KEY` in `.env`).
Ist der Key nicht gesetzt, sind alle KI-Funktionen deaktiviert Formular funktioniert ohne KI normal.
---
## 2. UX-Prinzip: Manual First, Auto als Fallback
```
Trainer gibt goal + execution ein
├─► [KI-Vorschlag]-Button sichtbar
│ │
│ ▼ (Klick)
│ KI generiert sofort Vorschlag
│ → Trainer sieht Vorschlag inline
│ → Kann akzeptieren / bearbeiten / ablehnen
└─► Kein Klick bis zum Speichern?
▼ (automatisch beim POST/PUT)
KI läuft im Hintergrund
→ summary + skill_suggestions werden gesetzt
→ Trainer kann danach noch bearbeiten
```
**Regel:** Manuell generierte Werte (wo Trainer aktiv bestätigt hat) werden
beim Auto-Fallback NICHT überschrieben. Auto-generierte Werte werden als
`ai_generated: true` markiert und können jederzeit neu generiert oder überschrieben werden.
---
## 3. KI-Zusammenfassung (`summary`)
### 3.1 Was wird generiert
Aus `title` + `goal` + `execution` generiert die KI eine **kurze Zusammenfassung**
(2-4 Sätze, max. 200 Zeichen) für:
- Übungslisten (Karten-Ansicht)
- Trainingsplan-Darstellung (kompakte Anzeige)
- Export-Berichte
### 3.2 Prompt-Logik (Backend)
**Prompt ist NICHT hardcoded** er wird aus der `ai_prompts` Tabelle geladen
(Slug: `exercise_summary`). Admins können das Template anpassen.
```python
# Aufruf via zentralem AI-Service (siehe AI_PROMPT_SYSTEM_SPEC.md)
result = await ai_service.run_ai_prompt(
slug="exercise_summary",
context_data={"exercise": exercise_data},
db=db,
openrouter_key=settings.openrouter_api_key
)
```
**Default-Template** (in Migration 020 als `default_template` gespeichert):
Siehe `AI_PROMPT_SYSTEM_SPEC.md` §2.1 Prompt `exercise_summary`.
### 3.3 DB-Feld
Kein neues Feld nötig `exercises.summary` nimmt den generierten Text auf.
Zusätzliches Tracking-Feld: `summary_ai_generated BOOLEAN DEFAULT false`
---
## 4. KI-Skill-Empfehlung
### 4.1 Was wird empfohlen
Aus `title` + `goal` + `execution` + Katalog aller verfügbaren Skills empfiehlt die KI:
- **Welche Skills** relevant sind (aus dem bestehenden Skill-Katalog)
- **Erforderliche Stufe** (`required_level`) was der Trainierende mitbringen muss
- **Ziel-Stufe** (`target_level`) was durch regelmäßige Ausführung erreicht wird
- **Intensität** (`intensity`) wie stark diese Fähigkeit trainiert wird
### 4.2 Prompt-Logik (Backend)
**Prompt ist NICHT hardcoded** Slug: `exercise_skill_suggestions`.
Der `{{skills_catalog}}`-Platzhalter wird vom Backend automatisch mit der aktuellen
Skill-Liste aus der DB aufgelöst.
Admins können den Prompt anpassen (z.B. die Anweisung zur Auswahl verschärfen oder
fachliche Hinweise ergänzen). Siehe `AI_PROMPT_SYSTEM_SPEC.md`.
### 4.3 Bestätigungsflow (UX)
```
KI gibt Vorschläge
┌─────────────────────────────────────────┐
│ KI-Vorschläge (3 Fähigkeiten) │
│ ─────────────────────────────────────── │
│ ✓ Distanzgefühl │
│ Voraussetzung: Grundlagen │
│ Ziel: Aufbau · Intensität: Hoch │
│ [✓ Übernehmen] [Stufe ändern ▼] [✕] │
│ ─────────────────────────────────────── │
│ ✓ Reaktionsschnelligkeit │
│ Voraussetzung: Einsteiger │
│ Ziel: Grundlagen · Intensität: Mittel │
│ [✓ Übernehmen] [Stufe ändern ▼] [✕] │
│ ─────────────────────────────────────── │
│ ◦ Gleichgewicht (optional) │
│ Voraussetzung: Grundlagen │
│ Ziel: Grundlagen · Intensität: Niedrig│
│ [✓ Übernehmen] [Stufe ändern ▼] [✕] │
│ ─────────────────────────────────────── │
│ [Alle übernehmen] [Verwerfen] │
└─────────────────────────────────────────┘
```
- Jeder Vorschlag kann **einzeln** übernommen, angepasst oder abgelehnt werden
- „Alle übernehmen" nimmt alle aktuell angezeigten Vorschläge
- Stufen können per Dropdown direkt angepasst werden bevor sie übernommen werden
- Abgelehnte Vorschläge werden nicht gespeichert
---
## 5. API-Endpoints
### 5.1 Endpoints Overview
| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| POST | `/exercises/ai/suggest` | Vorschläge für neue Übung (vor dem Speichern) |
| POST | `/exercises/{id}/ai/regenerate` | Neu generieren für bestehende Übung |
---
### 5.2 `POST /exercises/ai/suggest`
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
**Request Body:**
```json
{
"title": "Maai - Distanzübung",
"goal": "Distanzgefühl entwickeln durch Partnerübungen...",
"execution": "1. Partnerwahl\n2. Ausgangsstellung..."
}
```
**Required Fields:** mindestens `goal` ODER `execution` (je länger, desto besser)
**Response:** `200 OK`
```json
{
"summary": {
"text": "Partnerübung zur Entwicklung des Distanzgefühls. Trainiert werden räumliche Wahrnehmung und reaktives Verhalten in der Kumite-Distanz.",
"ai_generated": true,
"model": "anthropic/claude-sonnet-4"
},
"skills": [
{
"skill_id": 10,
"skill_name": "Distanzgefühl",
"skill_category": "Kumite",
"required_level": "grundlagen",
"target_level": "aufbau",
"intensity": "hoch",
"is_primary": true,
"confidence": 0.92
},
{
"skill_id": 15,
"skill_name": "Reaktionsschnelligkeit",
"skill_category": "Athletik",
"required_level": "einsteiger",
"target_level": "grundlagen",
"intensity": "mittel",
"is_primary": false,
"confidence": 0.74
}
]
}
```
**Errors:**
- `400` - Zu wenig Text (goal und execution leer)
- `503` - KI nicht verfügbar (OPENROUTER_API_KEY fehlt)
---
### 5.3 `POST /exercises/{id}/ai/regenerate`
Generiert summary und/oder skill-Empfehlungen für eine bestehende Übung neu.
Verwendet die aktuellen gespeicherten Felder als Input.
**Request Body:**
```json
{
"regenerate": ["summary", "skills"]
}
```
**Werte:** `"summary"` | `"skills"` (oder beide)
**Response:** `200 OK` (gleiche Struktur wie `/ai/suggest`)
**Hinweis:** Regenerieren überschreibt NICHT Felder, die manuell gesetzt wurden
(`summary_ai_generated = false`). Es wird nur der Vorschlag zurückgegeben,
Trainer muss aktiv übernehmen.
---
## 6. Datenbank
### 6.1 Neues Feld: `summary_ai_generated`
```sql
-- Als Teil von Migration 014 ergänzen:
ALTER TABLE exercises
ADD COLUMN IF NOT EXISTS summary_ai_generated BOOLEAN DEFAULT false;
```
**Semantik:**
- `false` (default) = Zusammenfassung manuell geschrieben oder noch leer
- `true` = Zuletzt von KI generiert (kann überschrieben werden)
Beim manuellen Überschreiben durch den Trainer: `summary_ai_generated = false` setzen.
### 6.2 Kein Feld für Skill-Empfehlungen
Skill-Vorschläge der KI werden **nicht** persistent gespeichert sie erscheinen
nur im Formular-UI und werden erst bei expliziter Bestätigung als reguläre
`exercise_skills`-Einträge gespeichert.
---
## 7. Integration in Speicher-Flow
### 7.1 Auto-Fallback beim Speichern (POST/PUT exercises)
```python
# In backend/routers/exercises.py
async def create_exercise(data, session):
# 1. Übung normal speichern
exercise_id = db.insert_exercise(data)
# 2. Auto-KI nur wenn:
# - OPENROUTER_API_KEY gesetzt
# - summary noch leer
# - goal oder execution vorhanden
if settings.openrouter_api_key and not data.summary and (data.goal or data.execution):
# Background Task (non-blocking)
background_tasks.add_task(
auto_generate_summary,
exercise_id=exercise_id,
title=data.title,
goal=data.goal,
execution=data.execution
)
return exercise_id
```
### 7.2 Skill-Auto-Suggestion
Skill-Empfehlungen werden beim Auto-Fallback **NICHT** automatisch gespeichert
(wegen Bestätigungs-Pflicht). Sie werden nur beim manuellen Trigger angeboten.
---
## 8. Frontend-Integration
### 8.1 ExerciseFormPage KI-Buttons
```
┌──────────────────────────────────────┐
│ Ziel der Übung * │
│ ┌────────────────────────────────┐ │
│ │ Distanzgefühl entwickeln... │ │
│ └────────────────────────────────┘ │
│ │
│ Durchführung * │
│ ┌────────────────────────────────┐ │
│ │ 1. Partnerwahl... │ │
│ └────────────────────────────────┘ │
│ ↓ │
│ Zusammenfassung (für Listen) │
│ ┌────────────────────────────────┐ │
│ │ [Leer] │ │
│ └────────────────────────────────┘ │
│ [✨ KI-Vorschlag] ← Button │
│ │
│ ─────────────────────────────────── │
│ Fähigkeiten │
│ [Keine zugeordnet] │
│ [+ Manuell hinzufügen] │
│ [✨ KI-Empfehlungen] ← Button │
└──────────────────────────────────────┘
```
### 8.2 KI-Vorschlag für Summary (Inline)
Beim Klick auf „✨ KI-Vorschlag":
```
┌──────────────────────────────────────┐
│ ✨ KI-Vorschlag │
│ ─────────────────────────────────── │
│ "Partnerübung zur Entwicklung des │
│ Distanzgefühls. Trainiert räumliche │
│ Wahrnehmung und reaktives Verhalten."│
│ ─────────────────────────────────── │
│ [✓ Übernehmen] [✕ Verwerfen] │
└──────────────────────────────────────┘
```
Nach „Übernehmen": Text landet im Summary-Feld, Button zeigt „↺ Neu generieren".
### 8.3 Anzeige KI-generierter Inhalte
Felder die KI-generiert sind, werden mit einem kleinen Badge markiert:
```
Zusammenfassung ✨ KI-generiert [bearbeiten]
```
Nach manuellem Bearbeiten verschwindet der Badge.
---
## 9. Konfiguration
```env
OPENROUTER_API_KEY=sk-or-...
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Für Zusammenfassung
OPENROUTER_MODEL_FAST=anthropic/claude-haiku-4-5 # Für Skill-Empfehlungen (billiger)
```
---
## 10. Fehlerbehandlung
| Szenario | Verhalten |
|----------|-----------|
| OPENROUTER_API_KEY nicht gesetzt | KI-Buttons nicht anzeigen, kein Auto-Fallback |
| API-Timeout (> 10s) | Fehlermeldung inline: "KI momentan nicht verfügbar. Bitte manuell ausfüllen." |
| API-Fehler (5xx) | Gleiche Meldung, kein Absturz |
| Unbekannte Skills in Empfehlung | Backend filtert Skills die nicht im Katalog sind heraus |
| Summary zu lang generiert | Backend kürzt auf 200 Zeichen |
---
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT

View File

@ -0,0 +1,425 @@
# MediaWiki Import Specification
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT - Ready for Review
**Autor:** Claude Code
---
## 1. Übersicht
**Ziel:** Übungen aus dem Jinkendo MediaWiki-System direkt via API importieren
(NICHT über Export/XML-Dump siehe Architektur-Entscheidung in EXERCISES_ARCHITECTURE.md §8).
**Importrichtung:** Einseitig (MediaWiki → Shinkan). Kein bidirektionaler Sync.
**Import-Reihenfolge:** Skills → Methods → Exercises (Abhängigkeiten zuerst)
**Import-Tracking:** Jeder Import wird geloggt, Duplikate werden via `wiki_page_title`
erkannt und nicht doppelt angelegt.
---
## 2. MediaWiki API-Zugriff
### 2.1 API-Endpoint
```
GET https://karatetrainer.net/api.php
```
**Benötigte Parameter:**
- `action=query` Seiteninhalte abfragen
- `action=parse` Einzelseite parsen (mit Semantic MediaWiki Properties)
- `prop=text|properties|categories` Relevante Felder
- `format=json`
### 2.2 Authentifizierung
Aktuell: Kein Auth (wenn Wiki öffentlich zugänglich).
Bei privatem Wiki: `lgtoken` via `/api.php?action=login`.
### 2.3 Relevante API-Calls
**Alle Übungen einer Kategorie auflisten:**
```
GET /api.php?action=query&list=categorymembers&cmtitle=Kategorie:Übungen&cmlimit=500&format=json
```
**Einzelne Übung abrufen (mit SMW Properties):**
```
GET /api.php?action=parse&page=Übungsname&prop=text|properties&format=json
```
**SMW-Properties einer Seite:**
```
GET /api.php?action=browsebysubject&subject=Übungsname&format=json
```
---
## 3. Field-Mapping (MediaWiki → Exercises)
**Echte Property-Namen von karatetrainer.net** (via `discover_properties()` ermittelt, 2026-04-24)
### 3.1 Kern-Felder
| Wiki-Property (SMW) | Exercises-Feld | Transformation |
|---------------------|---------------|----------------|
| `Übungsbezeichnung` | `title` | Bevorzugt über Seitentitel |
| `Summary` | `summary` | Direkt |
| `Ziel` | `goal` | Direkt |
| `Durchführung` | `execution` | Wikitext → Plaintext |
| `Hinweise` | `trainer_notes` | Wikitext → Plaintext |
| `Plandauer` | `duration_min` = `duration_max` | Zahl in Minuten z.B. `"10"` |
| `Gruppengröße` | `group_size_min` | Zahl z.B. `"2"` |
| `Hilfsmittel` | `equipment` (JSONB) | Array von Strings |
### 3.2 Katalog-Mapping
| Wiki-Property | Exercises-Tabelle | Transformation |
|---------------|------------------|----------------|
| `Übungstyp` | `exercise_focus_areas` | z.B. `"Karate"` → focus_area Name → ID |
| `Zielgruppe` | `exercise_target_groups` | Name → ID Lookup |
| `Altersgruppe` | `exercise_age_groups` | Name → ID Lookup |
| `Trainingsmethode` | exercise method ref | Wiki-Seitenname → Label (Unterstriche → Leerzeichen) |
| `PrimaryCapability` | `exercise_skills` (Name) | Skill-Name → ID Lookup |
| `CapabilityLevel` | `exercise_skills` (target_level) | Integer → Named Level (1=einsteiger…5=experte) |
| `Schlüsselworte` | — | Keywords (für zukünftige Tag-Funktion) |
### 3.3 Skill-Level-Mapping (CapabilityLevel → Stufen)
| Wiki-Wert | Shinkan-Stufe |
|-----------|---------------|
| 1 | einsteiger |
| 2 | grundlagen |
| 3 | aufbau |
| 4 | fortgeschritten |
| 5 | experte |
### 3.4 Kategorien (karatetrainer.net)
| Import-Typ | Wiki-Kategorie |
|------------|---------------|
| exercises | `Übungen` |
| skills | `Fähigkeitsbeschreibung` |
| methods | `Methodenbeschreibung` |
**Unbekannte Katalog-Werte:** Warnmeldung im Import-Log, Übung wird ohne
diese Zuordnung importiert (kein Abbruch).
### 3.3 Import-Tracking-Felder
| Feld | Wert |
|------|------|
| `import_source` | `'mediawiki'` |
| `import_id` | Wiki-Seitenname (URL-kodiert) |
| `visibility` | `'private'` (Trainer prüft vor Veröffentlichung) |
| `status` | `'draft'` |
### 3.4 Wikitext-Bereinigung
Vor dem Speichern werden folgende Wikitext-Elemente in Plaintext umgewandelt:
- `[[Link|Text]]``Text`
- `[[Link]]``Link`
- `{{Template}}` → (entfernt)
- `'''Bold'''``Bold`
- `''Italic''``Italic`
- `==Überschrift==``\nÜberschrift\n`
- Wikitables → (entfernt, zu komplex für automatischen Import)
---
## 4. Tracking-Tabellen
```sql
-- Migration: wiki_import_tracking
-- (In eigene Migration auslagern: 018_wiki_import_tracking.sql)
CREATE TABLE IF NOT EXISTS wiki_import_log (
id SERIAL PRIMARY KEY,
import_type VARCHAR(50) NOT NULL, -- 'exercise', 'skill', 'method'
import_status VARCHAR(20) CHECK (import_status IN ('running', 'completed', 'failed')),
items_total INT DEFAULT 0,
items_imported INT DEFAULT 0,
items_skipped INT DEFAULT 0,
items_failed INT DEFAULT 0,
error_log JSONB DEFAULT '[]'::jsonb, -- Array von {item, error} Objekten
imported_by INT REFERENCES profiles(id),
started_at TIMESTAMP DEFAULT NOW(),
finished_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS wiki_import_references (
id SERIAL PRIMARY KEY,
wiki_page_title VARCHAR(500) NOT NULL,
wiki_page_id INT, -- MediaWiki interne ID
content_type VARCHAR(50) NOT NULL, -- 'exercise', 'skill', 'method'
local_id INT NOT NULL, -- ID in der lokalen DB
last_imported TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(wiki_page_title, content_type)
);
CREATE INDEX IF NOT EXISTS idx_wiki_refs_title ON wiki_import_references(wiki_page_title);
CREATE INDEX IF NOT EXISTS idx_wiki_refs_type ON wiki_import_references(content_type);
```
---
## 5. API-Endpoints
### 5.1 Endpoints Overview
| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| GET | `/import/mediawiki/preview` | Vorschau: Was wird importiert? |
| POST | `/import/mediawiki/execute` | Import ausführen |
| GET | `/import/mediawiki/status/{log_id}` | Import-Status abrufen |
| GET | `/import/mediawiki/logs` | Alle Import-Logs |
| DELETE | `/import/mediawiki/references/{id}` | Referenz löschen (für Re-Import) |
### 5.2 `GET /import/mediawiki/preview`
**Query Parameters:**
- `category` (string, required) Wiki-Kategorie z.B. `Übungen`
- `limit` (int, optional, default: 10) Maximale Anzahl zum Voranschauen
**Response:** `200 OK`
```json
{
"category": "Übungen",
"total_found": 47,
"preview": [
{
"wiki_page_title": "Maai - Distanzübung",
"wiki_page_id": 1234,
"already_imported": false,
"last_imported_at": null,
"mapped_fields": {
"title": "Maai - Distanzübung",
"summary": "Distanzgefühl entwickeln...",
"goal": "Ziel...",
"focus_area": "Karate",
"focus_area_found": true
},
"warnings": [],
"errors": []
},
{
"wiki_page_title": "Kumite Grundtechniken",
"already_imported": true,
"last_imported_at": "2026-04-10T10:00:00Z",
"mapped_fields": {...},
"warnings": ["Feld 'Zielgruppe' hat unbekannten Wert 'Fortgeschrittene' - wird ignoriert"],
"errors": []
}
]
}
```
---
### 5.3 `POST /import/mediawiki/execute`
**Request Body:**
```json
{
"category": "Übungen",
"reimport_existing": false, // true: Bereits importierte Übungen überschreiben
"dry_run": false, // true: Nur simulieren, nichts speichern
"limit": null // null: alle importieren
}
```
**Response:** `202 Accepted` (Import läuft asynchron)
```json
{
"log_id": 5,
"status": "running",
"message": "Import gestartet. Verwende GET /import/mediawiki/status/5 für Status."
}
```
---
### 5.4 `GET /import/mediawiki/status/{log_id}`
**Response:** `200 OK`
```json
{
"id": 5,
"import_type": "exercise",
"import_status": "completed",
"items_total": 47,
"items_imported": 44,
"items_skipped": 2,
"items_failed": 1,
"error_log": [
{
"item": "Unvollständige Übung",
"error": "Pflichtfeld 'Durchführung' fehlt in Wiki-Seite"
}
],
"imported_by": 1,
"started_at": "2026-04-24T10:00:00Z",
"finished_at": "2026-04-24T10:02:30Z"
}
```
---
### 5.5 `GET /import/mediawiki/logs`
**Response:** `200 OK` Array von Import-Log Objekten (ohne error_log Details)
---
## 6. Backend-Implementation
### 6.1 Router-Datei
`backend/routers/import_wiki.py`
### 6.2 Import-Prozess (Pseudocode)
```python
async def execute_wiki_import(category: str, reimport: bool, dry_run: bool):
# 1. Alle Seiten der Kategorie via MediaWiki API abrufen
pages = wiki_api.get_category_members(category)
for page_title in pages:
# 2. Duplikat-Check
existing_ref = db.get_wiki_ref(page_title, 'exercise')
if existing_ref and not reimport:
log.skip(page_title, "bereits importiert")
continue
# 3. Seite parsen
wiki_data = wiki_api.parse_page(page_title)
# 4. Field-Mapping
exercise_data = map_wiki_to_exercise(wiki_data)
# 5. Validierung
errors = validate_exercise(exercise_data)
if errors:
log.fail(page_title, errors)
continue
# 6. Katalog-IDs auflösen
exercise_data = resolve_catalog_ids(exercise_data)
if not dry_run:
# 7. Speichern
exercise_id = db.upsert_exercise(exercise_data)
# 8. Import-Referenz aktualisieren
db.upsert_wiki_ref(page_title, 'exercise', exercise_id)
log.success(page_title)
```
### 6.3 Wikitext Parser
```python
import re
def wikitext_to_plaintext(wikitext: str) -> str:
"""Konvertiert Wikitext in lesbares Plaintext."""
text = wikitext
# Links: [[Link|Text]] → Text, [[Link]] → Link
text = re.sub(r'\[\[([^|]+)\|([^\]]+)\]\]', r'\2', text)
text = re.sub(r'\[\[([^\]]+)\]\]', r'\1', text)
# Templates entfernen
text = re.sub(r'\{\{[^}]+\}\}', '', text)
# Formatierung
text = re.sub(r"'''(.+?)'''", r'\1', text)
text = re.sub(r"''(.+?)''", r'\1', text)
# Überschriften
text = re.sub(r'={2,6}\s*(.+?)\s*={2,6}', r'\n\1\n', text)
# Mehrfache Leerzeilen normalisieren
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
```
---
## 7. Frontend-Import-UI
### 7.1 Route
```
/admin/import/mediawiki → AdminWikiImportPage
```
### 7.2 Layout
```
┌─────────────────────────────────────────┐
│ MediaWiki Import │
│ ─────────────────────────────────────── │
│ Wiki-Kategorie: [Übungen ] │
│ [+ Vorschau anzeigen] │
│ ─────────────────────────────────────── │
│ Vorschau (10 von 47): │
│ │
│ ✅ Maai - Distanzübung (neu) │
│ ✅ Kizami-Zuki (neu) │
│ ⚠️ Kumite XY (bereits importiert) │
│ ❌ Defekte Seite (Pflichtfeld fehlt) │
│ ... │
│ ─────────────────────────────────────── │
│ ☐ Bereits importierte überschreiben │
│ ☐ Nur simulieren (dry run) │
│ ─────────────────────────────────────── │
│ [47 Übungen importieren] │
│ ─────────────────────────────────────── │
│ Letzter Import: 44/47 · 2026-04-10 │
│ [Import-Logs anzeigen] │
└─────────────────────────────────────────┘
```
---
## 8. Fehlerbehandlung
| Fehler | Verhalten |
|--------|-----------|
| Wiki-Seite nicht erreichbar | Import-Prozess abbricht, Log-Eintrag |
| Pflichtfeld (`goal`, `execution`) fehlt | Item wird übersprungen, Warnung im Log |
| Unbekannter Katalog-Wert (Fokusbereich etc.) | Item wird ohne diese Zuordnung importiert |
| Duplikat-Titel im selben Club | Item wird übersprungen (außer reimport=true) |
| Wiki-API Timeout | Retry 3x, dann fail mit Log-Eintrag |
---
## 9. Sicherheit
- Nur Super-Admin darf Import ausführen (`require_role('superadmin')`)
- MediaWiki-URL ist konfigurierbar via `.env` (kein Hardcoding)
- Rate-Limiting: max. 1 Import parallel, max. 10/Tag
- Importierte Übungen starten immer als `visibility='private'`, `status='draft'`
---
## 10. Konfiguration (`.env`)
```
MEDIAWIKI_API_URL=https://karatetrainer.net/api.php
MEDIAWIKI_USER=import-bot # optional, für privates Wiki
MEDIAWIKI_PASSWORD=... # optional
```
---
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT - Ready for Review

View File

@ -0,0 +1,778 @@
# Media Upload & Embed Specification
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT - Awaiting Review
**Autor:** Claude Code
---
## 1. Upload-Strategie
### 1.1 Hybrid-Ansatz
**Zwei Medienquellen:**
1. **Lokale Uploads:** Datei-Upload auf Server (Images, Videos, PDFs, Sketches)
2. **Embeds:** Einbetten von YouTube, Instagram, Vimeo, TikTok
**Vorteile:**
- Lokale Kontrolle über kritische Inhalte
- Keine Abhängigkeit von Drittanbietern für Kern-Content
- Externe Plattformen für ergänzende Videos
**Limitierungen:**
- Max. 50 MB pro Datei
- Max. 10 Media-Items pro Übung (kombiniert Local + Embeds)
- Erlaubte Formate: JPEG, PNG, GIF, MP4, PDF
---
## 2. Upload-Flow (Lokale Dateien)
### 2.1 Frontend-Komponente: MediaUploader
**Props:**
```typescript
interface MediaUploaderProps {
exerciseId: number
existingMedia: Media[]
onUploadComplete: (media: Media) => void
maxFiles?: number // default: 10
maxSizeMB?: number // default: 50
}
```
**UI-Struktur:**
```jsx
<MediaUploader>
<div className="upload-zone">
{/* Drag & Drop Area */}
<input type="file" multiple accept="image/*,video/mp4,application/pdf" />
{/* Preview Grid */}
<div className="media-grid">
{uploadQueue.map(file => (
<div key={file.name} className="upload-preview">
<img src={file.preview} />
<ProgressBar percent={file.uploadProgress} />
<button onClick={() => removeFromQueue(file)}>×</button>
</div>
))}
</div>
{/* Metadata Form (per File) */}
<div className="upload-metadata">
<input name="title" placeholder="Titel (optional)" />
<textarea name="description" placeholder="Beschreibung" />
<select name="context">
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
<label>
<input type="checkbox" name="is_primary" />
Als Hauptbild markieren
</label>
</div>
<button onClick={handleUpload}>
{uploadQueue.length} Dateien hochladen
</button>
</div>
</MediaUploader>
```
### 2.2 Client-seitige Validierung
**Vor Upload prüfen:**
```javascript
// MediaUploader.jsx
const validateFile = (file) => {
const errors = []
// Größe
const maxSize = 50 * 1024 * 1024 // 50 MB
if (file.size > maxSize) {
errors.push(`${file.name} ist zu groß (max. 50 MB)`)
}
// MIME-Type
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'video/mp4', 'application/pdf']
if (!allowed.includes(file.type)) {
errors.push(`${file.name} hat ungültiges Format`)
}
// Video-spezifisch: Auflösung prüfen
if (file.type === 'video/mp4') {
const video = document.createElement('video')
video.src = URL.createObjectURL(file)
video.onloadedmetadata = () => {
if (video.videoWidth > 1920 || video.videoHeight > 1080) {
errors.push(`${file.name} Auflösung zu hoch (max. 1920x1080)`)
}
}
}
return errors
}
const handleFilesSelected = (files) => {
const validFiles = []
const errors = []
files.forEach(file => {
const fileErrors = validateFile(file)
if (fileErrors.length === 0) {
validFiles.push(file)
} else {
errors.push(...fileErrors)
}
})
if (errors.length > 0) {
setValidationErrors(errors)
}
setUploadQueue(prev => [...prev, ...validFiles])
}
```
### 2.3 Upload mit Progress
**multipart/form-data Upload:**
```javascript
const uploadFile = async (file, metadata) => {
const formData = new FormData()
formData.append('file', file)
formData.append('media_type', detectMediaType(file))
formData.append('title', metadata.title || '')
formData.append('description', metadata.description || '')
formData.append('context', metadata.context || 'ablauf')
formData.append('is_primary', metadata.is_primary || false)
return axios.post(
`/api/exercises/${exerciseId}/media`,
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
const percent = (progressEvent.loaded / progressEvent.total) * 100
setUploadProgress(file.name, percent)
}
}
)
}
const detectMediaType = (file) => {
if (file.type.startsWith('image/')) return 'image'
if (file.type === 'video/mp4') return 'video'
if (file.type === 'application/pdf') return 'document'
return 'sketch' // Fallback
}
```
### 2.4 Backend-Verarbeitung
**Endpoint:** `POST /api/exercises/{exercise_id}/media`
```python
from fastapi import UploadFile, File, Form
import magic # python-magic für MIME-Detection
import hashlib
import os
@router.post("/exercises/{exercise_id}/media")
async def upload_media(
exercise_id: int,
file: UploadFile = File(...),
media_type: str = Form(...),
title: str = Form(default=""),
description: str = Form(default=""),
context: str = Form(default="ablauf"),
is_primary: bool = Form(default=False),
session: dict = Depends(require_auth)
):
profile_id = session['profile_id']
# 1. Berechtigung prüfen
check_exercise_permission(exercise_id, profile_id)
# 2. Datei-Validierung
max_size = 50 * 1024 * 1024 # 50 MB
file_content = await file.read()
if len(file_content) > max_size:
raise HTTPException(413, "Datei zu groß (max. 50 MB)")
# 3. MIME-Type verifizieren (nicht nur Extension!)
mime = magic.from_buffer(file_content, mime=True)
allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'video/mp4', 'application/pdf']
if mime not in allowed_mimes:
raise HTTPException(400, f"Ungültiger Dateityp: {mime}")
# 4. Eindeutigen Dateinamen generieren
file_hash = hashlib.sha256(file_content).hexdigest()[:12]
file_ext = os.path.splitext(file.filename)[1]
safe_filename = f"{file_hash}_{exercise_id}{file_ext}"
# 5. Speichern
file_path = f"/app/media/exercises/{safe_filename}"
with open(file_path, 'wb') as f:
f.write(file_content)
# 6. DB-Eintrag
cur = get_db_cursor()
cur.execute("""
INSERT INTO exercise_media (
exercise_id, media_type, file_path, file_size, mime_type,
original_filename, title, description, context, is_primary, sort_order
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)
)
RETURNING id, sort_order, created_at
""", (
exercise_id, media_type, file_path, len(file_content), mime,
file.filename, title, description, context, is_primary, exercise_id
))
result = cur.fetchone()
return {
"id": result['id'],
"exercise_id": exercise_id,
"media_type": media_type,
"file_path": file_path,
"file_size": len(file_content),
"mime_type": mime,
"original_filename": file.filename,
"title": title,
"description": description,
"context": context,
"is_primary": is_primary,
"sort_order": result['sort_order'],
"created_at": result['created_at'].isoformat(),
}
```
---
## 3. Embed-Flow (YouTube, Instagram, Vimeo)
### 3.1 Frontend-Komponente: EmbedInput
**UI:**
```jsx
<div className="embed-input">
<input
type="url"
placeholder="YouTube, Instagram oder Vimeo URL einfügen"
value={embedUrl}
onChange={(e) => setEmbedUrl(e.target.value)}
onBlur={handleEmbedParse}
/>
{preview && (
<div className="embed-preview">
<iframe src={preview.embedUrl} />
<div className="embed-metadata">
<strong>{preview.platform}</strong> • {preview.title}
</div>
</div>
)}
<div className="embed-metadata-form">
<input name="title" placeholder="Titel (optional)" />
<textarea name="description" />
<select name="context">
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
</select>
</div>
<button onClick={handleEmbedSave}>Embed hinzufügen</button>
</div>
```
### 3.2 URL-Parsing (Client-Side)
**Unterstützte Formate:**
```javascript
const parseEmbedUrl = (url) => {
// YouTube
if (url.includes('youtube.com/watch?v=')) {
const videoId = new URL(url).searchParams.get('v')
return {
platform: 'youtube',
videoId,
embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}`,
originalUrl: url,
}
}
if (url.includes('youtu.be/')) {
const videoId = url.split('youtu.be/')[1].split('?')[0]
return {
platform: 'youtube',
videoId,
embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}`,
originalUrl: url,
}
}
// Vimeo
if (url.includes('vimeo.com/')) {
const videoId = url.split('vimeo.com/')[1].split('?')[0]
return {
platform: 'vimeo',
videoId,
embedUrl: `https://player.vimeo.com/video/${videoId}`,
originalUrl: url,
}
}
// Instagram (Reel oder Post)
if (url.includes('instagram.com/reel/') || url.includes('instagram.com/p/')) {
const postId = url.match(/\/(reel|p)\/([^\/\?]+)/)[2]
return {
platform: 'instagram',
postId,
embedUrl: `https://www.instagram.com/p/${postId}/embed`,
originalUrl: url,
}
}
// TikTok
if (url.includes('tiktok.com/')) {
const videoId = url.match(/video\/(\d+)/)?.[1]
return {
platform: 'tiktok',
videoId,
embedUrl: `https://www.tiktok.com/embed/v2/${videoId}`,
originalUrl: url,
}
}
return null
}
const handleEmbedParse = async () => {
const parsed = parseEmbedUrl(embedUrl)
if (!parsed) {
setError('Ungültige URL. Unterstützt: YouTube, Vimeo, Instagram, TikTok')
return
}
// Optional: Metadata via oEmbed API holen
const metadata = await fetchOembedData(parsed)
setPreview({ ...parsed, ...metadata })
}
```
### 3.3 oEmbed Metadata (Optional)
**YouTube oEmbed:**
```javascript
const fetchYoutubeMetadata = async (videoId) => {
const response = await fetch(
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`
)
const data = await response.json()
return {
title: data.title,
author: data.author_name,
thumbnail: data.thumbnail_url,
}
}
```
**Vimeo oEmbed:**
```javascript
const fetchVimeoMetadata = async (videoId) => {
const response = await fetch(
`https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}`
)
const data = await response.json()
return {
title: data.title,
author: data.author_name,
thumbnail: data.thumbnail_url,
}
}
```
### 3.4 Backend-Speicherung (Embed)
**Endpoint:** `POST /api/exercises/{exercise_id}/media` (gleicher Endpoint)
```python
@router.post("/exercises/{exercise_id}/media")
async def add_media(
exercise_id: int,
# Entweder file ODER embed_url (nicht beides)
file: UploadFile = File(default=None),
embed_url: str = Form(default=None),
media_type: str = Form(...),
title: str = Form(default=""),
description: str = Form(default=""),
context: str = Form(default="ablauf"),
session: dict = Depends(require_auth)
):
if file and embed_url:
raise HTTPException(400, "Entweder file oder embed_url, nicht beides")
if not file and not embed_url:
raise HTTPException(400, "file oder embed_url erforderlich")
if embed_url:
# Embed-URL validieren
platform = detect_platform(embed_url)
if not platform:
raise HTTPException(400, "Ungültige Embed-URL")
# DB-Eintrag (ohne file_path)
cur.execute("""
INSERT INTO exercise_media (
exercise_id, media_type, embed_url, embed_platform,
title, description, context, is_primary, sort_order
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s,
COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)
)
RETURNING id, sort_order, created_at
""", (
exercise_id, media_type, embed_url, platform,
title, description, context, False, exercise_id
))
# ... return result
# ... else: file upload (siehe 2.4)
def detect_platform(url):
if 'youtube.com' in url or 'youtu.be' in url:
return 'youtube'
if 'vimeo.com' in url:
return 'vimeo'
if 'instagram.com' in url:
return 'instagram'
if 'tiktok.com' in url:
return 'tiktok'
return None
```
---
## 4. Media-Galerie (Anzeige)
### 4.1 Komponente: MediaGallery
**Props:**
```typescript
interface MediaGalleryProps {
media: Media[]
primaryMediaId?: number
onMediaClick?: (media: Media) => void
layout?: 'grid' | 'list' | 'carousel'
showControls?: boolean
}
```
**Render-Logik:**
```jsx
<div className="media-gallery">
{/* Primary Media (groß oben) */}
{primaryMedia && (
<div className="primary-media">
{renderMedia(primaryMedia, 'large')}
</div>
)}
{/* Grid mit restlichen Medien */}
<div className="media-grid">
{secondaryMedia.map(media => (
<div key={media.id} className="media-item">
{renderMedia(media, 'thumbnail')}
<div className="media-overlay">
<span className="media-title">{media.title}</span>
<span className="media-context">{media.context}</span>
</div>
</div>
))}
</div>
</div>
const renderMedia = (media, size) => {
// Lokale Datei
if (media.file_path) {
if (media.media_type === 'image') {
return <img src={`/media/${media.file_path}`} alt={media.title} />
}
if (media.media_type === 'video') {
return <video src={`/media/${media.file_path}`} controls />
}
if (media.media_type === 'document') {
return <a href={`/media/${media.file_path}`} target="_blank">📄 {media.title}</a>
}
}
// Embed
if (media.embed_url) {
return (
<iframe
src={media.embed_url}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
)
}
}
```
### 4.2 Lightbox (Vollbild-Ansicht)
**Pattern:**
```jsx
const [lightboxMedia, setLightboxMedia] = useState(null)
<MediaGallery
media={exercise.media}
onMediaClick={(media) => setLightboxMedia(media)}
/>
{lightboxMedia && (
<Lightbox
media={lightboxMedia}
onClose={() => setLightboxMedia(null)}
onPrev={() => setLightboxMedia(getPrevMedia(lightboxMedia))}
onNext={() => setLightboxMedia(getNextMedia(lightboxMedia))}
/>
)}
```
---
## 5. Drag & Drop Reordering
### 5.1 Frontend-Komponente
**Nutzt DragDropList aus UI_COMPONENTS_SPEC.md:**
```jsx
<DragDropList
items={media}
onReorder={handleMediaReorder}
renderItem={(media) => (
<div className="media-reorder-item">
<DragHandle />
<img src={getThumbnail(media)} />
<span>{media.title || media.original_filename}</span>
{media.is_primary && <Badge>Hauptbild</Badge>}
</div>
)}
/>
const handleMediaReorder = async (reorderedMedia) => {
const mediaIds = reorderedMedia.map(m => m.id)
try {
await api.reorderExerciseMedia(exerciseId, mediaIds)
setMedia(reorderedMedia)
} catch (error) {
console.error('Reorder failed:', error)
}
}
```
### 5.2 Backend-Endpoint
**Endpoint:** `PUT /api/exercises/{exercise_id}/media/reorder`
```python
@router.put("/exercises/{exercise_id}/media/reorder")
def reorder_media(
exercise_id: int,
request: dict, # {"media_ids": [3, 1, 2]}
session: dict = Depends(require_auth)
):
profile_id = session['profile_id']
check_exercise_permission(exercise_id, profile_id)
media_ids = request.get('media_ids', [])
# Validierung: Alle IDs gehören zur Exercise
cur = get_db_cursor()
cur.execute("""
SELECT id FROM exercise_media WHERE exercise_id = %s
""", (exercise_id,))
existing_ids = {row['id'] for row in cur.fetchall()}
if set(media_ids) != existing_ids:
raise HTTPException(400, "media_ids inkonsistent")
# Reorder
for idx, media_id in enumerate(media_ids, start=1):
cur.execute("""
UPDATE exercise_media SET sort_order = %s WHERE id = %s
""", (idx, media_id))
return {"ok": True, "reordered": len(media_ids)}
```
---
## 6. Security & Performance
### 6.1 Security
**File Upload:**
- MIME-Type-Validierung mit `python-magic` (nicht nur Extension)
- Max. Dateigröße serverseitig prüfen
- Virus-Scan optional (ClamAV) bei Prod-System
- Keine Ausführungsrechte auf Upload-Ordner
**Embed:**
- Nur Whitelist-Domains (youtube.com, vimeo.com, instagram.com, tiktok.com)
- Keine User-Content-Domains (pastebin, file-sharing)
### 6.2 Performance
**Thumbnails generieren (Videos):**
```python
import ffmpeg
def generate_video_thumbnail(video_path, output_path):
(
ffmpeg
.input(video_path, ss='00:00:01') # 1 Sekunde ins Video
.filter('scale', 320, -1)
.output(output_path, vframes=1)
.run()
)
```
**Responsive Images (automatisch generieren):**
```python
from PIL import Image
def create_responsive_sizes(image_path):
img = Image.open(image_path)
sizes = {
'thumbnail': (320, 240),
'medium': (800, 600),
'large': (1920, 1080),
}
for size_name, (width, height) in sizes.items():
img_resized = img.copy()
img_resized.thumbnail((width, height), Image.LANCZOS)
output_path = image_path.replace('.jpg', f'_{size_name}.jpg')
img_resized.save(output_path, quality=85)
```
**Frontend nutzt `srcset`:**
```jsx
<img
src={`/media/${media.file_path}`}
srcSet={`
/media/${media.file_path.replace('.jpg', '_thumbnail.jpg')} 320w,
/media/${media.file_path.replace('.jpg', '_medium.jpg')} 800w,
/media/${media.file_path} 1920w
`}
sizes="(max-width: 600px) 320px, (max-width: 1200px) 800px, 1920px"
/>
```
---
## 7. Error-Handling
### 7.1 Upload-Fehler
**Frontend:**
```javascript
try {
await uploadFile(file, metadata)
} catch (error) {
if (error.response?.status === 413) {
setError('Datei zu groß (max. 50 MB)')
} else if (error.response?.status === 400) {
setError(error.response.data.detail)
} else {
setError('Upload fehlgeschlagen. Bitte erneut versuchen.')
}
}
```
**Backend:**
```python
# Detaillierte Fehlermeldungen
if len(file_content) > max_size:
raise HTTPException(413, f"Datei zu groß: {len(file_content) / (1024*1024):.1f} MB (max. 50 MB)")
if mime not in allowed_mimes:
raise HTTPException(400, f"Dateityp {mime} nicht erlaubt. Erlaubt: {', '.join(allowed_mimes)}")
```
### 7.2 Embed-Fehler
**Ungültige URL:**
```javascript
if (!parseEmbedUrl(url)) {
setError('URL nicht erkannt. Unterstützte Plattformen: YouTube, Vimeo, Instagram, TikTok')
}
```
**Embed lädt nicht:**
```javascript
<iframe
src={embedUrl}
onError={() => {
setEmbedError('Video konnte nicht geladen werden. Ist das Video öffentlich?')
}}
/>
```
---
## 8. Testing
### 8.1 Upload-Tests
```javascript
// upload.spec.js
test('Upload von JPEG-Datei funktioniert', async ({ page }) => {
await page.goto('/exercises/1/edit')
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles('test-files/image.jpg')
await page.click('button:has-text("Hochladen")')
await expect(page.locator('.media-gallery img')).toBeVisible()
})
test('Upload > 50 MB wird abgelehnt', async ({ page }) => {
await page.goto('/exercises/1/edit')
await page.setInputFiles('input[type="file"]', 'test-files/large-video.mp4')
await expect(page.locator('.error:has-text("zu groß")')).toBeVisible()
})
```
### 8.2 Embed-Tests
```javascript
test('YouTube URL wird korrekt geparst', async ({ page }) => {
await page.goto('/exercises/1/edit')
await page.fill('input[placeholder*="YouTube"]', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ')
await page.blur('input[placeholder*="YouTube"]')
await expect(page.locator('iframe[src*="youtube-nocookie.com"]')).toBeVisible()
})
```
---
**Version:** 1.0
**Letzte Änderung:** 2026-04-24
**Status:** DRAFT - Awaiting Review

View File

@ -0,0 +1,760 @@
# Search & Filter Specification
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT - Awaiting Review
**Autor:** Claude Code
---
## 1. Suchstrategie
### 1.1 Multi-Layer Search
**Drei Suchebenen:**
1. **Keyword-Search:** Titel, Summary, Execution (PostgreSQL tsvector)
2. **Katalog-Filter:** Focus Area, Style, Target Group, Age Group, Skill
3. **Meta-Filter:** Visibility, Status, Creator, Club
**Kombination:** Alle Filter UND Keyword-Search kombinierbar
---
## 2. Volltext-Suche (PostgreSQL)
### 2.1 tsvector Implementation
**Migration:** Erweitert `exercises` Tabelle
```sql
-- Migration 014 (Teil)
ALTER TABLE exercises ADD COLUMN search_vector tsvector;
-- Index für Volltext-Suche
CREATE INDEX idx_exercises_search ON exercises USING gin(search_vector);
-- Trigger für automatische Updates
CREATE OR REPLACE FUNCTION update_exercises_search_vector()
RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('german', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('german', COALESCE(NEW.summary, '')), 'B') ||
setweight(to_tsvector('german', COALESCE(NEW.execution, '')), 'C') ||
setweight(to_tsvector('german', COALESCE(NEW.trainer_notes, '')), 'D');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE TRIGGER exercises_search_update
BEFORE INSERT OR UPDATE ON exercises
FOR EACH ROW EXECUTE FUNCTION update_exercises_search_vector();
```
**Gewichtung:**
- A (höchste): Titel
- B: Summary
- C: Execution
- D (niedrigste): Trainer Notes
### 2.2 Backend-Query
**Endpoint:** `GET /api/exercises?search=<keyword>`
```python
@router.get("/exercises")
def list_exercises(
search: str = None,
focus_area: int = None,
visibility: str = None,
status: str = None,
skill_id: int = None,
limit: int = 50,
offset: int = 0,
session: dict = Depends(require_auth)
):
profile_id = session['profile_id']
conditions = []
params = []
# Volltext-Suche
if search:
conditions.append("""
search_vector @@ plainto_tsquery('german', %s)
""")
params.append(search)
# Katalog-Filter
if focus_area:
conditions.append("""
EXISTS (
SELECT 1 FROM exercise_focus_areas
WHERE exercise_id = exercises.id AND focus_area_id = %s
)
""")
params.append(focus_area)
# Meta-Filter
if visibility:
conditions.append("visibility = %s")
params.append(visibility)
if status:
conditions.append("status = %s")
params.append(status)
# Skill-Filter
if skill_id:
conditions.append("""
EXISTS (
SELECT 1 FROM exercise_skills
WHERE exercise_id = exercises.id AND skill_id = %s
)
""")
params.append(skill_id)
# WHERE-Clause zusammenbauen
where_clause = " AND ".join(conditions) if conditions else "1=1"
# Query mit Ranking (bei Volltext-Suche)
if search:
query = f"""
SELECT exercises.*,
ts_rank(search_vector, plainto_tsquery('german', %s)) AS rank
FROM exercises
WHERE {where_clause}
ORDER BY rank DESC, created_at DESC
LIMIT %s OFFSET %s
"""
params = [search] + params + [limit, offset]
else:
query = f"""
SELECT exercises.* FROM exercises
WHERE {where_clause}
ORDER BY created_at DESC
LIMIT %s OFFSET %s
"""
params = params + [limit, offset]
cur = get_db_cursor()
cur.execute(query, params)
return [r2d(row) for row in cur.fetchall()]
```
### 2.3 Highlighting (Optional)
**Snippet mit Treffer-Markierung:**
```python
def get_search_snippet(exercise, search_term):
"""Generiert Snippet mit <mark>-Tags um Treffer"""
cur = get_db_cursor()
cur.execute("""
SELECT ts_headline('german', execution,
plainto_tsquery('german', %s),
'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=30'
) AS snippet
FROM exercises WHERE id = %s
""", (search_term, exercise['id']))
result = cur.fetchone()
return result['snippet'] if result else exercise['summary']
```
**Response mit Snippet:**
```json
{
"id": 1,
"title": "Maai - Distanzübung",
"summary": "...",
"snippet": "...Partner hält <mark>Pratzen</mark>, Ausführung wie Haupt..."
}
```
---
## 3. Frontend-Filter-UI
### 3.1 Komponente: SearchFilterBar
**Layout:**
```
┌────────────────────────────────────────────────────────┐
│ 🔍 [Suche...] [Filter ▼] [×] │
├────────────────────────────────────────────────────────┤
│ Aktive Filter: │
│ [Karate ×] [Breitensport ×] [Freigegeben ×] │
└────────────────────────────────────────────────────────┘
```
**Code:**
```jsx
function SearchFilterBar({ onFiltersChange, initialFilters = {} }) {
const [search, setSearch] = useState(initialFilters.search || '')
const [showFilterPanel, setShowFilterPanel] = useState(false)
const [filters, setFilters] = useState(initialFilters)
const { focusAreas, trainingStyles, targetGroups } = useCatalog()
const handleSearchChange = debounce((value) => {
const newFilters = { ...filters, search: value }
setFilters(newFilters)
onFiltersChange(newFilters)
}, 300)
const handleFilterChange = (key, value) => {
const newFilters = { ...filters, [key]: value }
setFilters(newFilters)
onFiltersChange(newFilters)
}
const clearFilter = (key) => {
const newFilters = { ...filters }
delete newFilters[key]
setFilters(newFilters)
onFiltersChange(newFilters)
}
const clearAllFilters = () => {
setFilters({})
setSearch('')
onFiltersChange({})
}
return (
<div className="search-filter-bar">
{/* Suchfeld */}
<div className="search-input">
<SearchIcon />
<input
type="search"
placeholder="Übungen durchsuchen..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
handleSearchChange(e.target.value)
}}
/>
</div>
{/* Filter-Toggle */}
<button
className="btn-filter"
onClick={() => setShowFilterPanel(!showFilterPanel)}
>
Filter {Object.keys(filters).length > 0 && `(${Object.keys(filters).length})`}
<ChevronIcon direction={showFilterPanel ? 'up' : 'down'} />
</button>
{/* Clear-Button */}
{Object.keys(filters).length > 0 && (
<button className="btn-clear" onClick={clearAllFilters}>
×
</button>
)}
{/* Aktive Filter (Chips) */}
{Object.keys(filters).length > 0 && (
<div className="active-filters">
<span>Aktive Filter:</span>
{filters.focus_area && (
<Chip
label={focusAreas.find(f => f.id === filters.focus_area)?.name}
onRemove={() => clearFilter('focus_area')}
/>
)}
{filters.visibility && (
<Chip
label={filters.visibility}
onRemove={() => clearFilter('visibility')}
/>
)}
{filters.status && (
<Chip
label={filters.status}
onRemove={() => clearFilter('status')}
/>
)}
</div>
)}
{/* Filter-Panel (aufklappbar) */}
{showFilterPanel && (
<div className="filter-panel">
<div className="filter-section">
<label>Fokusbereich</label>
<select
value={filters.focus_area || ''}
onChange={(e) => handleFilterChange('focus_area', parseInt(e.target.value) || null)}
>
<option value="">Alle</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.name}</option>
))}
</select>
</div>
<div className="filter-section">
<label>Stil</label>
<select
value={filters.training_style || ''}
onChange={(e) => handleFilterChange('training_style', parseInt(e.target.value) || null)}
>
<option value="">Alle</option>
{trainingStyles.map(ts => (
<option key={ts.id} value={ts.id}>{ts.name}</option>
))}
</select>
</div>
<div className="filter-section">
<label>Sichtbarkeit</label>
<select
value={filters.visibility || ''}
onChange={(e) => handleFilterChange('visibility', e.target.value || null)}
>
<option value="">Alle</option>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
</select>
</div>
<div className="filter-section">
<label>Status</label>
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || null)}
>
<option value="">Alle</option>
<option value="draft">Entwurf</option>
<option value="in_review">In Prüfung</option>
<option value="approved">Freigegeben</option>
<option value="archived">Archiviert</option>
</select>
</div>
</div>
)}
</div>
)
}
```
### 3.2 Debouncing (Performance)
**Verhindert API-Call bei jedem Tastendruck:**
```javascript
function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
```
**Nur nach 300ms Pause wird gesucht → weniger Server-Last**
---
## 4. Erweiterte Filter
### 4.1 Multi-Select-Filter
**Beispiel: Mehrere Zielgruppen gleichzeitig:**
```jsx
<MultiSelect
options={targetGroups}
selected={filters.target_groups || []}
onChange={(selected) => handleFilterChange('target_groups', selected)}
label="Zielgruppen"
placeholder="Wähle eine oder mehrere..."
/>
```
**Backend-Query:**
```python
if target_groups: # List von IDs
conditions.append("""
EXISTS (
SELECT 1 FROM exercise_target_groups
WHERE exercise_id = exercises.id
AND target_group_id = ANY(%s)
)
""")
params.append(target_groups)
```
### 4.2 Range-Filter (Dauer, Gruppengröße)
**UI:**
```jsx
<div className="filter-range">
<label>Dauer (Minuten)</label>
<input
type="number"
placeholder="Min"
value={filters.duration_min || ''}
onChange={(e) => handleFilterChange('duration_min', parseInt(e.target.value) || null)}
/>
<span>bis</span>
<input
type="number"
placeholder="Max"
value={filters.duration_max || ''}
onChange={(e) => handleFilterChange('duration_max', parseInt(e.target.value) || null)}
/>
</div>
```
**Backend-Query:**
```python
if duration_min:
conditions.append("duration_max >= %s")
params.append(duration_min)
if duration_max:
conditions.append("duration_min <= %s")
params.append(duration_max)
```
### 4.3 Equipment-Filter (JSONB)
**UI:**
```jsx
<div className="filter-equipment">
<label>
<input
type="checkbox"
checked={filters.no_equipment || false}
onChange={(e) => handleFilterChange('no_equipment', e.target.checked)}
/>
Ohne Hilfsmittel
</label>
</div>
```
**Backend-Query:**
```python
if no_equipment:
conditions.append("""
(equipment IS NULL OR equipment = '[]'::jsonb)
""")
```
---
## 5. Faceted Search (Treffer-Zählung pro Filter)
### 5.1 Backend-Aggregation
**Zeigt Anzahl Treffer pro Filter-Option:**
```python
@router.get("/exercises/facets")
def get_facets(search: str = None, session: dict = Depends(require_auth)):
base_condition = "1=1"
params = []
if search:
base_condition = "search_vector @@ plainto_tsquery('german', %s)"
params = [search]
cur = get_db_cursor()
# Focus Area Facets
cur.execute(f"""
SELECT fa.id, fa.name, COUNT(DISTINCT e.id) as count
FROM focus_areas fa
LEFT JOIN exercise_focus_areas efa ON fa.id = efa.focus_area_id
LEFT JOIN exercises e ON efa.exercise_id = e.id
WHERE {base_condition}
GROUP BY fa.id, fa.name
ORDER BY count DESC
""", params)
focus_areas = [r2d(r) for r in cur.fetchall()]
# Visibility Facets
cur.execute(f"""
SELECT visibility, COUNT(*) as count
FROM exercises
WHERE {base_condition}
GROUP BY visibility
""", params)
visibility = [r2d(r) for r in cur.fetchall()]
return {
"focus_areas": focus_areas,
"visibility": visibility,
# ... weitere Facets
}
```
### 5.2 Frontend-Anzeige
**Zeigt Treffer-Anzahl neben Filter:**
```jsx
<select>
<option value="">Alle</option>
{facets.focus_areas.map(fa => (
<option key={fa.id} value={fa.id}>
{fa.name} ({fa.count})
</option>
))}
</select>
```
---
## 6. Saved Searches / Presets
### 6.1 Favoriten-Filter speichern
**UI:**
```jsx
<button onClick={handleSaveFilter}>
⭐ Filter speichern
</button>
{savedFilters.map(sf => (
<button
key={sf.id}
onClick={() => loadFilter(sf)}
className="saved-filter"
>
{sf.name}
</button>
))}
```
**Backend-Tabelle:**
```sql
CREATE TABLE saved_exercise_searches (
id SERIAL PRIMARY KEY,
profile_id INT REFERENCES profiles(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
filters JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
```
**API:**
```python
@router.post("/exercises/saved-searches")
def save_search(
name: str,
filters: dict,
session: dict = Depends(require_auth)
):
profile_id = session['profile_id']
cur = get_db_cursor()
cur.execute("""
INSERT INTO saved_exercise_searches (profile_id, name, filters)
VALUES (%s, %s, %s)
RETURNING id, created_at
""", (profile_id, name, json.dumps(filters)))
result = cur.fetchone()
return {
"id": result['id'],
"name": name,
"filters": filters,
"created_at": result['created_at'].isoformat(),
}
```
---
## 7. Sortierung
### 7.1 Sortier-Optionen
**UI:**
```jsx
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="relevance">Relevanz</option>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
<option value="title_asc">Titel A-Z</option>
<option value="title_desc">Titel Z-A</option>
<option value="duration">Dauer (kurz → lang)</option>
</select>
```
### 7.2 Backend-Sortierung
**Query-Parameter:** `?sort=newest`
```python
SORT_OPTIONS = {
'relevance': 'rank DESC', # nur bei Volltext-Suche
'newest': 'created_at DESC',
'oldest': 'created_at ASC',
'title_asc': 'title ASC',
'title_desc': 'title DESC',
'duration': 'duration_min ASC',
}
@router.get("/exercises")
def list_exercises(sort: str = 'newest', ...):
order_by = SORT_OPTIONS.get(sort, 'created_at DESC')
# Bei Volltext-Suche IMMER nach Relevanz sortieren
if search and sort != 'relevance':
order_by = f"rank DESC, {order_by}"
query = f"""
SELECT ... FROM exercises
WHERE {where_clause}
ORDER BY {order_by}
LIMIT %s OFFSET %s
"""
```
---
## 8. Performance-Optimierung
### 8.1 Indizes
**Wichtige Indizes:**
```sql
-- Volltext-Suche
CREATE INDEX idx_exercises_search ON exercises USING gin(search_vector);
-- Häufige Filter
CREATE INDEX idx_exercises_visibility ON exercises(visibility);
CREATE INDEX idx_exercises_status ON exercises(status);
CREATE INDEX idx_exercises_created_at ON exercises(created_at DESC);
-- M:N Relations
CREATE INDEX idx_exercise_focus_areas_focus ON exercise_focus_areas(focus_area_id);
CREATE INDEX idx_exercise_skills_skill ON exercise_skills(skill_id);
```
### 8.2 Query-Caching (Backend)
**Cachen häufiger Abfragen:**
```python
from functools import lru_cache
import hashlib
@lru_cache(maxsize=100)
def get_exercises_cached(filters_hash: str):
# filters_hash = SHA256 von JSON(filters)
# Cached für 60 Sekunden
pass
def list_exercises(filters: dict):
filters_hash = hashlib.sha256(
json.dumps(filters, sort_keys=True).encode()
).hexdigest()
return get_exercises_cached(filters_hash)
```
### 8.3 Client-Side Caching (React Query)
**Nutzt React Query für intelligentes Caching:**
```jsx
const { data: exercises, isLoading } = useQuery(
['exercises', filters],
() => api.listExercises(filters),
{
staleTime: 5 * 60 * 1000, // 5 Minuten fresh
cacheTime: 10 * 60 * 1000, // 10 Minuten im Cache
}
)
```
---
## 9. Mobile-Optimierung
### 9.1 Filter-Drawer (Mobile)
**Statt Dropdown → Fullscreen-Drawer:**
```jsx
{/* Desktop: Inline-Panel */}
<div className="filter-panel desktop-only">
{/* Filter-Felder */}
</div>
{/* Mobile: Drawer */}
<Drawer
isOpen={showFilterDrawer}
onClose={() => setShowFilterDrawer(false)}
position="bottom"
>
<div className="filter-drawer">
<h3>Filter</h3>
{/* Filter-Felder */}
<button onClick={applyFilters}>
{resultsCount} Ergebnisse anzeigen
</button>
</div>
</Drawer>
```
### 9.2 Sticky Search-Bar
**Bleibt beim Scrollen oben:**
```css
.search-filter-bar {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 12px;
}
```
---
## 10. Testing
### 10.1 Volltext-Suche-Tests
```javascript
test('Volltext-Suche findet Übung im Titel', async ({ page }) => {
await page.goto('/exercises')
await page.fill('input[type="search"]', 'Maai')
await expect(page.locator('.exercise-card:has-text("Maai")')).toBeVisible()
})
test('Volltext-Suche findet Übung in Execution', async ({ page }) => {
await page.goto('/exercises')
await page.fill('input[type="search"]', 'Pratzen')
await expect(page.locator('.exercise-card')).toHaveCount(1)
})
```
### 10.2 Filter-Kombination-Tests
```javascript
test('Filter kombinierbar: Focus Area + Visibility', async ({ page }) => {
await page.goto('/exercises')
await page.selectOption('select[name="focus_area"]', '1') // Karate
await page.selectOption('select[name="visibility"]', 'club')
const cards = page.locator('.exercise-card')
await expect(cards).toHaveCount(3)
})
```
---
**Version:** 1.0
**Letzte Änderung:** 2026-04-24
**Status:** DRAFT - Awaiting Review

View File

@ -0,0 +1,454 @@
# Fähigkeitsmatrix / Reifegradmodell Specification
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT
**Autor:** Claude Code
**Basis:** shinkan_anforderungsdokument_entwurf.md §8 + DOMAIN_MODEL.md
---
## 1. Konzept
### 1.1 Was ist die Fähigkeitsmatrix?
Die Fähigkeitsmatrix verbindet **globale Fähigkeiten** (Skills) mit **kontextspezifischen
Reifegradmodellen**. Sie beantwortet pro Kontext die Frage:
> „Was muss ein Schüler auf Stufe X dieser Fähigkeit können, und welche Übungen trainieren ihn von Stufe A auf Stufe B?"
**Drei Ebenen:**
```
1. Globale Fähigkeit (skill)
"Distanzgefühl" gilt überall, für alle Stile und Zielgruppen
2. Reifegradmodell (maturity_model)
"Karate / Shotokan / Breitensport" definiert WIE VIELE Stufen es gibt
und wie die Stufen heißen
3. Modell-Fähigkeitsstufe (model_skill_level)
"Stufe 3 von Distanzgefühl im Shotokan-Breitensport-Modell"
beschreibt KONKRET was auf Stufe 3 erwartet wird
```
### 1.2 Warum kontextabhängig?
Dieselbe Fähigkeit (z.B. „Distanzgefühl") hat in verschiedenen Kontexten
unterschiedliche Bedeutungen:
| Kontext | Stufe 3 Bedeutung |
|---------|-------------------|
| Karate / Shotokan / Breitensport | Distanzkontrolle in einfachen Partnerübungen |
| Karate / Shotokan / Leistungssport | Stabile Distanz im freien Kumite |
| Selbstverteidigung / Erwachsene | Sicherheitsabstand in Alltagssituationen einschätzen |
Ein Modell hat **variable Stufenanzahl** (3-7, nicht fest 5):
```
Shotokan Breitensport → 5 Stufen (Einsteiger bis Experte)
Kinder-Karate → 4 Stufen (Kinder-gerecht vereinfacht)
Leistungssport → 7 Stufen (feiner granuliert)
```
### 1.3 Abgrenzung zu exercise_skills
```
exercise_skills: Übung trainiert Fähigkeit mit Intensität und
suggested required/target Stufe
→ kontextunabhängig (gilt für alle Modelle)
model_skill_levels: Für dieses Modell bedeutet "Stufe 3" von Fähigkeit X:
"[konkrete Beschreibung]"
→ kontextspezifisch
```
---
## 2. Datenmodell
### 2.1 Entity-Übersicht
```
focus_areas (global, existiert)
└─ style_directions (existiert, früher training_styles)
└─ target_groups (M:N, existiert)
maturity_models (NEU)
├─ focus_area_id FK (optional, NULL = gilt für alle Fokusbereiche)
├─ style_direction_id FK (optional)
├─ target_group_id FK (optional)
└─ level_count (INT) wie viele Stufen hat das Modell?
model_levels (NEU) Stufen-Definitionen
├─ maturity_model_id FK
├─ level_number (1 bis level_count)
├─ name (z.B. "Einsteiger", "Grundlagen", ...)
└─ description
model_skill_levels (NEU) Was bedeutet Stufe X für Fähigkeit Y im Modell Z?
├─ maturity_model_id FK
├─ skill_id FK
├─ level_number (muss in maturity_model.level_count liegen)
├─ description (Pflicht-Text: was soll der Lernende können?)
├─ observable_criteria (Beobachtungskriterien für Trainer)
└─ example_exercises JSONB (Empfohlene Übungs-Typen, keine FK)
```
### 2.2 Vollständige Migration (019_maturity_models.sql)
```sql
-- Migration 019: Fähigkeitsmatrix / Reifegradmodelle
-- Autor: Claude Code
-- Datum: 2026-04-24
DO $$
BEGIN
-- ============================================================================
-- MATURITY MODELS (Reifegradmodelle)
-- ============================================================================
CREATE TABLE IF NOT EXISTS maturity_models (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
-- Kontext-Bindung (alle optional NULL bedeutet "gilt allgemein")
-- Je mehr gesetzt, desto spezifischer das Modell
focus_area_id INT REFERENCES focus_areas(id) ON DELETE RESTRICT,
style_direction_id INT REFERENCES style_directions(id) ON DELETE RESTRICT,
target_group_id INT REFERENCES target_groups(id) ON DELETE RESTRICT,
-- Stufenanzahl (flexibel, nicht fest auf 5)
level_count INT NOT NULL DEFAULT 5 CHECK (level_count BETWEEN 3 AND 10),
-- Sichtbarkeit & Freigabe
-- 'draft': in Bearbeitung | 'active': in Nutzung | 'archived': nicht mehr aktiv
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
-- Versionierung
version VARCHAR(20) DEFAULT '1.0',
-- Ownership
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
-- Import-Tracking (für Semantic MediaWiki Import)
import_source VARCHAR(50),
import_id VARCHAR(200),
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
-- Constraint: Name + Kontext eindeutig
UNIQUE(name, focus_area_id, style_direction_id, target_group_id)
);
-- ============================================================================
-- MODEL LEVELS (Stufendefinitionen pro Modell)
-- ============================================================================
CREATE TABLE IF NOT EXISTS model_levels (
id SERIAL PRIMARY KEY,
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
level_number INT NOT NULL CHECK (level_number >= 1),
name VARCHAR(100) NOT NULL, -- z.B. "Einsteiger", "Grundlagen", "Aufbau"
description TEXT, -- Was zeichnet diese Stufe generell aus?
sort_order INT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(maturity_model_id, level_number)
);
-- ============================================================================
-- MODEL SKILL LEVELS
-- Was bedeutet "Stufe X der Fähigkeit Y im Modell Z"?
-- ============================================================================
CREATE TABLE IF NOT EXISTS model_skill_levels (
id SERIAL PRIMARY KEY,
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
skill_id INT NOT NULL REFERENCES skills(id) ON DELETE RESTRICT,
level_number INT NOT NULL CHECK (level_number >= 1),
-- Was wird auf dieser Stufe erwartet? (Pflichtfeld)
description TEXT NOT NULL,
-- Konkrete Beobachtungskriterien für Trainer
-- z.B. "Kann Distanz in ruhigen Partnerübungen halten (3/3 Versuchen)"
observable_criteria TEXT,
-- Empfohlene Übungs-Typen (keine FK, nur Hinweise)
-- z.B. {"types": ["Grundübung", "Partnerübung"], "max_duration_min": 15}
example_exercise_hints JSONB,
-- KI-generiert?
ai_generated BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(maturity_model_id, skill_id, level_number),
-- level_number muss im Bereich des Modells liegen
-- (wird per Trigger oder Backend-Validierung geprüft)
CONSTRAINT ck_level_positive CHECK (level_number > 0)
);
-- ============================================================================
-- SKILL CATALOG: Felder für Matrix-Nutzung (falls noch nicht vorhanden)
-- ============================================================================
-- Fähigkeiten können einem bestimmten Fokusbereich "zugehören" (primär)
-- bleiben aber global nutzbar (secondary assignments über M:N)
ALTER TABLE skills
ADD COLUMN IF NOT EXISTS primary_focus_area_id INT REFERENCES focus_areas(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS is_cross_domain BOOLEAN DEFAULT false; -- gilt für alle Fokusbereiche
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_maturity_models_focus ON maturity_models(focus_area_id);
CREATE INDEX IF NOT EXISTS idx_maturity_models_style ON maturity_models(style_direction_id);
CREATE INDEX IF NOT EXISTS idx_maturity_models_target ON maturity_models(target_group_id);
CREATE INDEX IF NOT EXISTS idx_maturity_models_status ON maturity_models(status);
CREATE INDEX IF NOT EXISTS idx_model_levels_model ON model_levels(maturity_model_id);
CREATE INDEX IF NOT EXISTS idx_model_skill_levels_model ON model_skill_levels(maturity_model_id);
CREATE INDEX IF NOT EXISTS idx_model_skill_levels_skill ON model_skill_levels(skill_id);
CREATE INDEX IF NOT EXISTS idx_model_skill_levels_combo ON model_skill_levels(maturity_model_id, skill_id);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
DROP TRIGGER IF EXISTS maturity_models_update ON maturity_models;
CREATE TRIGGER maturity_models_update
BEFORE UPDATE ON maturity_models
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
DROP TRIGGER IF EXISTS model_skill_levels_update ON model_skill_levels;
CREATE TRIGGER model_skill_levels_update
BEFORE UPDATE ON model_skill_levels
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
RAISE NOTICE 'Migration 019 completed successfully (Maturity Models / Fähigkeitsmatrix)';
END $$;
```
---
## 3. Beispiel-Daten
### 3.1 Beispiel-Modell: Karate Shotokan Breitensport (5 Stufen)
```sql
INSERT INTO maturity_models (name, focus_area_id, style_direction_id, target_group_id, level_count, status)
VALUES ('Karate Shotokan Breitensport', 1, 1, 1, 5, 'active');
-- Stufennamen
INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order) VALUES
(1, 1, 'Einsteiger', 'Erste Begegnung keine Vorkenntnisse erforderlich', 1),
(1, 2, 'Grundlagen', 'Grundprinzipien bekannt, in ruhigen Situationen anwendbar', 2),
(1, 3, 'Aufbau', 'Semi-intuitiver Einsatz, mit gelegentlicher Korrektur', 3),
(1, 4, 'Fortgeschritten', 'Intuitiver Einsatz auch unter Druck', 4),
(1, 5, 'Experte', 'Vollständig automatisiert, stabile Leistung auf Spitzenniveau', 5);
-- Fähigkeitsstufen: Distanzgefühl im Shotokan-Breitensport-Modell
INSERT INTO model_skill_levels (maturity_model_id, skill_id, level_number, description, observable_criteria)
VALUES
(1, 10, 1, 'Versteht das Konzept "Distanz" und kann erklären warum es wichtig ist.',
'Kann auf Frage benennen, was Maai bedeutet.'),
(1, 10, 2, 'Hält in langsamen, geführten Partnerübungen die Angriffsdistanz.',
'In 3 von 3 langsamen Wiederholungen korrekte Distanz eingenommen.'),
(1, 10, 3, 'Kontrolliert Distanz in mittlerer Geschwindigkeit bei Standardtechniken.',
'Trainer bewertet Distanzkontrolle mit "gut" in Kihon-Ippon-Kumite.'),
(1, 10, 4, 'Stabiler Einsatz im Jiyu-Ippon-Kumite, selten vom Partner überrascht.',
'In freiem Jiyu-Kumite hält Schüler Linie in >70% der Aktionen.'),
(1, 10, 5, 'Feingefühl für minimale Distanzveränderungen, taktischer Einsatz im Shiai.',
'Wettkampferfolge durch bewusstes Distanzmanagement nachweisbar.');
```
---
## 4. API-Endpoints
### 4.1 Übersicht
| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| GET | `/maturity-models` | Liste aller Modelle (mit Filter) |
| GET | `/maturity-models/{id}` | Modell-Detail mit Stufen + Skills |
| POST | `/maturity-models` | Neues Modell erstellen (Admin) |
| PUT | `/maturity-models/{id}` | Modell bearbeiten (Admin) |
| DELETE | `/maturity-models/{id}` | Modell löschen (Admin) |
| GET | `/maturity-models/{id}/skills` | Alle Fähigkeiten + Stufen dieses Modells |
| PUT | `/maturity-models/{id}/skills/{skill_id}/levels` | Stufen-Beschreibungen setzen (Admin) |
| GET | `/maturity-models/resolve` | Bestes Modell für Kontext finden |
### 4.2 `GET /maturity-models/resolve`
Findet das spezifischste Modell für einen gegebenen Kontext.
Wird von der KI und vom Frontend verwendet.
**Query Parameters:**
- `focus_area_id` (int, optional)
- `style_direction_id` (int, optional)
- `target_group_id` (int, optional)
**Response:**
```json
{
"model": {
"id": 1,
"name": "Karate Shotokan Breitensport",
"level_count": 5,
"match_specificity": "full"
},
"fallback_used": false
}
```
**Auflösungslogik (Most Specific First):**
1. Alle drei passen (focus + style + target) → `match_specificity: "full"`
2. focus + style passen → `match_specificity: "style"`
3. Nur focus passt → `match_specificity: "focus"`
4. Allgemeines Modell (alle FK = NULL) → `match_specificity: "generic"`
5. Kein Modell vorhanden → `match_specificity: null`, 404
---
## 5. Verbindung zu Übungen
### 5.1 Wie exercise_skills das Modell referenziert
`exercise_skills` speichert `required_level` und `target_level` als **Stufen-Namen**
(einsteiger/grundlagen/aufbau/fortgeschritten/experte), die modellunabhängig sind.
Das Frontend kann bei Bedarf die konkrete Modell-Beschreibung nachladen:
```
exercise_skills.required_level = "grundlagen"
→ GET /maturity-models/resolve?focus_area_id=1
→ Model: Karate Shotokan Breitensport (level_count=5)
→ model_levels: level_number=2, name="Grundlagen"
→ model_skill_levels für skill_id=10: "Hält in ruhigen Partnerübungen Distanz"
```
### 5.2 Darstellung in der Übungsdetail-Ansicht
```
Fähigkeiten
─────────────────────────────────────────────
★ Distanzgefühl (primär)
Voraussetzung: Grundlagen → Ziel: Aufbau
Intensität: Hoch
────────────────────────────────
Grundlagen: "Hält in ruhigen Partnerübungen
Distanz"
Aufbau: "Kontrolle in mittlerer
Geschwindigkeit"
[Kontext: Shotokan Breitensport ▼]
─────────────────────────────────────────────
◦ Reaktionsschnelligkeit (sekundär)
Voraussetzung: Einsteiger → Ziel: Grundlagen
Intensität: Mittel
```
**Kontext-Dropdown:** Trainer kann Modell wechseln um kontextspezifische Beschreibungen zu sehen.
---
## 6. KI-Unterstützung für Modell-Pflege
Die KI kann `model_skill_levels`-Beschreibungen vorschlagen.
**Prompt-Placeholder:**
- `{{skill_name}}` Name der Fähigkeit
- `{{model_name}}` Name des Reifegradmodells
- `{{level_count}}` Anzahl der Stufen
- `{{level_names}}` Namen der Stufen (z.B. "Einsteiger, Grundlagen, Aufbau...")
- `{{focus_area}}` Fokusbereich
- `{{target_group}}` Zielgruppe
**Endpoint:** `POST /maturity-models/{id}/ai/suggest-skill-levels`
```json
// Request
{
"skill_id": 10,
"regenerate_existing": false
}
// Response
{
"skill": {"id": 10, "name": "Distanzgefühl"},
"suggestions": [
{
"level_number": 1,
"level_name": "Einsteiger",
"description": "...",
"observable_criteria": "...",
"ai_generated": true
},
...
]
}
```
---
## 7. Semantic MediaWiki Import
Die bestehende Fähigkeitsmatrix im SMW enthält bereits Stufen-Beschreibungen.
**Erweiterung der MEDIAWIKI_IMPORT_SPEC.md:**
| Wiki-Feld | Ziel-Tabelle | Transformation |
|-----------|-------------|----------------|
| SMW Property: `Stufe 1 Beschreibung` | `model_skill_levels.description` (level=1) | Direkt |
| SMW Property: `Stufe N Beschreibung` | `model_skill_levels.description` (level=N) | Direkt |
| SMW Property: `Modell` | `maturity_models.name` | Lookup/Create |
| SMW Property: `Fokusbereich` | `maturity_models.focus_area_id` | Name → ID |
**Import-Endpoint:** `POST /import/mediawiki/execute` mit `import_type: "maturity_model"`
---
## 8. Admin-Workflow
### 8.1 Modell anlegen (Admin)
```
1. Admin wählt: Fokusbereich + Stil + Zielgruppe
2. Admin legt Stufenanzahl + Stufennamen fest
3. Admin weist Fähigkeiten dem Modell zu (Pflicht-/Optional-Skills)
4. Für jede Fähigkeit × Stufe: Beschreibung eingeben
→ Optional: KI-Vorschlag generieren
5. Modell auf "active" stellen
```
### 8.2 SMW-Migration-Workflow
```
1. SMW-Import: Bestehende Modelle und Beschreibungen importieren
2. Review: Trainer prüft importierte Beschreibungen
3. KI-Ergänzung: Für fehlende Stufen KI-Vorschläge generieren
4. Freigabe: status → 'active'
```
---
**Version:** 1.0
**Datum:** 2026-04-24
**Status:** DRAFT
**Nächste Schritte:**
- SMW-Struktur analysieren (User legt Daten ins Repo)
- Migration 019 implementieren
- Admin-UI für Modellpflege spezifizieren (UI_COMPONENTS_SPEC.md ergänzen)

File diff suppressed because it is too large Load Diff

View File

@ -23,3 +23,13 @@ ENVIRONMENT=production
# Media Storage
MEDIA_DIR=/app/media
# MediaWiki Import (Semantic MediaWiki)
MEDIAWIKI_API_URL=https://karatetrainer.net/api.php
MEDIAWIKI_USER=Jinkendo
MEDIAWIKI_PASSWORD=CHANGE_ME
# Kategorienamen im Wiki (echte Namen von karatetrainer.net)
MEDIAWIKI_CATEGORY_EXERCISES=Übungen
MEDIAWIKI_CATEGORY_SKILLS=Fähigkeitsbeschreibung
MEDIAWIKI_CATEGORY_METHODS=Methodenbeschreibung
MEDIAWIKI_CATEGORY_MODELS=Reifegradmodelle

View File

@ -70,7 +70,7 @@ def read_root():
}
# Register routers
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, import_wiki
app.include_router(auth.router)
app.include_router(profiles.router)
@ -79,6 +79,7 @@ app.include_router(clubs.router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(catalogs.router)
app.include_router(import_wiki.router)
if __name__ == "__main__":
import uvicorn

View File

@ -0,0 +1,40 @@
-- Migration 018: MediaWiki Import Tracking Tables
-- Tracks import runs and cross-references between Wiki pages and local DB entries
CREATE TABLE IF NOT EXISTS wiki_import_log (
id SERIAL PRIMARY KEY,
import_type VARCHAR(50) NOT NULL
CHECK (import_type IN ('exercise', 'skill', 'method', 'maturity_model')),
import_status VARCHAR(20) NOT NULL DEFAULT 'running'
CHECK (import_status IN ('running', 'completed', 'failed')),
category VARCHAR(200), -- Wiki category name used
dry_run BOOLEAN DEFAULT false,
reimport BOOLEAN DEFAULT false,
items_total INT DEFAULT 0,
items_imported INT DEFAULT 0,
items_skipped INT DEFAULT 0,
items_failed INT DEFAULT 0,
error_log JSONB DEFAULT '[]'::jsonb, -- [{item, error}, ...]
imported_by INT REFERENCES profiles(id),
started_at TIMESTAMP DEFAULT NOW(),
finished_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wiki_import_references (
id SERIAL PRIMARY KEY,
wiki_page_title VARCHAR(500) NOT NULL,
wiki_page_id INT, -- MediaWiki internal page ID
content_type VARCHAR(50) NOT NULL
CHECK (content_type IN ('exercise', 'skill', 'method', 'maturity_model')),
local_id INT NOT NULL, -- ID in the local DB table
last_imported TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(wiki_page_title, content_type)
);
CREATE INDEX IF NOT EXISTS idx_wiki_refs_title ON wiki_import_references(wiki_page_title);
CREATE INDEX IF NOT EXISTS idx_wiki_refs_type ON wiki_import_references(content_type);
CREATE INDEX IF NOT EXISTS idx_wiki_log_status ON wiki_import_log(import_status);
CREATE INDEX IF NOT EXISTS idx_wiki_log_type ON wiki_import_log(import_type);

View File

@ -0,0 +1,637 @@
"""
MediaWiki Import Router
Importiert Übungen, Fähigkeiten und Methoden aus Semantic MediaWiki
direkt via API (kein XML-Export). Nur Super-Admin darf importieren.
Wiki-URL: https://karatetrainer.net/api.php
"""
import asyncio
import logging
import os
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Query
from pydantic import BaseModel
from db import get_db, get_cursor, r2d
from auth import require_auth
from smw_client import SmwClient, SmwClientError
from smw_mapper import map_wiki_to_exercise, map_wiki_to_skill, map_wiki_to_method, build_skill_assignments
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["import"])
# Kategorie-Namen aus .env (echte Namen von karatetrainer.net)
CATEGORY_EXERCISES = os.getenv("MEDIAWIKI_CATEGORY_EXERCISES", "Übungen")
CATEGORY_SKILLS = os.getenv("MEDIAWIKI_CATEGORY_SKILLS", "Fähigkeitsbeschreibung")
CATEGORY_METHODS = os.getenv("MEDIAWIKI_CATEGORY_METHODS", "Methodenbeschreibung")
CATEGORY_MODELS = os.getenv("MEDIAWIKI_CATEGORY_MODELS", "Reifegradmodelle")
# ------------------------------------------------------------------ #
# Pydantic Models #
# ------------------------------------------------------------------ #
class ImportExecuteRequest(BaseModel):
category: str
import_type: str = "exercise" # exercise | skill | method
reimport_existing: bool = False
dry_run: bool = False
limit: Optional[int] = None
# ------------------------------------------------------------------ #
# Auth-Hilfsfunktion #
# ------------------------------------------------------------------ #
def require_admin(session: dict = Depends(require_auth)) -> dict:
"""Nur Super-Admins dürfen importieren."""
if session.get("role") not in ("admin", "superadmin"):
raise HTTPException(status_code=403, detail="Nur Admins dürfen den Import ausführen")
return session
# ------------------------------------------------------------------ #
# Preview Endpoint #
# ------------------------------------------------------------------ #
@router.get("/import/mediawiki/preview")
async def preview_import(
category: str = Query(default=CATEGORY_EXERCISES),
import_type: str = Query(default="exercise"),
limit: int = Query(default=10, ge=1, le=100),
session: dict = Depends(require_admin),
):
"""
Zeigt Vorschau: Welche Seiten würden importiert werden?
Überprüft Duplikate und mapped Felder ohne zu speichern.
"""
client = SmwClient()
try:
members = await client.get_category_members(category, limit=limit)
except SmwClientError as e:
raise HTTPException(status_code=502, detail=f"Wiki-API nicht erreichbar: {e}")
preview = []
with get_db() as conn:
cur = get_cursor(conn)
for member in members[:limit]:
page_title = member["title"]
page_id = member.get("pageid")
# Duplikat-Check
cur.execute(
"SELECT id, last_imported FROM wiki_import_references WHERE wiki_page_title = %s AND content_type = %s",
(page_title, import_type)
)
existing_ref = r2d(cur.fetchone())
# SMW Properties abrufen
mapped_fields = {}
warnings = []
errors = []
try:
smw_props = await client.browse_subject(page_title)
if import_type == "exercise":
mapped = map_wiki_to_exercise(page_title, page_id, smw_props)
elif import_type == "skill":
mapped = map_wiki_to_skill(page_title, page_id, smw_props)
else:
mapped = map_wiki_to_method(page_title, page_id, smw_props)
warnings = mapped.pop("warnings", [])
# Entferne interne Felder aus der Vorschau
for k in ("wiki_page_id", "import_source", "import_id"):
mapped.pop(k, None)
mapped_fields = mapped
# Warnung wenn Pflichtfelder fehlen (nur bei Übungen)
if import_type == "exercise":
if not mapped.get("goal") and not mapped.get("execution"):
errors.append("Pflichtfelder 'Ziel' und 'Durchführung' fehlen")
except SmwClientError as e:
errors.append(f"Seite nicht abrufbar: {e}")
preview.append({
"wiki_page_title": page_title,
"wiki_page_id": page_id,
"already_imported": existing_ref is not None,
"last_imported_at": existing_ref["last_imported"].isoformat() if existing_ref else None,
"mapped_fields": mapped_fields,
"warnings": warnings,
"errors": errors,
})
return {
"category": category,
"import_type": import_type,
"total_found": len(members),
"preview": preview,
}
# ------------------------------------------------------------------ #
# Execute Import (async Background Task) #
# ------------------------------------------------------------------ #
@router.post("/import/mediawiki/execute", status_code=202)
async def execute_import(
body: ImportExecuteRequest,
background_tasks: BackgroundTasks,
session: dict = Depends(require_admin),
):
"""
Startet einen MediaWiki-Import als Background-Task.
Gibt sofort log_id zurück Status via GET /import/mediawiki/status/{log_id}.
"""
profile_id = session["profile_id"]
# Log-Eintrag anlegen
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""INSERT INTO wiki_import_log
(import_type, import_status, category, dry_run, reimport, imported_by)
VALUES (%s, 'running', %s, %s, %s, %s)
RETURNING id""",
(body.import_type, body.category, body.dry_run, body.reimport_existing, profile_id)
)
log_id = cur.fetchone()[0]
conn.commit()
# Import asynchron starten
background_tasks.add_task(
_run_import,
log_id=log_id,
category=body.category,
import_type=body.import_type,
reimport=body.reimport_existing,
dry_run=body.dry_run,
limit=body.limit,
)
return {
"log_id": log_id,
"status": "running",
"message": f"Import gestartet. Status: GET /api/import/mediawiki/status/{log_id}",
}
# ------------------------------------------------------------------ #
# Status & Logs #
# ------------------------------------------------------------------ #
@router.get("/import/mediawiki/status/{log_id}")
def get_import_status(
log_id: int,
session: dict = Depends(require_admin),
):
"""Status eines laufenden oder abgeschlossenen Imports."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM wiki_import_log WHERE id = %s", (log_id,))
row = r2d(cur.fetchone())
if not row:
raise HTTPException(status_code=404, detail="Import-Log nicht gefunden")
return row
@router.get("/import/mediawiki/logs")
def list_import_logs(
session: dict = Depends(require_admin),
):
"""Alle Import-Logs (neueste zuerst, ohne error_log Details)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT id, import_type, import_status, category, dry_run, reimport,
items_total, items_imported, items_skipped, items_failed,
imported_by, started_at, finished_at
FROM wiki_import_log
ORDER BY started_at DESC
LIMIT 50"""
)
rows = cur.fetchall()
return [r2d(r) for r in rows]
@router.delete("/import/mediawiki/references/{ref_id}", status_code=204)
def delete_import_reference(
ref_id: int,
session: dict = Depends(require_admin),
):
"""
Löscht eine Import-Referenz, damit das Item bei nächstem Import
als neu behandelt wird (Re-Import ermöglichen).
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM wiki_import_references WHERE id = %s RETURNING id", (ref_id,))
deleted = cur.fetchone()
conn.commit()
if not deleted:
raise HTTPException(status_code=404, detail="Referenz nicht gefunden")
# ------------------------------------------------------------------ #
# Schema-Discovery (Debug-Hilfsmittel für Admins) #
# ------------------------------------------------------------------ #
@router.get("/import/mediawiki/discover/{page_title:path}")
async def discover_properties(
page_title: str,
session: dict = Depends(require_admin),
):
"""
Gibt alle SMW-Properties einer Wiki-Seite zurück.
Nützlich um Property-Namen zu ermitteln und smw_mapper.py anzupassen.
"""
client = SmwClient()
try:
props = await client.discover_properties(page_title)
except SmwClientError as e:
raise HTTPException(status_code=502, detail=f"Wiki-API Fehler: {e}")
return {"page_title": page_title, "properties": props}
# ------------------------------------------------------------------ #
# Background Import Worker #
# ------------------------------------------------------------------ #
async def _run_import(
log_id: int,
category: str,
import_type: str,
reimport: bool,
dry_run: bool,
limit: Optional[int],
):
"""Hintergrund-Task: Importiert alle Seiten einer Kategorie."""
client = SmwClient()
stats = {"total": 0, "imported": 0, "skipped": 0, "failed": 0}
errors = []
try:
members = await client.get_category_members(category, limit=limit or 500)
stats["total"] = len(members)
_update_log(log_id, import_status="running", items_total=stats["total"])
for member in members:
page_title = member["title"]
page_id = member.get("pageid")
# Duplikat-Check
if not reimport:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id FROM wiki_import_references WHERE wiki_page_title = %s AND content_type = %s",
(page_title, import_type)
)
if cur.fetchone():
stats["skipped"] += 1
_update_log(log_id, **stats)
continue
# SMW Properties abrufen (mit Retry)
try:
smw_props = await _fetch_with_retry(client, page_title)
except SmwClientError as e:
errors.append({"item": page_title, "error": str(e)})
stats["failed"] += 1
_update_log(log_id, **stats, error_log=errors)
continue
# Field-Mapping
if import_type == "exercise":
mapped = map_wiki_to_exercise(page_title, page_id, smw_props)
required_ok = bool(mapped.get("goal") or mapped.get("execution"))
elif import_type == "skill":
mapped = map_wiki_to_skill(page_title, page_id, smw_props)
required_ok = True
else:
mapped = map_wiki_to_method(page_title, page_id, smw_props)
required_ok = True
if not required_ok:
errors.append({"item": page_title, "error": "Pflichtfelder fehlen (Ziel + Durchführung)"})
stats["failed"] += 1
_update_log(log_id, **stats, error_log=errors)
continue
if dry_run:
stats["imported"] += 1
_update_log(log_id, **stats)
continue
# Speichern
try:
if import_type == "exercise":
local_id = _upsert_exercise(mapped, reimport)
elif import_type == "skill":
local_id = _upsert_skill(mapped, reimport)
else:
local_id = _upsert_method(mapped, reimport)
if local_id:
_upsert_wiki_ref(page_title, page_id, import_type, local_id)
stats["imported"] += 1
else:
stats["skipped"] += 1
except Exception as e:
logger.exception("Fehler beim Speichern von '%s'", page_title)
errors.append({"item": page_title, "error": str(e)})
stats["failed"] += 1
_update_log(log_id, **stats, error_log=errors)
# Abschluss
_update_log(
log_id,
import_status="completed",
error_log=errors,
finished=True,
**stats,
)
except Exception as e:
logger.exception("Import-Prozess fehlgeschlagen (log_id=%s)", log_id)
_update_log(log_id, import_status="failed", error_log=[{"item": "global", "error": str(e)}], finished=True, **stats)
async def _fetch_with_retry(client: SmwClient, page_title: str, retries: int = 3) -> dict:
"""Ruft SMW-Properties mit bis zu 3 Versuchen ab."""
for attempt in range(retries):
try:
return await client.browse_subject(page_title)
except SmwClientError:
if attempt == retries - 1:
raise
await asyncio.sleep(2 ** attempt)
# ------------------------------------------------------------------ #
# DB-Helper #
# ------------------------------------------------------------------ #
def _update_log(
log_id: int,
import_status: Optional[str] = None,
items_total: int = 0,
total: int = 0,
imported: int = 0,
skipped: int = 0,
failed: int = 0,
error_log: Optional[list] = None,
finished: bool = False,
):
import json as _json
with get_db() as conn:
cur = get_cursor(conn)
fields = []
params = []
if import_status:
fields.append("import_status = %s")
params.append(import_status)
total_val = items_total or total
fields += [
"items_total = %s",
"items_imported = %s",
"items_skipped = %s",
"items_failed = %s",
]
params += [total_val, imported, skipped, failed]
if error_log is not None:
fields.append("error_log = %s")
params.append(_json.dumps(error_log))
if finished:
fields.append("finished_at = NOW()")
fields.append("updated_at = NOW()")
params.append(log_id)
cur.execute(
f"UPDATE wiki_import_log SET {', '.join(fields)} WHERE id = %s",
params
)
conn.commit()
def _upsert_exercise(mapped: dict, reimport: bool) -> Optional[int]:
"""Legt Übung an oder aktualisiert sie (wenn reimport=True)."""
import json as _json
with get_db() as conn:
cur = get_cursor(conn)
# Existiert die Übung bereits (über import_id)?
cur.execute(
"SELECT id FROM exercises WHERE import_id = %s AND import_source = 'mediawiki'",
(mapped["import_id"],)
)
existing = cur.fetchone()
equipment = _json.dumps(mapped.get("equipment", [])) if mapped.get("equipment") else None
if existing and reimport:
ex_id = existing[0]
cur.execute(
"""UPDATE exercises SET
title = %s, summary = %s, goal = %s, execution = %s,
preparation = %s, trainer_notes = %s,
duration_min = %s, duration_max = %s,
group_size_min = %s, group_size_max = %s,
equipment = %s, updated_at = NOW()
WHERE id = %s""",
(
mapped.get("title"), mapped.get("summary"), mapped.get("goal"),
mapped.get("execution"), mapped.get("preparation"), mapped.get("trainer_notes"),
mapped.get("duration_min"), mapped.get("duration_max"),
mapped.get("group_size_min"), mapped.get("group_size_max"),
equipment, ex_id
)
)
elif existing and not reimport:
return None # Übersprungen
else:
cur.execute(
"""INSERT INTO exercises
(title, summary, goal, execution, preparation, trainer_notes,
duration_min, duration_max, group_size_min, group_size_max,
equipment, visibility, status, import_source, import_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id""",
(
mapped.get("title"), mapped.get("summary"), mapped.get("goal"),
mapped.get("execution"), mapped.get("preparation"), mapped.get("trainer_notes"),
mapped.get("duration_min"), mapped.get("duration_max"),
mapped.get("group_size_min"), mapped.get("group_size_max"),
equipment,
mapped.get("visibility", "private"),
mapped.get("status", "draft"),
"mediawiki",
mapped.get("import_id"),
)
)
ex_id = cur.fetchone()[0]
conn.commit()
# Katalog-Zuordnungen (Focus Areas, Skills, etc.)
_assign_exercise_catalogs(cur, conn, ex_id, mapped)
# Skill-Zuordnungen mit Levels
skill_assignments = build_skill_assignments(mapped)
_assign_exercise_skills(cur, conn, ex_id, skill_assignments)
return ex_id
def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict):
"""Weist M:N Katalog-Zuordnungen für eine importierte Übung zu."""
# Focus Areas
for idx, name in enumerate(mapped.get("focus_area_names", [])):
cur.execute("SELECT id FROM focus_areas WHERE name ILIKE %s", (name,))
row = cur.fetchone()
if row:
cur.execute(
"""INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
VALUES (%s, %s, %s)
ON CONFLICT (exercise_id, focus_area_id) DO NOTHING""",
(exercise_id, row[0], idx == 0)
)
else:
logger.warning("Focus Area '%s' nicht im Katalog gefunden", name)
# Style Directions
for name in mapped.get("style_names", []):
cur.execute("SELECT id FROM style_directions WHERE name ILIKE %s", (name,))
row = cur.fetchone()
if row:
cur.execute(
"""INSERT INTO exercise_training_styles (exercise_id, style_direction_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING""",
(exercise_id, row[0])
)
# Target Groups
for name in mapped.get("target_group_names", []):
cur.execute("SELECT id FROM target_groups WHERE name ILIKE %s", (name,))
row = cur.fetchone()
if row:
cur.execute(
"""INSERT INTO exercise_target_groups (exercise_id, target_group_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING""",
(exercise_id, row[0])
)
# Skills
for name in mapped.get("skill_names", []):
cur.execute("SELECT id FROM skills WHERE name ILIKE %s", (name,))
row = cur.fetchone()
if row:
cur.execute(
"""INSERT INTO exercise_skills (exercise_id, skill_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING""",
(exercise_id, row[0])
)
conn.commit()
def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list):
"""Weist Skill-Zuordnungen mit Levels einer Übung zu."""
for assignment in skill_assignments:
cur.execute("SELECT id FROM skills WHERE name ILIKE %s", (assignment["skill_name"],))
row = cur.fetchone()
if not row:
logger.warning("Skill '%s' nicht im Katalog gefunden", assignment["skill_name"])
continue
cur.execute(
"""INSERT INTO exercise_skills
(exercise_id, skill_id, target_level, required_level, intensity, is_primary)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
target_level = EXCLUDED.target_level,
is_primary = EXCLUDED.is_primary""",
(
exercise_id, row[0],
assignment.get("target_level"),
assignment.get("required_level"),
assignment.get("intensity"),
assignment.get("is_primary", False),
)
)
conn.commit()
def _upsert_skill(mapped: dict, reimport: bool) -> Optional[int]:
"""Legt Skill an oder überspringt bei Duplikat."""
with get_db() as conn:
cur = get_cursor(conn)
# Kategorie auflösen (optional)
category_id = None
if mapped.get("category_name"):
cur.execute("SELECT id FROM skill_categories WHERE name ILIKE %s", (mapped["category_name"],))
row = cur.fetchone()
if row:
category_id = row[0]
cur.execute(
"""INSERT INTO skills (name, description, category_id)
VALUES (%s, %s, %s)
ON CONFLICT (name) DO UPDATE SET
description = EXCLUDED.description
RETURNING id""",
(mapped["name"], mapped.get("description"), category_id)
)
skill_id = cur.fetchone()[0]
conn.commit()
return skill_id
def _upsert_method(mapped: dict, reimport: bool) -> Optional[int]:
"""Legt Trainingsmethode an oder aktualisiert Beschreibung."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""INSERT INTO training_methods (name, description)
VALUES (%s, %s)
ON CONFLICT (name) DO UPDATE SET
description = EXCLUDED.description
RETURNING id""",
(mapped["name"], mapped.get("description"))
)
method_id = cur.fetchone()[0]
conn.commit()
return method_id
def _upsert_wiki_ref(
page_title: str, page_id: Optional[int], content_type: str, local_id: int
):
"""Legt Import-Referenz an oder aktualisiert last_imported."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""INSERT INTO wiki_import_references
(wiki_page_title, wiki_page_id, content_type, local_id, last_imported)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (wiki_page_title, content_type) DO UPDATE SET
wiki_page_id = EXCLUDED.wiki_page_id,
local_id = EXCLUDED.local_id,
last_imported = NOW()""",
(page_title, page_id, content_type, local_id)
)
conn.commit()

215
backend/smw_client.py Normal file
View File

@ -0,0 +1,215 @@
"""
Semantic MediaWiki API Client
Greift direkt auf die MediaWiki API zu (kein XML-Export).
Unterstützt Login, Kategorien-Abfragen und SMW Browse-API.
"""
import os
import logging
import httpx
from typing import Optional
logger = logging.getLogger(__name__)
MEDIAWIKI_API_URL = os.getenv("MEDIAWIKI_API_URL", "")
MEDIAWIKI_USER = os.getenv("MEDIAWIKI_USER", "")
MEDIAWIKI_PASSWORD = os.getenv("MEDIAWIKI_PASSWORD", "")
class SmwClientError(Exception):
pass
class SmwClient:
"""Stateless MediaWiki/SMW API Client mit Session-Login."""
def __init__(self, api_url: str = None, user: str = None, password: str = None):
self.api_url = (api_url or MEDIAWIKI_API_URL).rstrip("/")
self.user = user or MEDIAWIKI_USER
self.password = password or MEDIAWIKI_PASSWORD
self._cookies: dict = {}
self._logged_in = False
if not self.api_url:
raise SmwClientError("MEDIAWIKI_API_URL nicht konfiguriert")
# ------------------------------------------------------------------ #
# Authentication #
# ------------------------------------------------------------------ #
async def login(self) -> None:
"""MediaWiki Login (zwei Schritte: Token holen → Login ausführen)."""
async with httpx.AsyncClient(timeout=15) as client:
# Schritt 1: Login-Token holen
r1 = await client.get(self.api_url, params={
"action": "query",
"meta": "tokens",
"type": "login",
"format": "json",
})
r1.raise_for_status()
token = r1.json()["query"]["tokens"]["logintoken"]
cookies = dict(r1.cookies)
# Schritt 2: Einloggen
r2 = await client.post(self.api_url, params={"format": "json"}, data={
"action": "login",
"lgname": self.user,
"lgpassword": self.password,
"lgtoken": token,
}, cookies=cookies)
r2.raise_for_status()
result = r2.json()
if result.get("login", {}).get("result") != "Success":
reason = result.get("login", {}).get("reason", "unbekannt")
raise SmwClientError(f"MediaWiki Login fehlgeschlagen: {reason}")
self._cookies = dict(r2.cookies)
self._logged_in = True
logger.info("SMW Login erfolgreich als '%s'", self.user)
async def _get(self, params: dict) -> dict:
"""Authentifizierter GET-Request gegen die API."""
if not self._logged_in:
await self.login()
params["format"] = "json"
async with httpx.AsyncClient(timeout=30, cookies=self._cookies) as client:
r = await client.get(self.api_url, params=params)
r.raise_for_status()
return r.json()
# ------------------------------------------------------------------ #
# Kategorien #
# ------------------------------------------------------------------ #
async def get_category_members(self, category: str, limit: int = 500) -> list[dict]:
"""
Gibt alle Seiten einer Kategorie zurück.
Gibt Liste von {"pageid": int, "title": str} zurück.
"""
members = []
cmcontinue = None
while True:
params = {
"action": "query",
"list": "categorymembers",
"cmtitle": f"Kategorie:{category}",
"cmlimit": min(limit, 500),
"cmtype": "page",
"cmprop": "ids|title",
}
if cmcontinue:
params["cmcontinue"] = cmcontinue
data = await self._get(params)
members.extend(data["query"]["categorymembers"])
if "continue" in data and len(members) < limit:
cmcontinue = data["continue"].get("cmcontinue")
else:
break
return members[:limit]
# ------------------------------------------------------------------ #
# Seiteninhalte #
# ------------------------------------------------------------------ #
async def get_page_wikitext(self, title: str) -> str:
"""Rohen Wikitext einer Seite abrufen."""
data = await self._get({
"action": "query",
"titles": title,
"prop": "revisions",
"rvprop": "content",
"rvslots": "main",
})
pages = data["query"]["pages"]
page = next(iter(pages.values()))
if "missing" in page:
raise SmwClientError(f"Seite '{title}' nicht gefunden")
return page["revisions"][0]["slots"]["main"]["*"]
async def get_page_html(self, title: str) -> str:
"""Geparsten HTML-Inhalt einer Seite abrufen."""
data = await self._get({
"action": "parse",
"page": title,
"prop": "text",
})
return data["parse"]["text"]["*"]
# ------------------------------------------------------------------ #
# Semantic MediaWiki #
# ------------------------------------------------------------------ #
async def browse_subject(self, title: str) -> dict:
"""
SMW Browse-API: Gibt alle Properties (Attribute) einer Seite zurück.
Gibt dict {property_name: [value, ...]} zurück.
"""
data = await self._get({
"action": "browsebysubject",
"subject": title,
})
if "error" in data:
raise SmwClientError(f"SMW Browse-Fehler für '{title}': {data['error']}")
# Normalisiere: {property_label: [wert1, wert2]}
result = {}
for prop_data in data.get("query", {}).get("data", []):
prop_label = prop_data.get("property", "")
if prop_label.startswith("_"): # Interne SMW-Properties überspringen
continue
values = []
for item in prop_data.get("dataitem", []):
raw = item.get("item", "")
# SMW codiert Werte manchmal als "Wert#0##" → bereinigen
clean = raw.split("#")[0].strip() if "#" in raw else raw.strip()
if clean:
values.append(clean)
if values:
result[prop_label] = values
return result
async def ask_query(self, query: str, limit: int = 100) -> list[dict]:
"""
SMW Ask-API: Semantische Abfrage.
Beispiel: query = "[[Kategorie:Übungen]]|?Fokusbereich|?Ziel"
Gibt Liste von {title, properties} zurück.
"""
data = await self._get({
"action": "ask",
"query": f"{query}|limit={limit}",
})
if "error" in data:
raise SmwClientError(f"SMW Ask-Fehler: {data['error']}")
results = []
for title, props in data.get("query", {}).get("results", {}).items():
entry = {"title": title, "properties": {}}
for prop_name, prop_data in props.get("printouts", {}).items():
values = []
for item in prop_data:
if isinstance(item, dict):
values.append(item.get("fulltext") or item.get("raw") or str(item))
else:
values.append(str(item))
entry["properties"][prop_name] = values
results.append(entry)
return results
# ------------------------------------------------------------------ #
# Schema-Discovery #
# ------------------------------------------------------------------ #
async def discover_properties(self, sample_title: str) -> dict:
"""
Gibt alle SMW-Properties einer Beispielseite zurück.
Nützlich um die Property-Namen zu ermitteln bevor der Mapper gebaut wird.
"""
props = await self.browse_subject(sample_title)
logger.info("Properties von '%s': %s", sample_title, list(props.keys()))
return props

365
backend/smw_mapper.py Normal file
View File

@ -0,0 +1,365 @@
"""
Semantic MediaWiki Shinkan Field Mapper
Wandelt SMW-Properties von karatetrainer.net in lokale DB-Felder um.
Property-Namen wurden via discover_properties() auf echten Wiki-Seiten ermittelt.
Entdeckte Kategorien:
Übungen: Kategorie: Übungen (auch "Übungen Karate", "Übungen allgemein")
Fähigkeiten: Fähigkeitsbeschreibung
Methoden: Methodenbeschreibung
"""
import re
import logging
from typing import Optional
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------ #
# CapabilityLevel Integer → benannte Stufen #
# ------------------------------------------------------------------ #
# Mapping: SMW-Integer → Shinkan-Stufenname
CAPABILITY_LEVEL_MAP = {
"1": "einsteiger",
"2": "grundlagen",
"3": "aufbau",
"4": "fortgeschritten",
"5": "experte",
}
# ------------------------------------------------------------------ #
# SMW Property → lokales Feld #
# Echte Namen von karatetrainer.net (via discover_properties) #
# ------------------------------------------------------------------ #
# Übungen (exercises)
EXERCISE_PROPERTY_MAP = {
# Kern-Felder
"Übungsbezeichnung": "title_override", # Übungsname (bevorzugt ggü. Seitentitel)
"Ziel": "goal",
"Durchführung": "execution",
"Summary": "summary",
"Hinweise": "trainer_notes",
"Plandauer": "duration_raw", # Zahl in Minuten z.B. "10"
"Gruppengröße": "group_size_raw", # Zahl z.B. "2"
"Hilfsmittel": "equipment_raw", # Komma-Liste / einzelner Wert
"Schlüsselworte": "keywords_raw", # Keywords (nicht direkt in DB, für spätere Tags)
# Katalog-Felder (Name → ID Lookup)
"Übungstyp": "focus_area_names", # "Karate" → focus_area
"Zielgruppe": "target_group_names",
"Altersgruppe": "age_group_names",
"Trainingsmethode": "method_names", # Wiki-Seitenname z.B. "Plyometrisches_Training"
# Fähigkeiten (als Namen + Level)
"PrimaryCapability": "skill_names", # Skill-Namen (können mehrere sein)
"CapabilityLevel": "skill_levels_raw", # Integer-Levels ["3", "2"] → aufbau, grundlagen
# Weitere Felder (optional)
"Graduierung": "graduierung", # "0 - Anfänger" (zukünftige Nutzung)
"Lernstufe": "lernstufe", # "Lernstufe_1_-_Erlernen_und_Festigen"
}
# Fähigkeiten (skills) Kategorie: Fähigkeitsbeschreibung
SKILL_PROPERTY_MAP = {
"Summary": "description",
"KarateRelevanz": "karate_relevance", # Wird in description ergänzt
"RelevanzLevel": "relevance_level", # 1-3, nicht direkt in skills DB
}
# Trainingsmethoden Kategorie: Methodenbeschreibung
METHOD_PROPERTY_MAP = {
"Summary": "description",
"Kurzbezeichnung": "code", # Abkürzung z.B. "DM"
"KarateRelevanz": "karate_relevance",
"PrimaryCapability": "skill_names", # Verknüpfte Fähigkeiten
}
# ------------------------------------------------------------------ #
# Wikitext → Plaintext #
# ------------------------------------------------------------------ #
def wikitext_to_plaintext(wikitext: str) -> str:
"""Entfernt Wikitext-Formatierungen und gibt lesbaren Plaintext zurück."""
text = wikitext
# Externe Links: [https://example.com Text] → Text
text = re.sub(r'\[https?://\S+\s+([^\]]+)\]', r'\1', text)
# Interne Links mit Alias: [[Link|Text]] → Text
text = re.sub(r'\[\[([^|\]]+)\|([^\]]+)\]\]', r'\2', text)
# Interne Links ohne Alias: [[Link]] → Link (Unterstriche → Leerzeichen)
text = re.sub(r'\[\[([^\]]+)\]\]', lambda m: m.group(1).replace('_', ' '), text)
# Templates entfernen (einzeilig)
text = re.sub(r'\{\{[^}]+\}\}', '', text)
# Fettdruck und Kursiv
text = re.sub(r"'''(.+?)'''", r'\1', text)
text = re.sub(r"''(.+?)''", r'\1', text)
# HTML-Tags entfernen (inkl. <br>)
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<[^>]+>', '', text)
# Überschriften
text = re.sub(r'={2,6}\s*(.+?)\s*={2,6}', r'\n\1\n', text)
# Aufzählungszeichen normalisieren
text = re.sub(r'^[*#:;]+\s*', '- ', text, flags=re.MULTILINE)
# Mehrfache Leerzeilen normalisieren
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
def wiki_name_to_label(wiki_name: str) -> str:
"""Wandelt Wiki-Seitennamen in lesbare Labels um: Plyometrisches_Training → Plyometrisches Training"""
return wiki_name.replace('_', ' ').strip()
# ------------------------------------------------------------------ #
# Parsing-Hilfsfunktionen #
# ------------------------------------------------------------------ #
def parse_duration(raw: str) -> tuple[Optional[int], Optional[int]]:
"""
"10" (10, 10) (Plandauer ist immer eine einzelne Zahl in Minuten)
"10-15" (10, 15)
"""
if not raw:
return None, None
numbers = re.findall(r'\d+', raw)
if not numbers:
return None, None
if len(numbers) == 1:
val = int(numbers[0])
return val, val
return int(numbers[0]), int(numbers[1])
def parse_group_size(raw: str) -> tuple[Optional[int], Optional[int]]:
"""
"2" (2, 2) (Gruppengröße ist immer eine einzelne Zahl)
"""
if not raw:
return None, None
numbers = re.findall(r'\d+', raw)
if not numbers:
return None, None
val = int(numbers[0])
if len(numbers) == 1:
return val, None # Minimum, kein Maximum angegeben
return int(numbers[0]), int(numbers[1])
def parse_equipment(raw: list[str]) -> list[str]:
"""Normalisiert Equipment-Liste: ["Ausdruck"] oder ["Gewicht"] → bereinigt"""
result = []
for item in raw:
for part in re.split(r'[,;/]', item):
cleaned = wiki_name_to_label(part.strip())
if cleaned:
result.append(cleaned)
return result
def map_capability_level(level_str: str) -> str:
"""Wandelt Integer-Level in benannte Stufe: "3""aufbau" """
return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "einsteiger")
# ------------------------------------------------------------------ #
# Haupt-Mapping-Funktion #
# ------------------------------------------------------------------ #
def map_wiki_to_exercise(
page_title: str,
wiki_page_id: Optional[int],
smw_props: dict,
) -> dict:
"""
Wandelt SMW-Properties einer Wiki-Seite in ein Exercise-Dict um.
Args:
page_title: Titel der Wiki-Seite (Fallback für title)
wiki_page_id: Interne MediaWiki-Seiten-ID
smw_props: {property_name: [value, ...]} aus SmwClient.browse_subject()
Returns:
Dict mit gemappten Feldern + Katalog-Listen für ID-Lookup.
"""
mapped: dict = {
"title": page_title,
"wiki_page_id": wiki_page_id,
# Tracking
"import_source": "mediawiki",
"import_id": page_title,
# Defaults
"visibility": "private",
"status": "draft",
# Katalog-Referenzen (Name → ID-Lookup erfolgt im Router)
"focus_area_names": [],
"target_group_names": [],
"age_group_names": [],
"skill_names": [],
"skill_levels_raw": [], # Integer-Strings ["3", "2"]
"method_names": [],
# Equipment
"equipment": [],
# Warnungen für unbekannte Katalog-Werte
"warnings": [],
}
for prop_name, values in smw_props.items():
if not values:
continue
target = EXERCISE_PROPERTY_MAP.get(prop_name)
if not target:
continue
# Ersten Wert oder ganzes Array
first_value = values[0] if isinstance(values, list) else values
if target == "title_override":
mapped["title"] = wiki_name_to_label(first_value)
elif target in ("goal", "execution", "summary", "trainer_notes"):
mapped[target] = wikitext_to_plaintext(first_value)
elif target == "duration_raw":
dur_min, dur_max = parse_duration(first_value)
mapped["duration_min"] = dur_min
mapped["duration_max"] = dur_max
elif target == "group_size_raw":
gs_min, gs_max = parse_group_size(first_value)
mapped["group_size_min"] = gs_min
mapped["group_size_max"] = gs_max
elif target == "equipment_raw":
mapped["equipment"] = parse_equipment(values if isinstance(values, list) else [values])
elif target == "keywords_raw":
# Keywords für spätere Tag-Implementierung speichern
mapped["keywords"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
elif target == "focus_area_names":
mapped["focus_area_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
elif target == "target_group_names":
mapped["target_group_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
elif target == "age_group_names":
mapped["age_group_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
elif target == "method_names":
mapped["method_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
elif target == "skill_names":
mapped["skill_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
elif target == "skill_levels_raw":
mapped["skill_levels_raw"] = list(values) if isinstance(values, list) else [values]
return mapped
def build_skill_assignments(mapped: dict) -> list[dict]:
"""
Erstellt Skill-Zuordnungen aus PrimaryCapability + CapabilityLevel.
CapabilityLevel [3, 2] korrespondiert mit PrimaryCapability [Schnellkraft, Schnelligkeitsausdauer]
ergibt: [{skill: Schnellkraft, target_level: aufbau}, {skill: Schnelligkeitsausdauer, target_level: grundlagen}]
"""
skills = mapped.get("skill_names", [])
levels = mapped.get("skill_levels_raw", [])
assignments = []
for idx, skill_name in enumerate(skills):
level_str = levels[idx] if idx < len(levels) else "1"
assignments.append({
"skill_name": skill_name,
"target_level": map_capability_level(level_str),
"required_level": None, # Nicht im Wiki spezifiziert
"intensity": None, # Nicht im Wiki spezifiziert
"is_primary": idx == 0,
})
return assignments
def map_wiki_to_skill(
page_title: str,
wiki_page_id: Optional[int],
smw_props: dict,
) -> dict:
"""Wandelt SMW-Properties einer Fähigkeitsbeschreibung-Seite in ein Skill-Dict um."""
mapped = {
"name": page_title,
"wiki_page_id": wiki_page_id,
"import_source": "mediawiki",
"import_id": page_title,
"warnings": [],
}
description_parts = []
for prop_name, values in smw_props.items():
if not values:
continue
target = SKILL_PROPERTY_MAP.get(prop_name)
if not target:
continue
first_value = values[0] if isinstance(values, list) else values
if target == "description":
description_parts.insert(0, wikitext_to_plaintext(first_value))
elif target == "karate_relevance":
rel = wikitext_to_plaintext(first_value)
description_parts.append(f"\nKarate-Relevanz: {rel}")
if description_parts:
mapped["description"] = "\n".join(description_parts).strip()
return mapped
def map_wiki_to_method(
page_title: str,
wiki_page_id: Optional[int],
smw_props: dict,
) -> dict:
"""Wandelt SMW-Properties einer Methodenbeschreibung-Seite in ein Method-Dict um."""
mapped = {
"name": page_title,
"wiki_page_id": wiki_page_id,
"import_source": "mediawiki",
"import_id": page_title,
"warnings": [],
}
description_parts = []
for prop_name, values in smw_props.items():
if not values:
continue
target = METHOD_PROPERTY_MAP.get(prop_name)
if not target:
continue
first_value = values[0] if isinstance(values, list) else values
if target == "description":
description_parts.insert(0, wikitext_to_plaintext(first_value))
elif target == "code":
mapped["code"] = first_value.strip()
elif target == "karate_relevance":
rel = wikitext_to_plaintext(first_value)
description_parts.append(f"\nKarate-Relevanz: {rel}")
if description_parts:
mapped["description"] = "\n".join(description_parts).strip()
return mapped

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.5.0"
BUILD_DATE = "2026-04-23"
DB_SCHEMA_VERSION = "20260423002"
APP_VERSION = "0.6.0"
BUILD_DATE = "2026-04-24"
DB_SCHEMA_VERSION = "20260424001"
MODULE_VERSIONS = {
"auth": "1.0.0",
@ -15,13 +15,25 @@ MODULE_VERSIONS = {
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.1.0",
"import_wiki": "0.1.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
"membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
}
CHANGELOG = [
{
"version": "0.6.0",
"date": "2026-04-24",
"changes": [
"Feature: MediaWiki Import (SMW Direct API)",
"DB: Migration 018 - wiki_import_log + wiki_import_references",
"Backend: SmwClient (login, browse, ask, categorymembers)",
"Backend: SmwMapper (SMW Properties → exercises/skills/methods)",
"Backend: import_wiki Router (preview, execute, status, logs, discover)",
"Config: MEDIAWIKI_API_URL=https://karatetrainer.net/api.php",
]
},
{
"version": "0.5.0",
"date": "2026-04-23",