Compare commits

..

50 Commits

Author SHA1 Message Date
ea7de64061 Merge pull request 'progression V2' (#57) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Reviewed-on: #57
2026-06-13 16:34:09 +02:00
dbc2dfacb9 Merge pull request 'Progression enhancement 3 QS stages' (#56) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 49s
Reviewed-on: #56
2026-06-12 12:24:54 +02:00
9b4d091637 Merge pull request 'Progression optimiert Phase A' (#55) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 48s
Reviewed-on: #55
2026-06-11 21:26:54 +02:00
4724da28b1 Merge pull request 'Progressionsgraph verbessert' (#54) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 48s
Reviewed-on: #54
2026-06-09 16:37:22 +02:00
50c9beb4b3 Merge pull request 'Ki und Admin Feature' (#53) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 46s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m24s
Reviewed-on: #53
2026-06-06 18:01:11 +02:00
29a5db63e0 Merge pull request 'Progressionsgraphen' (#52) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m24s
Reviewed-on: #52
2026-05-23 12:56:52 +02:00
6db31e7312 Merge pull request 'Implement Phase C2 Enhancements for Exercise Suggestions' (#51) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #51
2026-05-23 11:43:57 +02:00
16187fbbd0 Merge pull request 'Verbesserung Suche und Neuanlage von Übungen' (#50) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
Reviewed-on: #50
2026-05-23 10:52:41 +02:00
9ba35dc022 Merge pull request 'Optimierung KI-Scuhe + Ki-Überarbeitungen der Übungen' (#49) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #49
2026-05-23 07:54:21 +02:00
fc5748bef1 Merge pull request 'KI Übungen - MVP 0.8' (#48) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
Reviewed-on: #48
2026-05-22 19:51:54 +02:00
3bf012a8f4 Merge pull request 'export der Fähigkeiten, KI- Admin' (#47) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #47
2026-05-22 11:56:54 +02:00
69f238d9b8 Merge pull request 'KI Implementierung (MVP) auf Übungen' (#46) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #46
2026-05-22 10:38:39 +02:00
a1b85cd865 Merge pull request 'Bug_fixes' (#45) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m20s
Reviewed-on: #45
2026-05-22 07:16:32 +02:00
4720d70af0 Merge pull request 'Verbesserung UX für Übungen' (#44) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
Reviewed-on: #44
2026-05-21 15:02:15 +02:00
57c464c9f6 Merge pull request 'KPI-Scroing, Filter, etc,' (#43) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m2s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m16s
Reviewed-on: #43
2026-05-21 10:36:49 +02:00
d42eb3ac52 Merge pull request 'Verbesserung Darstellung Rahmenprogramme' (#42) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m22s
Reviewed-on: #42
2026-05-20 16:29:53 +02:00
f9df2d31db Merge pull request 'Fähigkeitauswahl verbessert' (#41) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m21s
Reviewed-on: #41
2026-05-20 11:24:38 +02:00
1e2fdeeb0f Merge pull request 'UX Verbesserung, Navigationsspeicherung' (#40) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m24s
Reviewed-on: #40
2026-05-20 10:44:00 +02:00
7450c269a5 Merge pull request 'Editor Verbesserung für Übungen' (#39) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
Reviewed-on: #39
2026-05-20 06:42:07 +02:00
99a5fccaa5 Grafische Änderungen (kein Trainingsmodal). Diverse Verwaltungsdialoge
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 16s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #38
2026-05-19 14:56:41 +02:00
55d87d8d17 Merge pull request 'Fähigkeitsimport verbessert' (#37) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m12s
Reviewed-on: #37
2026-05-16 11:08:39 +02:00
b35a5ae216 Merge pull request 'SplitSession auch für Rahmenprogramme' (#36) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
Reviewed-on: #36
2026-05-16 09:02:51 +02:00
bd9cfaa6e4 Merge pull request 'Parlellsession- Plan' (#35) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
Reviewed-on: #35
2026-05-15 22:04:52 +02:00
639392e133 Merge pull request 'Bug Fix Dashboard + Performance Phase 3a' (#34) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m1s
Reviewed-on: #34
2026-05-14 11:51:53 +02:00
d153a22545 Merge pull request 'Bug Fixing Kombi-Übungen - Performance Update 1 (Phase 0-2)' (#33) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 32s
Test Suite / playwright-tests (push) Successful in 58s
Reviewed-on: #33
2026-05-14 09:09:55 +02:00
81d1e9bdfd Merge pull request 'minor improvements. Darstellung, Handlung, Popups' (#32) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 59s
Reviewed-on: #32
2026-05-13 22:02:42 +02:00
3214055531 Merge pull request 'Module und Kombinationsübnungen in Version 0.8' (#31) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 54s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / playwright-tests (push) Successful in 1m9s
Reviewed-on: #31
2026-05-13 16:16:49 +02:00
4c974620d8 Merge pull request 'DGSVO Compliance update 1' (#30) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 57s
Reviewed-on: #30
2026-05-12 06:34:14 +02:00
3134160003 Merge pull request 'feat(admin): restrict admin access and enhance navigation for superadmins' (#29) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 26s
Reviewed-on: #29
2026-05-09 13:30:32 +02:00
b19940c997 Merge pull request 'Administration für Vereinsadmin, Sperren von Usern, Inbox, Medienmanger für alle, etc.' (#28) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 26s
Reviewed-on: #28
2026-05-09 11:01:02 +02:00
dbc9057601 Merge pull request 'feat(exercises): bump version to 0.8.65 and enhance club media copyright handling' (#27) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #27
2026-05-09 09:05:28 +02:00
6136813f60 Merge pull request 'feat(clubs): enhance co-trainer ID handling for training groups' (#26) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #26
2026-05-08 13:39:58 +02:00
d82b805ffa Merge pull request 'feat(exercises): update to version 0.8.64 and enhance inline media functionality' (#25) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 31s
Reviewed-on: #25
2026-05-08 13:28:30 +02:00
c8c40474d1 Merge pull request 'Inline Medien' (#24) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #24
2026-05-08 12:39:27 +02:00
f035b5bb0b Merge pull request 'MediaPfad extern, Upload Manager Bug Fixes' (#23) from develop into main
All checks were successful
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 25s
Reviewed-on: #23
2026-05-08 11:17:12 +02:00
696cb09bf4 Merge pull request 'Medienmanager update 1' (#22) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #22
2026-05-07 16:30:20 +02:00
33fedd5ec6 Merge pull request 'Medienmanager und Sicherheitsupdate' (#21) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 24s
Reviewed-on: #21
2026-05-07 16:00:18 +02:00
c8a08f8a94 Merge pull request 'pre-Prod Alpha' (#18) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #18
2026-05-07 10:37:21 +02:00
d04ebee1f6 Merge pull request 'UX - Filter' (#12) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
Reviewed-on: #12
2026-05-06 21:25:56 +02:00
0c1fbab0ef Merge pull request 'Mandantenfähigkeit V1.1' (#11) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 11s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 22s
Reviewed-on: #11
2026-05-05 23:11:49 +02:00
a7d68c0646 Merge pull request 'Mandantenfähigkeit V1' (#10) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 46s
Reviewed-on: #10
2026-05-05 22:34:25 +02:00
a34dc19f5d Merge pull request 'Trainingsplanung und Rahmenplanung' (#9) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 58s
Reviewed-on: #9
2026-05-05 16:05:01 +02:00
9227b98431 Merge pull request 'feat: enhance MediaWiki integration and error handling in SMW client' (#8) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 52s
Reviewed-on: #8
2026-04-29 13:56:58 +02:00
390b0ecb73 Merge pull request 'chore: clean up docker-compose files and enhance SQL migration for skills' (#7) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s
Reviewed-on: #7
2026-04-29 13:43:57 +02:00
7e7adfab54 Merge pull request 'bug fixes' (#6) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 1m29s
Reviewed-on: #6
2026-04-29 13:37:21 +02:00
bce235b9f6 Merge pull request 'feat: enhance Playwright configuration and CI workflow for E2E testing' (#5) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 4s
Reviewed-on: #5
2026-04-29 12:37:28 +02:00
5334836207 Merge pull request 'feat: enhance database migration handling and health check endpoint' (#4) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m55s
Reviewed-on: #4
2026-04-29 12:31:03 +02:00
38d78abc3b Merge pull request 'bug fix' (#3) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m59s
Reviewed-on: #3
2026-04-29 11:54:17 +02:00
a143532486 Merge pull request 'Version 0.1' (#2) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m53s
Reviewed-on: #2
2026-04-29 11:20:41 +02:00
2f54fef88e Merge pull request 'feat: update docker-compose and Nginx configuration for improved service dependencies and API routing' (#1) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m56s
Reviewed-on: #1
2026-04-29 08:52:11 +02:00
47 changed files with 386 additions and 4037 deletions

View File

@ -15,7 +15,7 @@
**Plattform-Rechtstexte (P-01, 0.8.950.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.1370.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**, **F15** Match-Dialog + getrennte Pfad-QS lokal): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8.
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.1370.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8.
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)

View File

@ -1,6 +1,6 @@
# Progressionsgraph — Slot-Editor (Phase B + F15)
# Progressionsgraph — Slot-Editor (Phase B)
**Stand:** 2026-05-22 · **Status:** Umgesetzt (F14 + F15 lokal nach 0.8.233)
**Stand:** 2026-06-10 · **Status:** In Umsetzung
## Ziel
@ -35,52 +35,35 @@ Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgende
slots: Slot[], // index = major_step_index
pathSkillExpectations?,
lastFindings?, // path_qa-Snapshot
findingsStale?: boolean, // Bewertung veraltet (↔ Artefakt findings_stale)
dirty: boolean,
}
```
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, `last_findings`, **`findings_stale`**.
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`.
## Findings-Panel
Nutzt `path_qa`:
| Feld | Bedeutung |
|------|-----------|
| `quality_score` | Gesamt = **min(`roadmap_qa`, `assignment_qa`)** |
| `roadmap_qa` | Stufen/Roadmap (LLM `topic_coverage`, …) |
| `assignment_qa` | Slot-Befüllung (`empty_slot_count`, …) |
| `overall_ok`, `issues`, `recommendations`, `gap_fill_offers`, … | wie bisher |
Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …).
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
**Bewertung veraltet:** Jede Graph-Änderung setzt `findingsStale: true` → Banner im Panel. Nach „Graph bewerten“ → `false`. Persistenz: `planning_roadmap.findings_stale`.
## Match-Flow („Übungen matchen“)
1. **Schritt 1:** `evaluate_only` + volle Pfad-QS (wie „Graph bewerten“)
2. **Schritt 2:** `unified_slot_review: true`**`ProgressionOptimizeCompareModal`**
3. Pro Slot: aktuell vs. beste Bibliothek vs. optional KI-Vorschlag
4. **Vorauswahl:** Bibliothek nur wenn Stufen-Fit ≥ 50 % und besser als Baseline; sonst KI (bei leerem/schwachem Slot)
5. **Übernahme:** nur gewählte Slots speichern — **keine** automatische Nach-Bewertung
Persistenz: `planning_roadmap.last_findings`.
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
Optional:
Zusätzlich optional:
- `slot_contents[]``{ major_step_index, primary, siblings[] }`
- `last_findings` — letzter `path_qa`-Snapshot
- **`findings_stale`** — bool, Bewertung bezieht sich nicht mehr auf aktuellen Graph-Stand
## UI (konsolidiert)
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
- **Slot-Keys:** stabil `slot-{index}` (nicht Lernziel-Text) — sonst Fokusverlust beim Tippen
- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
## Ersetzt (Legacy, nicht mehr im Panel)
@ -88,14 +71,11 @@ Optional:
## Implementierungsreihenfolge
| ID | Inhalt | Status |
|----|--------|--------|
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | ✅ |
| B.1 | Slot-Karten, Bibliothek + Entwurf | ✅ |
| B.2 | Findings-Panel + `evaluate_only` | ✅ |
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | ✅ |
| B.4 | Route + Panel vereinfachen | ✅ |
| B.5 | `last_findings` + Phase-C-Vorbereitung | ✅ |
| F15 | Unified Slot-Review, getrennte QS, `findings_stale` | ✅ |
**Ist-Doku:** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` §8.1 · `docs/HANDOVER.md` §2.8 F15
| ID | Inhalt |
|----|--------|
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten |
| B.1 | Slot-Karten, Bibliothek + Entwurf |
| B.2 | Findings-Panel + `evaluate_only` |
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ |
| B.4 | Route + Panel vereinfachen |
| B.5 | `last_findings` + Phase-C-Vorbereitung |

View File

@ -1,317 +0,0 @@
"""
Admin-Vorschau: Platzhalter für Planungs-Prompts (Progressionsgraph, Pfad-QS, Suggest).
Nutzt repräsentative Beispieldaten + echte Katalog-Auszüge aus der DB.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Mapping, Optional
from pydantic import BaseModel, Field
from planning_exercise_semantics import brief_to_summary_dict, build_semantic_brief
from planning_intent_context import build_planning_intent_context
from planning_prompt_variables import merge_planning_prompt_variables
PLANNING_PROMPT_SLUGS = frozenset(
{
"planning_progression_start_target",
"planning_progression_goal_analysis",
"planning_progression_roadmap",
"planning_progression_stage_spec",
"planning_exercise_query_semantics",
"planning_exercise_path_qa",
"planning_exercise_search_intent",
"planning_exercise_search_rank",
"planning_exercise_expectation_profile",
}
)
class PlanningPromptPreviewInput(BaseModel):
goal_query: str = Field(
default="Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe",
max_length=2000,
)
user_notes: str = Field(default="Fokus Breitensport, ohne Wettkampfdruck.", max_length=2000)
max_steps: int = Field(default=5, ge=2, le=10)
search_query: Optional[str] = Field(default=None, max_length=2000)
planning_catalog_context: Optional[Dict[str, Any]] = Field(default=None)
def is_planning_prompt_slug(slug: str) -> bool:
return (slug or "").strip().lower() in PLANNING_PROMPT_SLUGS
def _compact_json(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def _sample_goal_analysis() -> Dict[str, Any]:
return {
"primary_topic": "Mae Geri",
"start_assumption": "Grundstellung und einfache Frontkick-Bewegung bekannt",
"target_state": "Kontrollierter Mae Geri in Kumite-Nähe mit Hüftöffnung",
"success_criteria": [
"Hüfte öffnet vor dem Kick",
"Ballen trifft Zielzone",
"Rückzug ohne Balanceverlust",
],
"constraints": {
"partner_required": False,
"excluded_themes": ["reine Kraft ohne Technikbezug"],
"trainer_notes": "Breitensport, kein Wettkampf",
},
}
def _sample_major_steps(max_steps: int) -> List[Dict[str, Any]]:
phases = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"]
titles = [
"Grundstellung und Mae Geri Einstieg",
"Hüftöffnung und Ballen-Fokus",
"Koordination und Rückzug",
"Anwendung in Partnerübung",
"Qualität unter leichtem Druck",
]
out: List[Dict[str, Any]] = []
for i in range(max_steps):
out.append(
{
"index": i,
"phase": phases[min(i, len(phases) - 1)],
"title": titles[min(i, len(titles) - 1)],
"learning_goal": titles[min(i, len(titles) - 1)],
}
)
return out
def _sample_path_steps() -> List[Dict[str, Any]]:
return [
{
"index": 1,
"exercise_id": 101,
"title": "Mae Geri — Stand und Hüftöffnung",
"goal": "Frontkick mit geöffneter Hüfte aus Grundstellung",
"is_bridge": False,
"is_ai_proposal": False,
"reasons": ["Stufen-Gate: Grundlagen"],
},
{
"index": 2,
"exercise_id": 102,
"title": "Mae Geri — Ballen und Rückzug",
"goal": "Präziser Ballentreffer mit kontrolliertem Rückzug",
"is_bridge": False,
"is_ai_proposal": False,
"reasons": ["Nachfolger im Graph"],
},
]
def _sample_planning_context() -> Dict[str, Any]:
return {
"scope": "progression_path",
"goal_query": "Mae Geri vom Grundschritt bis zur Kumite-Nähe",
"stage_index": 1,
"learning_goal": "Hüftöffnung und Ballen-Fokus",
}
def _sample_target_profile() -> Dict[str, Any]:
return {
"primary_focus": "Kihon",
"training_type": "Breitensport",
"skill_expectations": ["Geri Waza", "Koordination"],
}
def _sample_candidates() -> List[Dict[str, Any]]:
return [
{
"exercise_id": 101,
"title": "Mae Geri — Stand und Hüftöffnung",
"summary": "Frontkick mit Hüftöffnung",
"skill_names": ["Geri Waza"],
"score_hint": 0.82,
},
{
"exercise_id": 102,
"title": "Mae Geri — Ballen und Rückzug",
"summary": "Ballentreffer mit Rückzug",
"skill_names": ["Geri Waza", "Koordination"],
"score_hint": 0.76,
},
]
def _load_catalog_variables(cur) -> Dict[str, str]:
from planning_exercise_intent import (
_load_compact_catalog,
_load_skills_catalog_compact,
)
return {
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
}
def _preview_catalog_context(body: PlanningPromptPreviewInput):
from planning_catalog_context import catalog_context_from_mapping
raw = body.planning_catalog_context
if raw:
return catalog_context_from_mapping(raw)
return None
def _merge_catalog_preview(cur, slug: str, base: Dict[str, str], body: PlanningPromptPreviewInput) -> Dict[str, str]:
return merge_planning_prompt_variables(
cur,
base,
catalog=_preview_catalog_context(body),
slug=slug,
)
def resolve_planning_prompt_preview_variables(
cur,
slug: str,
body: PlanningPromptPreviewInput,
) -> Dict[str, str]:
"""Mustache-Variablen für Planungs-Prompt-Vorschau im Admin."""
s = (slug or "").strip().lower()
if s not in PLANNING_PROMPT_SLUGS:
raise ValueError(f"Kein Planungs-Prompt-Slug: {slug!r}")
goal_query = (body.goal_query or "").strip() or "Mae Geri Progression"
search_query = (body.search_query or "").strip() or goal_query
max_steps = int(body.max_steps)
brief = build_semantic_brief(goal_query)
brief_json = _compact_json(brief_to_summary_dict(brief))
goal_analysis = _sample_goal_analysis()
major_steps = _sample_major_steps(max_steps)
intent_ctx = build_planning_intent_context(
goal_query=goal_query,
goal_analysis=goal_analysis,
semantic_brief=brief,
extra_context=(body.user_notes or "").strip() or None,
)
intent_ctx_json = _compact_json(intent_ctx.to_api_dict())
ctx = _sample_planning_context()
target = _sample_target_profile()
catalogs = _load_catalog_variables(cur)
if s == "planning_progression_start_target":
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"semantic_brief_json": brief_json,
"user_notes": (body.user_notes or "").strip(),
},
body,
)
if s == "planning_progression_goal_analysis":
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"semantic_brief_json": brief_json,
},
body,
)
if s == "planning_progression_roadmap":
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"goal_analysis_json": _compact_json(goal_analysis),
"semantic_brief_json": brief_json,
"max_steps": str(max_steps),
},
body,
)
if s == "planning_progression_stage_spec":
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"goal_analysis_json": _compact_json(goal_analysis),
"major_steps_json": _compact_json(major_steps),
"intent_context_json": intent_ctx_json,
"semantic_brief_json": brief_json,
},
body,
)
if s == "planning_exercise_query_semantics":
return {
"search_query": search_query,
"semantic_brief_json": brief_json,
}
if s == "planning_exercise_path_qa":
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"semantic_brief_json": brief_json,
"steps_json": _compact_json(_sample_path_steps()),
"gaps_json": _compact_json([]),
"bridge_inserts_json": _compact_json([]),
},
body,
)
if s == "planning_exercise_search_intent":
return {
"search_query": search_query,
"heuristic_intent": "progression_next",
"scenario_hint": "preset_next",
"planning_context_json": _compact_json(ctx),
"target_profile_json": _compact_json(target),
**catalogs,
}
if s == "planning_exercise_search_rank":
return {
"search_query": search_query,
"intent": "progression_next",
"planning_context_json": _compact_json(ctx),
"target_profile_json": _compact_json(target),
"candidates_json": _compact_json(_sample_candidates()),
"result_limit": "5",
}
if s == "planning_exercise_expectation_profile":
return {
"heuristic_intent": "suggest_next",
"planning_context_json": _compact_json(ctx),
"target_profile_json": _compact_json(target),
**{k: v for k, v in catalogs.items() if k != "style_directions_catalog_json"},
}
raise ValueError(f"Planungs-Prompt-Slug nicht implementiert: {slug!r}")
__all__ = [
"PLANNING_PROMPT_SLUGS",
"PlanningPromptPreviewInput",
"is_planning_prompt_slug",
"resolve_planning_prompt_preview_variables",
]

View File

@ -1,432 +0,0 @@
"""
Katalog-Prompt-Slots Slot-Typ-Vokabular + Werte pro Stammdaten-Zeile (H2).
Prompts in ai_prompts referenzieren Platzhalter wie {{focus_area_hints_on_progression}}.
Inhalte liegen in catalog_prompt_slots (Admin-editierbar), nicht im Code pro Eintrag.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from planning_catalog_context import (
ProgressionPlanningCatalogContext,
PlanningCatalogContextItem,
catalog_context_has_items,
)
from catalog_slot_fallbacks import merge_stored_slots_with_fallbacks
# ---------------------------------------------------------------------------
# Dimensionen (Prioritätsreihenfolge)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class CatalogKindConfig:
kind: str
table: str
context_attr: str
label_de: str
CATALOG_KINDS: Tuple[CatalogKindConfig, ...] = (
CatalogKindConfig("focus_area", "focus_areas", "focus_areas", "Primärfokus"),
CatalogKindConfig("training_type", "training_types", "training_types", "Trainingsstil"),
CatalogKindConfig("target_group", "target_groups", "target_groups", "Zielgruppe"),
CatalogKindConfig("style_direction", "style_directions", "style_directions", "Stilrichtung"),
)
_KIND_BY_NAME = {c.kind: c for c in CATALOG_KINDS}
# ---------------------------------------------------------------------------
# Slot-Typen (Vokabular — erweiterbar via catalog_prompt_slot_types)
# ---------------------------------------------------------------------------
SLOT_KEYS: Tuple[str, ...] = (
"description",
"hints_on_progression",
"hints_on_exercise",
"hints_on_path_qa",
"anti_patterns",
"rematch_guard",
)
LLM_SLOT_KEYS: Tuple[str, ...] = tuple(k for k in SLOT_KEYS if k != "rematch_guard")
GUIDANCE_BLOCK_SLOTS: Tuple[str, ...] = (
"description",
"hints_on_progression",
"hints_on_path_qa",
"anti_patterns",
)
GUIDANCE_PROFILE_BY_SLUG: Dict[str, Tuple[str, ...]] = {
"planning_exercise_path_qa": ("description", "hints_on_path_qa", "anti_patterns"),
"planning_progression_roadmap": ("description", "hints_on_progression", "anti_patterns"),
"planning_progression_stage_spec": ("hints_on_progression", "anti_patterns", "description"),
"planning_progression_goal_analysis": ("description", "hints_on_progression"),
"planning_progression_start_target": ("description",),
}
def placeholder_key(catalog_kind: str, slot_key: str) -> str:
return f"{catalog_kind}_{slot_key}"
def all_placeholder_keys() -> List[str]:
keys: List[str] = []
for cfg in CATALOG_KINDS:
for slot in SLOT_KEYS:
keys.append(placeholder_key(cfg.kind, slot))
keys.extend(["catalog_guidance_block", "catalog_context_json", "has_catalog_guidance"])
return keys
def empty_catalog_variables() -> Dict[str, str]:
out = {k: "" for k in all_placeholder_keys()}
return out
# ---------------------------------------------------------------------------
# Katalog-Kontext → aktiver Eintrag
# ---------------------------------------------------------------------------
def pick_active_catalog_item(
items: Sequence[PlanningCatalogContextItem],
) -> Optional[PlanningCatalogContextItem]:
if not items:
return None
primaries = [i for i in items if i.is_primary]
if primaries:
return primaries[0]
if len(items) == 1:
return items[0]
return max(items, key=lambda i: (float(i.weight), -int(i.id)))
def _load_catalog_row(cur, table: str, item_id: int) -> Optional[Dict[str, Any]]:
cur.execute(
f"""
SELECT id, name, description
FROM {table}
WHERE id = %s
""",
(int(item_id),),
)
row = cur.fetchone()
if not row:
return None
return {
"id": int(row["id"]),
"name": str(row.get("name") or "").strip(),
"description": str(row.get("description") or "").strip(),
}
def _load_slots_for_entry(cur, catalog_kind: str, catalog_id: int) -> Dict[str, str]:
cur.execute(
"""
SELECT slot_key, content
FROM catalog_prompt_slots
WHERE catalog_kind = %s AND catalog_id = %s
""",
(catalog_kind, int(catalog_id)),
)
out: Dict[str, str] = {}
for row in cur.fetchall():
key = str(row.get("slot_key") or "").strip()
if key:
out[key] = str(row.get("content") or "").strip()
return out
def _slot_types_table_ready(cur) -> bool:
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.catalog_prompt_slot_types",))
row = cur.fetchone()
if not row:
return False
val = row.get("t") if isinstance(row, dict) else row[0]
return val is not None and str(val).strip() != ""
def list_slot_type_definitions(cur) -> List[Dict[str, Any]]:
if not _slot_types_table_ready(cur):
return _fallback_slot_type_rows()
cur.execute(
"""
SELECT slot_key, display_name, description, applicable_kinds, sort_order, for_llm, for_code
FROM catalog_prompt_slot_types
ORDER BY sort_order ASC NULLS LAST, slot_key ASC
"""
)
rows = []
for row in cur.fetchall():
d = dict(row)
kinds = d.get("applicable_kinds")
if isinstance(kinds, str):
kinds = [k.strip() for k in kinds.strip("{}").split(",") if k.strip()]
d["applicable_kinds"] = list(kinds or [])
rows.append(d)
return rows
def _fallback_slot_type_rows() -> List[Dict[str, Any]]:
labels = {
"description": "Allgemeine Beschreibung",
"hints_on_progression": "Hinweise Progressionsgraph",
"hints_on_exercise": "Hinweise Übungsanlage",
"hints_on_path_qa": "Hinweise Pfad-QS",
"anti_patterns": "Anti-Patterns (Fehlbewertung vermeiden)",
"rematch_guard": "Rematch-Guard (Code)",
}
kinds = [c.kind for c in CATALOG_KINDS]
rows = []
for i, key in enumerate(SLOT_KEYS):
rows.append(
{
"slot_key": key,
"display_name": labels.get(key, key),
"description": "",
"applicable_kinds": kinds,
"sort_order": (i + 1) * 10,
"for_llm": key != "rematch_guard",
"for_code": key == "rematch_guard",
}
)
return rows
def _resolve_entry_slot_values(
stored: Mapping[str, str],
row: Mapping[str, Any],
catalog_kind: str,
) -> Dict[str, str]:
"""DB → Namens-Fallback → Stammdaten-Beschreibung (nur description)."""
return merge_stored_slots_with_fallbacks(
stored,
catalog_kind=catalog_kind,
name=str(row.get("name") or ""),
stammdaten_description=str(row.get("description") or ""),
)
def get_catalog_entry_slots(cur, catalog_kind: str, catalog_id: int) -> Dict[str, Any]:
cfg = _KIND_BY_NAME.get((catalog_kind or "").strip())
if not cfg:
raise ValueError(f"Unbekannter catalog_kind: {catalog_kind!r}")
row = _load_catalog_row(cur, cfg.table, catalog_id)
if not row:
raise LookupError("Katalog-Eintrag nicht gefunden")
stored = _load_slots_for_entry(cur, cfg.kind, catalog_id)
merged = _resolve_entry_slot_values(stored, row, cfg.kind)
return {
"catalog_kind": cfg.kind,
"catalog_id": int(catalog_id),
"name": row["name"],
"slots": merged,
"stored_slots": {k: stored.get(k, "") for k in SLOT_KEYS},
}
def upsert_catalog_entry_slots(
cur,
catalog_kind: str,
catalog_id: int,
slots: Mapping[str, Any],
) -> Dict[str, Any]:
cfg = _KIND_BY_NAME.get((catalog_kind or "").strip())
if not cfg:
raise ValueError(f"Unbekannter catalog_kind: {catalog_kind!r}")
row = _load_catalog_row(cur, cfg.table, catalog_id)
if not row:
raise LookupError("Katalog-Eintrag nicht gefunden")
for slot_key, raw in (slots or {}).items():
sk = str(slot_key or "").strip()
if sk not in SLOT_KEYS:
continue
content = str(raw or "").strip()
if not content:
cur.execute(
"""
DELETE FROM catalog_prompt_slots
WHERE catalog_kind = %s AND catalog_id = %s AND slot_key = %s
""",
(cfg.kind, int(catalog_id), sk),
)
continue
cur.execute(
"""
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content, updated_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
""",
(cfg.kind, int(catalog_id), sk, content),
)
return get_catalog_entry_slots(cur, cfg.kind, catalog_id)
def _render_dimension_section(
label_de: str,
name: str,
slot_values: Mapping[str, str],
*,
slot_keys: Sequence[str],
) -> Optional[str]:
parts: List[str] = [f"### {label_de}{name}"]
labels = {
"description": "Beschreibung",
"hints_on_progression": "Progressions-Hinweise",
"hints_on_path_qa": "QS-Hinweise",
"hints_on_exercise": "Übungsanlage",
"anti_patterns": "Vermeiden",
}
added = False
for sk in slot_keys:
text = str(slot_values.get(sk) or "").strip()
if not text:
continue
added = True
if sk == "description":
parts.append(text)
else:
parts.append(f"{labels.get(sk, sk)}: {text}")
if not added:
return None
return "\n".join(parts)
def _compose_guidance_block(
sections: List[str],
) -> str:
if not sections:
return ""
return "## Katalog-Kontext (Didaktik & Bewertung)\n\n" + "\n\n".join(sections)
def resolve_catalog_prompt_variables(
cur,
catalog: Optional[ProgressionPlanningCatalogContext],
*,
slug: Optional[str] = None,
) -> Dict[str, Any]:
"""
Liefert Mustache-Strings + Metadaten.
Returns dict mit allen {{kind_slot}} Keys, catalog_guidance_block, catalog_context_json,
has_catalog_guidance (bool), active_slots (list).
"""
variables = empty_catalog_variables()
meta: Dict[str, Any] = {
"active_slots": [],
"audit": {},
}
if cur is None or not catalog_context_has_items(catalog):
variables["catalog_context_json"] = ""
return {**variables, **meta}
profile = GUIDANCE_PROFILE_BY_SLUG.get((slug or "").strip().lower(), GUIDANCE_BLOCK_SLOTS)
sections: List[str] = []
audit: Dict[str, Any] = {}
has_any = False
active_slots: List[str] = []
for cfg in CATALOG_KINDS:
items = getattr(catalog, cfg.context_attr, None) or []
active = pick_active_catalog_item(items)
if not active:
continue
row = _load_catalog_row(cur, cfg.table, active.id)
if not row:
continue
stored = _load_slots_for_entry(cur, cfg.kind, row["id"]) if _slot_types_table_ready(cur) else {}
slot_values = _resolve_entry_slot_values(stored, row, cfg.kind)
for sk in SLOT_KEYS:
pk = placeholder_key(cfg.kind, sk)
text = slot_values.get(sk, "")
variables[pk] = text
if text.strip() and sk in LLM_SLOT_KEYS:
has_any = True
active_slots.append(pk)
audit[cfg.context_attr] = {
"catalog_kind": cfg.kind,
"id": row["id"],
"name": row["name"],
"is_primary": bool(active.is_primary),
"weight": float(active.weight),
"filled_slots": [k for k in LLM_SLOT_KEYS if slot_values.get(k, "").strip()],
"stored_slots": [k for k in SLOT_KEYS if (stored.get(k) or "").strip()],
}
section = _render_dimension_section(cfg.label_de, row["name"], slot_values, slot_keys=profile)
if section:
sections.append(section)
variables["catalog_guidance_block"] = _compose_guidance_block(sections)
ctx_json = json.dumps(audit, ensure_ascii=False, separators=(",", ":"))
variables["catalog_context_json"] = f"Katalog-Audit: {ctx_json}" if audit else ""
variables["has_catalog_guidance"] = "true" if has_any else ""
return {
**variables,
"active_slots": active_slots,
"audit": audit,
}
def get_rematch_guard_for_catalog(
cur,
catalog: Optional[ProgressionPlanningCatalogContext],
) -> Optional[str]:
"""Erste passende rematch_guard entlang der Dimensions-Priorität."""
if cur is None or not catalog_context_has_items(catalog):
return None
for cfg in CATALOG_KINDS:
items = getattr(catalog, cfg.context_attr, None) or []
active = pick_active_catalog_item(items)
if not active:
continue
stored = _load_slots_for_entry(cur, cfg.kind, active.id)
row = _load_catalog_row(cur, cfg.table, active.id)
if not row:
continue
slot_values = _resolve_entry_slot_values(stored, row, cfg.kind)
guard = (slot_values.get("rematch_guard") or "").strip()
if guard:
return guard
return None
# Abwärtskompatibilität H1-API
def build_catalog_guidance_for_prompt(
cur,
catalog: Optional[ProgressionPlanningCatalogContext],
*,
slug: Optional[str] = None,
) -> Dict[str, Any]:
resolved = resolve_catalog_prompt_variables(cur, catalog, slug=slug)
return {
"catalog_guidance_block": resolved.get("catalog_guidance_block", ""),
"catalog_context_json": resolved.get("catalog_context_json", ""),
"has_catalog_guidance": resolved.get("has_catalog_guidance") == "true",
"snippet_keys": list(resolved.get("active_slots") or []),
"variables": {k: str(resolved.get(k) or "") for k in all_placeholder_keys()},
}
__all__ = [
"CATALOG_KINDS",
"GUIDANCE_PROFILE_BY_SLUG",
"SLOT_KEYS",
"build_catalog_guidance_for_prompt",
"empty_catalog_variables",
"get_catalog_entry_slots",
"get_rematch_guard_for_catalog",
"list_slot_type_definitions",
"pick_active_catalog_item",
"placeholder_key",
"all_placeholder_keys",
"resolve_catalog_prompt_variables",
"upsert_catalog_entry_slots",
]

View File

@ -1,284 +0,0 @@
"""
Namensbasierte Fallback-Slots bis Admin/DB befüllt sind (H1-Registry-Inhalt).
DB-Werte in catalog_prompt_slots haben immer Vorrang. Fallbacks füllen nur leere Slot-Keys.
"""
from __future__ import annotations
import re
import unicodedata
from typing import Dict, Mapping, Optional, Sequence, Tuple
_UMLAUT_MAP = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss", "Ä": "ae", "Ö": "oe", "Ü": "ue"})
SlotPack = Dict[str, str]
# (catalog_kind, name_pattern_lower) — erste passende Regel gewinnt; * = Default pro Kind
_FALLBACK_RULES: Tuple[Tuple[str, str, SlotPack], ...] = (
# --- focus_area ---
(
"focus_area",
"gewaltschutz",
{
"description": (
"Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — "
"nicht auf Wettkampf-Perfektion oder Technik-Show."
),
"hints_on_progression": (
"Phasen: Wahrnehmung → Grenzen → Deeskalation → sichere Übungsformen; "
"keine Kumite-Perfektionsstufen erzwingen."
),
"hints_on_exercise": (
"Übungen mit Rollen, Kommunikation, Ausweichen; keine rein technischen Kick-Fokus-Inseln ohne Bezug."
),
"hints_on_path_qa": (
"Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; "
"„Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten."
),
"anti_patterns": "Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.",
},
),
(
"focus_area",
"selbstverteidigung",
{
"description": (
"Praktische Selbstverteidigung: realistische Szenarien, Sicherheit und "
"anwendungsnahe Progression — nicht Show-Technik oder Wettkampf-Kata."
),
"hints_on_progression": (
"Von Wahrnehmung und Distanz zu einfachen Abwehrmustern und kontrollierter Anwendung."
),
"hints_on_exercise": "Partnerübungen mit klaren Sicherheitsregeln; Szenario-Bezug wichtiger als Stil-Show.",
"hints_on_path_qa": (
"Lücken bei Szenario- oder Sicherheitsstufen sind relevant; "
"fehlende Kick-Varianten oder Wettkampftiefe sind kein Mangel."
),
"anti_patterns": "Keine Wettkampf- oder Kata-Perfektion als QS-Maßstab.",
},
),
(
"focus_area",
"fitness",
{
"description": (
"Fitness- und Konditionsorientierung mit sicherer Belastungssteuerung; "
"Technikbezug nur wo fachlich sinnvoll."
),
"hints_on_progression": "Progression von niedriger zu moderater Belastung; klare Pausen und Technikhygiene.",
"hints_on_path_qa": (
"Keine Wettkampf-Spezialisierung als Pflicht-Kriterium; "
"Belastungssteigerung ohne Technikbezug abwerten."
),
"anti_patterns": "Keine Kumite-Perfektion oder Wettkampf-Kombinationen als QS-Maßstab verlangen.",
},
),
(
"focus_area",
"karate",
{
"description": (
"Technik-Curriculum im Karate-Kontext: aufeinander aufbauende Kihon-Progression "
"mit klaren Qualitätsankern (Stand, Hüfte, Kime)."
),
"hints_on_progression": (
"Typische Phasen: Einstieg → Grundlagen → Koordination/Kraft → Anwendung → optional Vertiefung; "
"Grundlagen vor Perfektion."
),
"hints_on_exercise": (
"Kihon und Partnerübungen mit Technikbezug; reine Kraft-/Ausdauer-Inseln nur mit klarer Begründung."
),
"hints_on_path_qa": (
"Kohärente Progression Grundlagen → Anwendung → Vertiefung; "
"Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten."
),
"anti_patterns": "Keine pauschale Perfektions-Stufe verlangen, wenn Kontext Breitensport ist.",
},
),
(
"focus_area",
"*",
{
"description": "Technik- oder Themen-Curriculum mit didaktisch aufeinander aufbauenden Stufen.",
"hints_on_progression": "Grundlagen vor Anwendung; moderate Sprünge zwischen Stufen vermeiden.",
"hints_on_path_qa": (
"Kohärente Progression zum Anfrage-Thema; "
"Lücken sind fehlende Zwischenstufen im Lernpfad, nicht fehlende Nebenthemen."
),
"hints_on_exercise": "Übungen mit klarem Bezug zum Pfad-Thema und zur Stufe.",
},
),
# --- training_type ---
(
"training_type",
"breitensport",
{
"description": (
"Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung."
),
"hints_on_progression": "Moderater Schwierigkeitsanstieg; Perfektionsphasen optional.",
"hints_on_path_qa": (
"Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; "
"„Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke."
),
"rematch_guard": "Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.",
},
),
(
"training_type",
"leistungssport",
{
"description": "Leistungsorientiertes Training mit höherer Anspruchskurve und Spezialisierung.",
"hints_on_progression": "Belastungs- und Kombinationsprogressionen sind erwünscht.",
"hints_on_path_qa": (
"Höhere Anspruchskurven sind passend; Lücken in Spezialisierung können echte Hinweise sein."
),
},
),
(
"training_type",
"wettkampf",
{
"description": (
"Wettkampforientiertes Training mit höherer Anspruchskurve und belastungsnahen Phasen."
),
"hints_on_progression": "Anwendungs- und Druckphasen zeitig einplanen.",
"hints_on_path_qa": (
"Spezialisierung, Kombination und Belastung unter Druck sind relevant; "
"Lücken in Anwendungs- oder Perfektionsphasen können echte Hinweise sein."
),
},
),
(
"training_type",
"*",
{
"hints_on_path_qa": "Didaktische Kohärenz wichtiger als maximale Spezialisierung — Kontext beachten.",
},
),
# --- target_group ---
(
"target_group",
"kinder",
{
"description": (
"Kinder: kurze Einheiten, spielerische Einstiege, Sicherheit und altersgerechte Komplexität."
),
"hints_on_progression": "Spielerische Einstiege; kurze Abschnitte; Sicherheit vor Perfektion.",
"hints_on_path_qa": (
"Didaktik ohne Überforderung; klare Regeln und Sicherheit vor Perfektion; "
"Lücken bei Spiel-/Rollenelementen wichtiger als Wettkampftiefe."
),
"anti_patterns": "Keine Erwachsenen-Wettkampf-Perfektion als QS-Maßstab.",
},
),
(
"target_group",
"leistungssportler",
{
"description": "Leistungsgruppe: höhere Anspruchskurven und Spezialisierung sind fachlich passend.",
"hints_on_progression": "Anspruchskurve und Spezialisierung dürfen steiler sein.",
"hints_on_path_qa": (
"Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; "
"Lücken in Spezialisierung können echte Hinweise sein."
),
},
),
(
"target_group",
"breitensportler",
{
"description": "Breitensport: Partizipation und Verständlichkeit vor maximaler Spezialisierung.",
"hints_on_path_qa": (
"Moderate Progression; Perfektions-Lücken sind selten echte Mängel."
),
"anti_patterns": "Keine Leistungssport-Perfektion als Pflicht-Kriterium.",
},
),
(
"target_group",
"*",
{
"hints_on_path_qa": "Zielgruppe im Tempo und in der Komplexität berücksichtigen.",
},
),
# --- style_direction ---
(
"style_direction",
"shotokan",
{
"description": (
"Shotokan-Linie: klare Kihon-Struktur, Hüft- und Standarbeit als wiederkehrende Qualitätsanker."
),
"hints_on_progression": "Nuancen in Stellung und Hüfttechnik; kein neuer Planungstyp.",
"hints_on_path_qa": "Konsistenz von Stand, Hüfte und Kime entlang des Pfads bewerten.",
},
),
(
"style_direction",
"*",
{
"hints_on_progression": (
"Stil-spezifische Nuancen (Stand, Hüfte, Rhythmus) einbeziehen — ohne Stilwechsel zu erzwingen."
),
},
),
)
def normalize_catalog_name_key(name: str) -> str:
s = unicodedata.normalize("NFKD", (name or "").translate(_UMLAUT_MAP))
s = s.encode("ascii", "ignore").decode("ascii").lower()
s = re.sub(r"[^a-z0-9]+", "_", s).strip("_")
return s or "unknown"
def get_fallback_slots_for_entry(catalog_kind: str, name: str) -> SlotPack:
kind = (catalog_kind or "").strip().lower()
norm = normalize_catalog_name_key(name)
default: SlotPack = {}
for rule_kind, pattern, pack in _FALLBACK_RULES:
if rule_kind != kind:
continue
if pattern == "*":
default = dict(pack)
continue
if pattern in norm or norm.startswith(pattern) or pattern in (name or "").lower():
return dict(pack)
return default
def merge_stored_slots_with_fallbacks(
stored: Mapping[str, str],
*,
catalog_kind: str,
name: str,
stammdaten_description: str = "",
) -> Dict[str, str]:
"""DB + Stammdaten-Beschreibung + Namens-Fallback."""
fallbacks = get_fallback_slots_for_entry(catalog_kind, name)
out: Dict[str, str] = {}
for key in (
"description",
"hints_on_progression",
"hints_on_exercise",
"hints_on_path_qa",
"anti_patterns",
"rematch_guard",
):
if key == "description":
out[key] = (
(stored.get(key) or "").strip()
or (fallbacks.get(key) or "").strip()
or (stammdaten_description or "").strip()
)
else:
out[key] = (stored.get(key) or "").strip() or (fallbacks.get(key) or "").strip()
return out
__all__ = [
"get_fallback_slots_for_entry",
"merge_stored_slots_with_fallbacks",
"normalize_catalog_name_key",
]

View File

@ -243,7 +243,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, catalog_prompt_slots, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@ -269,7 +269,6 @@ app.include_router(dashboard.router)
app.include_router(training_modules.router)
app.include_router(training_framework_programs.router)
app.include_router(catalogs.router)
app.include_router(catalog_prompt_slots.router)
app.include_router(maturity_models.router)
app.include_router(matrix_stack_bundle.router)
app.include_router(matrix_editor.router)

View File

@ -1,172 +0,0 @@
-- Migration 091: Planungs-KI H1 — Katalog-Guidance-Platzhalter in Progressions-Prompts
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
{{catalog_guidance_block}}
{{catalog_context_json}}
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
"phase": "vertiefung",
"insert_after_step_index": 2,
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
}
]
}$t$,
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
{{catalog_guidance_block}}
{{catalog_context_json}}
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
"phase": "vertiefung",
"insert_after_step_index": 2,
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
}
]
}$t$
WHERE slug = 'planning_exercise_path_qa';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
{{catalog_guidance_block}}
Wichtig: Keine Gruppenanalyse nur didaktischer Pfad für die Technik/das Thema.
Antworte NUR mit JSON:
{
"primary_topic": "Mae Geri",
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
"target_state": "Konkreter Zielzustand der Progression",
"success_criteria": ["messbare Kriterien"],
"constraints": { "partner_required": false }
}$t$,
default_template = template
WHERE slug = 'planning_progression_goal_analysis';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Semantic Brief: {{semantic_brief_json}}
Anzahl Major Steps (N): {{max_steps}}
{{catalog_guidance_block}}
{{catalog_context_json}}
Erzeuge zuerst 812 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps.
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion in sinnvoller Reihenfolge (Grundlagen vor Perfektion).
Beachte Katalog-Roadmap-Hinweise, falls gesetzt.
Antworte NUR mit JSON:
{
"micro_objectives": [
{ "id": "m1", "phase": "grundlage", "title": "", "weight": 0.9, "depends_on": [] }
],
"major_steps": [
{ "index": 0, "phase": "grundlage", "learning_goal": "", "consolidates": ["m1","m2"], "rationale": "" }
],
"consolidation_notes": [""]
}$t$,
default_template = template
WHERE slug = 'planning_progression_roadmap';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Major Steps: {{major_steps_json}}
{{catalog_guidance_block}}
{{catalog_context_json}}
Für jeden Major Step: messbares Lernziel, load_profile (z. B. koordination, präzision, kraft), exercise_type (kihon_einzel, partner_drill, kombination, kraft_auxiliary), success_criteria, anti_patterns (z. B. reine Kraft ohne Technikbezug).
Beachte Katalog-QS-Kriterien und Anti-Patterns, falls gesetzt.
Antworte NUR mit JSON:
{
"stage_specs": [
{
"major_step_index": 0,
"learning_goal": "",
"load_profile": ["koordination", "gleichgewicht"],
"exercise_type": "kihon_einzel",
"success_criteria": [""],
"anti_patterns": [""]
}
]
}$t$,
default_template = template
WHERE slug = 'planning_progression_stage_spec';

View File

@ -1,176 +0,0 @@
-- Migration 092: Katalog-Prompt-Slots (H2) — Slot-Typ-Vokabular + Werte pro Stammdaten-Zeile
CREATE TABLE IF NOT EXISTS catalog_prompt_slot_types (
slot_key VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(200) NOT NULL,
description TEXT,
applicable_kinds TEXT[] NOT NULL DEFAULT '{}',
sort_order INT DEFAULT 99,
for_llm BOOLEAN NOT NULL DEFAULT true,
for_code BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS catalog_prompt_slots (
id SERIAL PRIMARY KEY,
catalog_kind VARCHAR(32) NOT NULL,
catalog_id INT NOT NULL,
slot_key VARCHAR(64) NOT NULL REFERENCES catalog_prompt_slot_types(slot_key) ON DELETE CASCADE,
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (catalog_kind, catalog_id, slot_key)
);
CREATE INDEX IF NOT EXISTS idx_catalog_prompt_slots_kind_id
ON catalog_prompt_slots (catalog_kind, catalog_id);
INSERT INTO catalog_prompt_slot_types (slot_key, display_name, description, applicable_kinds, sort_order, for_llm, for_code)
VALUES
(
'description',
'Allgemeine Beschreibung',
'Fachliche Einordnung des Katalog-Eintrags für Planungs-KI.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
10,
true,
false
),
(
'hints_on_progression',
'Hinweise Progressionsgraph',
'Didaktik für Roadmap, Major Steps und Stufenspezifikation.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
20,
true,
false
),
(
'hints_on_exercise',
'Hinweise Übungsanlage',
'Kontext für Gap-Fill, Übungs-KI und Schnellanlage.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
30,
true,
false
),
(
'hints_on_path_qa',
'Hinweise Pfad-QS',
'Bewertungsmaßstäbe für Pfad-Qualitätssicherung.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
40,
true,
false
),
(
'anti_patterns',
'Anti-Patterns',
'Explizite Fehlbewertungen vermeiden.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
50,
true,
false
),
(
'rematch_guard',
'Rematch-Guard',
'Wann kein Auto-Rematch sinnvoll ist (primär Code-Logik).',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
60,
false,
true
)
ON CONFLICT (slot_key) DO NOTHING;
-- Seed aus H1-Registry (Name-Match auf Stammdaten)
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'description',
'Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.'
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'hints_on_path_qa',
'Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.'
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'anti_patterns',
'Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.'
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'description',
'Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung.'
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'hints_on_path_qa',
'Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; „Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke.'
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'rematch_guard',
'Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.'
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'description',
'Kinder: kurze Einheiten, spielerische Einstiege, Sicherheit und altersgerechte Komplexität.'
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'hints_on_path_qa',
'Didaktik ohne Überforderung; klare Regeln und Sicherheit vor Perfektion; Lücken bei Spiel-/Rollenelementen wichtiger als Wettkampftiefe.'
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'anti_patterns',
'Keine Erwachsenen-Wettkampf-Perfektion als QS-Maßstab.'
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'description',
'Leistungsgruppe: höhere Anspruchskurven und Spezialisierung sind fachlich passend.'
FROM target_groups tg WHERE tg.name ILIKE 'Leistungssportler'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'hints_on_path_qa',
'Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; Lücken in Spezialisierung können echte Hinweise sein.'
FROM target_groups tg WHERE tg.name ILIKE 'Leistungssportler'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'style_direction', sd.id, 'description',
'Shotokan-Linie: klare Kihon-Struktur, Hüft- und Standarbeit als wiederkehrende Qualitätsanker.'
FROM style_directions sd WHERE sd.name ILIKE 'Shotokan'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'style_direction', sd.id, 'hints_on_progression',
'Nuancen in Stellung und Hüfttechnik, kein neuer Planungstyp.'
FROM style_directions sd WHERE sd.name ILIKE 'Shotokan'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'description',
'Wettkampforientiertes Training mit höherer Anspruchskurve und belastungsnahen Phasen.'
FROM training_types tt WHERE tt.name ILIKE 'Wettkampf'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'hints_on_path_qa',
'Spezialisierung, Kombination und Belastung unter Druck sind relevant; Lücken in Anwendungs- oder Perfektionsphasen können echte Hinweise sein.'
FROM training_types tt WHERE tt.name ILIKE 'Wettkampf'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();

View File

@ -1,199 +0,0 @@
-- Migration 093: Planungs-KI — granulare Katalog-Slot-Platzhalter in Prompt-Templates
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Katalog-Kontext für Bewertung (Trainer-Auswahl leere Zeilen ignorieren):
Primärfokus:
{{focus_area_description}}
QS: {{focus_area_hints_on_path_qa}}
Vermeiden: {{focus_area_anti_patterns}}
Trainingsstil:
{{training_type_description}}
QS: {{training_type_hints_on_path_qa}}
Zielgruppe:
{{target_group_description}}
QS: {{target_group_hints_on_path_qa}}
Stilrichtung:
{{style_direction_description}}
QS: {{style_direction_hints_on_path_qa}}
{{catalog_context_json}}
Wichtig: Wenn Katalog-Slots gesetzt sind, haben diese Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte gemäß Katalog-QS-Hinweisen, nicht pauschal Perfektion?
6. Gibt es Schritte ohne Bezug zum Hauptthema?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes.
Wenn wichtige Zwischenschritte fehlen: suggested_new_exercises mit Titel + Kurzskizze und insert_after_step_index.
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": []
}$t$,
default_template = template
WHERE slug = 'planning_exercise_path_qa';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Katalog-Kontext (Primärfokus / Trainingsstil / Zielgruppe / Stil leere Zeilen ignorieren):
Primärfokus: {{focus_area_description}}
Progression: {{focus_area_hints_on_progression}}
Trainingsstil: {{training_type_description}}
Progression: {{training_type_hints_on_progression}}
Zielgruppe: {{target_group_description}}
Stilrichtung: {{style_direction_description}}
Wichtig: Keine Gruppenanalyse nur didaktischer Pfad. Katalog-Hinweise beachten.
Antworte NUR mit JSON:
{
"primary_topic": "Mae Geri",
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
"target_state": "Konkreter Zielzustand der Progression",
"success_criteria": ["messbare Kriterien"],
"constraints": { "partner_required": false }
}$t$,
default_template = template
WHERE slug = 'planning_progression_goal_analysis';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Semantic Brief: {{semantic_brief_json}}
Anzahl Major Steps (N): {{max_steps}}
Katalog-Kontext für Stufenlogik:
Primärfokus:
{{focus_area_description}}
Roadmap: {{focus_area_hints_on_progression}}
Vermeiden: {{focus_area_anti_patterns}}
Trainingsstil:
{{training_type_description}}
Roadmap: {{training_type_hints_on_progression}}
Zielgruppe:
{{target_group_description}}
Roadmap: {{target_group_hints_on_progression}}
Stilrichtung:
{{style_direction_description}}
Roadmap: {{style_direction_hints_on_progression}}
{{catalog_context_json}}
Erzeuge zuerst 812 micro_objectives, dann konsolidiere auf genau N major_steps.
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion Katalog-Roadmap-Hinweise beachten.
Antworte NUR mit JSON:
{
"micro_objectives": [
{ "id": "m1", "phase": "grundlage", "title": "", "weight": 0.9, "depends_on": [] }
],
"major_steps": [
{ "index": 0, "phase": "grundlage", "learning_goal": "", "consolidates": ["m1","m2"], "rationale": "" }
],
"consolidation_notes": [""]
}$t$,
default_template = template
WHERE slug = 'planning_progression_roadmap';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Major Steps: {{major_steps_json}}
Intent-Kontext: {{intent_context_json}}
Semantic Brief: {{semantic_brief_json}}
Katalog-Kontext je Stufe:
Primärfokus Progression: {{focus_area_hints_on_progression}}
Primärfokus Vermeiden: {{focus_area_anti_patterns}}
Trainingsstil Progression: {{training_type_hints_on_progression}}
Trainingsstil Vermeiden: {{training_type_anti_patterns}}
Zielgruppe Progression: {{target_group_hints_on_progression}}
Zielgruppe Vermeiden: {{target_group_anti_patterns}}
Stilrichtung Progression: {{style_direction_hints_on_progression}}
{{catalog_context_json}}
Für jeden Major Step: messbares Lernziel, load_profile, exercise_type, success_criteria, anti_patterns Katalog-Slots beachten.
Antworte NUR mit JSON:
{
"stage_specs": [
{
"major_step_index": 0,
"learning_goal": "",
"load_profile": ["koordination", "gleichgewicht"],
"exercise_type": "kihon_einzel",
"success_criteria": [""],
"anti_patterns": [""]
}
]
}$t$,
default_template = template
WHERE slug = 'planning_progression_stage_spec';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und extrahierst Start, Ziel und Ergänzungen für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Trainer-Notizen: {{user_notes}}
Katalog-Einordnung:
Primärfokus: {{focus_area_description}}
Trainingsstil: {{training_type_description}}
Zielgruppe: {{target_group_description}}
Antworte NUR mit JSON:
{
"primary_topic": "",
"start_situation": "",
"target_state": "",
"roadmap_notes": "",
"extraction_notes": ""
}$t$,
default_template = template
WHERE slug = 'planning_progression_start_target';

View File

@ -1,167 +0,0 @@
-- Migration 094: Vollständige Befüllung catalog_prompt_slots (H1-Inhalte + Defaults für alle Stammdaten)
CREATE TEMP TABLE IF NOT EXISTS _catalog_slot_seed (
catalog_kind VARCHAR(32) NOT NULL,
name_pattern TEXT NOT NULL,
slot_key VARCHAR(64) NOT NULL,
content TEXT NOT NULL
);
TRUNCATE _catalog_slot_seed;
-- Primärfokus Karate (häufigster Technik-Pfad)
INSERT INTO _catalog_slot_seed (catalog_kind, name_pattern, slot_key, content) VALUES
('focus_area', 'Karate', 'description',
'Technik-Curriculum im Karate-Kontext: aufeinander aufbauende Kihon-Progression mit klaren Qualitätsankern (Stand, Hüfte, Kime).'),
('focus_area', 'Karate', 'hints_on_progression',
'Typische Phasen: Einstieg → Grundlagen → Koordination/Kraft → Anwendung → optional Vertiefung; Grundlagen vor Perfektion.'),
('focus_area', 'Karate', 'hints_on_exercise',
'Kihon und Partnerübungen mit Technikbezug; reine Kraft-/Ausdauer-Inseln nur mit klarer Begründung.'),
('focus_area', 'Karate', 'hints_on_path_qa',
'Kohärente Progression Grundlagen → Anwendung → Vertiefung; Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten.'),
('focus_area', 'Karate', 'anti_patterns',
'Keine pauschale Perfektions-Stufe verlangen, wenn der Trainingsstil Breitensport ist.');
-- Selbstverteidigung
INSERT INTO _catalog_slot_seed VALUES
('focus_area', 'Selbstverteidigung', 'description',
'Praktische Selbstverteidigung: realistische Szenarien, Sicherheit und anwendungsnahe Progression — nicht Show-Technik oder Wettkampf-Kata.'),
('focus_area', 'Selbstverteidigung', 'hints_on_progression',
'Von Wahrnehmung und Distanz zu einfachen Abwehrmustern und kontrollierter Anwendung.'),
('focus_area', 'Selbstverteidigung', 'hints_on_exercise',
'Partnerübungen mit klaren Sicherheitsregeln; Szenario-Bezug wichtiger als Stil-Show.'),
('focus_area', 'Selbstverteidigung', 'hints_on_path_qa',
'Lücken bei Szenario- oder Sicherheitsstufen sind relevant; fehlende Kick-Varianten oder Wettkampftiefe sind kein Mangel.'),
('focus_area', 'Selbstverteidigung', 'anti_patterns',
'Keine Wettkampf- oder Kata-Perfektion als QS-Maßstab.');
-- Gewaltschutz (ergänzt 092)
INSERT INTO _catalog_slot_seed VALUES
('focus_area', 'Gewaltschutz', 'hints_on_progression',
'Phasen: Wahrnehmung → Grenzen → Deeskalation → sichere Übungsformen; keine Kumite-Perfektionsstufen erzwingen.'),
('focus_area', 'Gewaltschutz', 'hints_on_exercise',
'Übungen mit Rollen, Kommunikation, Ausweichen; keine rein technischen Kick-Fokus-Inseln ohne Bezug.');
-- Fitness (falls vorhanden)
INSERT INTO _catalog_slot_seed VALUES
('focus_area', 'Fitness', 'description',
'Fitness- und Konditionsorientierung mit sicherer Belastungssteuerung; Technikbezug nur wo fachlich sinnvoll.'),
('focus_area', 'Fitness', 'hints_on_progression',
'Progression von niedriger zu moderater Belastung; klare Pausen und Technikhygiene.'),
('focus_area', 'Fitness', 'hints_on_path_qa',
'Keine Wettkampf-Spezialisierung als Pflicht-Kriterium; Belastungssteigerung ohne Technikbezug abwerten.'),
('focus_area', 'Fitness', 'anti_patterns',
'Keine Kumite-Perfektion oder Wettkampf-Kombinationen als QS-Maßstab verlangen.');
-- Trainingsstile (global)
INSERT INTO _catalog_slot_seed VALUES
('training_type', 'Breitensport', 'hints_on_progression',
'Moderater Schwierigkeitsanstieg; Perfektionsphasen optional.'),
('training_type', 'Breitensport', 'anti_patterns',
'Keine Leistungssport-Perfektion als Pflicht-Lücke.'),
('training_type', 'Leistungssport', 'description',
'Leistungsorientiertes Training mit höherer Anspruchskurve und Spezialisierung.'),
('training_type', 'Leistungssport', 'hints_on_progression',
'Belastungs- und Kombinationsprogressionen sind erwünscht.'),
('training_type', 'Leistungssport', 'hints_on_path_qa',
'Höhere Anspruchskurven sind passend; Lücken in Spezialisierung können echte Hinweise sein.'),
('training_type', 'Wettkampf', 'hints_on_progression',
'Anwendungs- und Druckphasen zeitig einplanen.');
-- Zielgruppen
INSERT INTO _catalog_slot_seed VALUES
('target_group', 'Breitensportler', 'description',
'Breitensport: Partizipation und Verständlichkeit vor maximaler Spezialisierung.'),
('target_group', 'Breitensportler', 'hints_on_path_qa',
'Moderate Progression; Perfektions-Lücken sind selten echte Mängel.'),
('target_group', 'Breitensportler', 'anti_patterns',
'Keine Leistungssport-Perfektion als Pflicht-Kriterium.'),
('target_group', 'Kinder', 'hints_on_progression',
'Spielerische Einstiege; kurze Abschnitte; Sicherheit vor Perfektion.'),
('target_group', 'Leistungssportler', 'hints_on_progression',
'Anspruchskurve und Spezialisierung dürfen steiler sein.');
-- Stilrichtungen (generisch + Shotokan-Details via 092)
INSERT INTO _catalog_slot_seed VALUES
('style_direction', 'Goju-Ryu', 'hints_on_progression',
'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'),
('style_direction', 'Wado-Ryu', 'hints_on_progression',
'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'),
('style_direction', 'Shito-Ryu', 'hints_on_progression',
'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'),
('style_direction', 'Kyokushin', 'hints_on_progression',
'Stil-Nuancen (Stand, Belastung, Kime) einbeziehen — kein Stilwechsel erzwingen.');
-- Fokusbereiche: aus Seed-Tabelle
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT s.catalog_kind, fa.id, s.slot_key, s.content
FROM _catalog_slot_seed s
JOIN focus_areas fa ON fa.name ILIKE s.name_pattern
WHERE s.catalog_kind = 'focus_area'
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT s.catalog_kind, tt.id, s.slot_key, s.content
FROM _catalog_slot_seed s
JOIN training_types tt ON tt.name ILIKE s.name_pattern
WHERE s.catalog_kind = 'training_type'
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT s.catalog_kind, tg.id, s.slot_key, s.content
FROM _catalog_slot_seed s
JOIN target_groups tg ON tg.name ILIKE s.name_pattern
WHERE s.catalog_kind = 'target_group'
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT s.catalog_kind, sd.id, s.slot_key, s.content
FROM _catalog_slot_seed s
JOIN style_directions sd ON sd.name ILIKE s.name_pattern
WHERE s.catalog_kind = 'style_direction'
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
-- Default-Technik-Pack für Fokusbereiche ohne hints_on_path_qa (außer Gewaltschutz/Fitness)
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'hints_on_path_qa',
'Kohärente Progression zum Anfrage-Thema; Lücken sind fehlende Zwischenstufen im Lernpfad, nicht fehlende Nebenthemen.'
FROM focus_areas fa
WHERE fa.name NOT ILIKE 'Gewaltschutz'
AND fa.name NOT ILIKE 'Fitness'
AND NOT EXISTS (
SELECT 1 FROM catalog_prompt_slots cps
WHERE cps.catalog_kind = 'focus_area' AND cps.catalog_id = fa.id AND cps.slot_key = 'hints_on_path_qa'
AND TRIM(cps.content) <> ''
)
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING;
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'hints_on_progression',
'Grundlagen vor Anwendung; moderate Sprünge zwischen Stufen vermeiden.'
FROM focus_areas fa
WHERE fa.name NOT ILIKE 'Gewaltschutz'
AND fa.name NOT ILIKE 'Fitness'
AND NOT EXISTS (
SELECT 1 FROM catalog_prompt_slots cps
WHERE cps.catalog_kind = 'focus_area' AND cps.catalog_id = fa.id AND cps.slot_key = 'hints_on_progression'
AND TRIM(cps.content) <> ''
)
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING;
-- Stilrichtungen ohne Eintrag: generischer Progressions-Hinweis
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'style_direction', sd.id, 'hints_on_progression',
'Stil-spezifische Nuancen (Stand, Hüfte, Rhythmus) einbeziehen — ohne Stilwechsel zu erzwingen.'
FROM style_directions sd
WHERE NOT EXISTS (
SELECT 1 FROM catalog_prompt_slots cps
WHERE cps.catalog_kind = 'style_direction' AND cps.catalog_id = sd.id AND cps.slot_key = 'hints_on_progression'
AND TRIM(cps.content) <> ''
)
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING;
DROP TABLE IF EXISTS _catalog_slot_seed;

View File

@ -196,13 +196,6 @@ def openrouter_chat_completion(
cc,
)
try:
from planning_llm_usage import record_planning_llm_call
record_planning_llm_call(1)
except Exception:
pass
return joined

View File

@ -1,16 +0,0 @@
"""
Katalog-Prompt-Snippets Abwärtskompatibilität (H1-Importpfade).
Implementierung: catalog_prompt_slots.py (H2).
"""
from catalog_prompt_slots import (
build_catalog_guidance_for_prompt,
get_rematch_guard_for_catalog,
pick_active_catalog_item,
)
__all__ = [
"build_catalog_guidance_for_prompt",
"get_rematch_guard_for_catalog",
"pick_active_catalog_item",
]

View File

@ -2082,7 +2082,6 @@ def _run_evaluate_only_path_qa(
semantic_brief: PlanningSemanticBrief,
steps: List[Dict[str, Any]],
roadmap_ctx: Optional[ProgressionRoadmapContext],
catalog_context: Optional[ProgressionPlanningCatalogContext] = None,
) -> Dict[str, Any]:
roadmap_first = roadmap_ctx is not None
gaps: List[Dict[str, Any]] = []
@ -2096,9 +2095,6 @@ def _run_evaluate_only_path_qa(
gap_fill_offers: List[Dict[str, Any]] = []
roadmap_qa_mode: Optional[str] = None
if catalog_context is None:
catalog_context = _resolve_planning_catalog_context(cur, body)
if body.include_path_qa:
if roadmap_first:
roadmap_qa_mode = "roadmap_first_lite"
@ -2119,7 +2115,6 @@ def _run_evaluate_only_path_qa(
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
catalog=catalog_context,
)
off_topic_steps = detect_off_topic_steps(
@ -2213,7 +2208,6 @@ def _run_evaluate_only_path_qa(
reorder_notes=[],
roadmap_qa_mode=roadmap_qa_mode,
multistage_qa=multistage_qa,
steps=steps,
)
return {
"path_qa": path_qa,
@ -2506,7 +2500,6 @@ def _quick_evaluate_steps_qa(
llm_applied=False,
roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None,
multistage_qa=multistage_qa,
steps=steps_list,
)
if path_qa.get("quality_score") is None:
path_qa["quality_score"] = compute_deterministic_path_quality_score(
@ -3079,7 +3072,6 @@ def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]:
_SLOT_FIT_POOR_THRESHOLD = 0.30
_SLOT_FIT_GOOD_THRESHOLD = 0.50
def _off_topic_semantic_scores_by_slot(
@ -3160,18 +3152,9 @@ def _slot_auto_select_library(
return False
if proposed_slot_score is None:
return False
effective_baseline = float(baseline_slot_score) if baseline_slot_score is not None else 0.0
if float(proposed_slot_score) <= effective_baseline + 0.001:
return False
# Leerer Slot: Bibliothek nur vorauswählen, wenn Stufen-Fit klar ausreicht.
if baseline_exercise_id is None:
return float(proposed_slot_score) >= _SLOT_FIT_GOOD_THRESHOLD
return True
def _slot_auto_select_ai(*, library_auto_select: bool, has_ai: bool) -> bool:
"""KI-Vorschlag vorauswählen, wenn angeboten und Bibliothek nicht klar besser."""
return bool(has_ai and not library_auto_select)
if baseline_slot_score is None:
return True
return float(proposed_slot_score) > float(baseline_slot_score) + 0.001
def _build_unified_slot_review_entry(
@ -3408,14 +3391,10 @@ def _build_unified_slot_review_entry(
)
gap_fill_offers.append(slot_offer)
if slot_offer:
ai_auto = _slot_auto_select_ai(
library_auto_select=bool(library_alt and library_alt.get("auto_select")),
has_ai=True,
)
ai_alt = {
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
"gap_offer": slot_offer,
"auto_select": ai_auto,
"auto_select": False,
}
return {
@ -3833,7 +3812,6 @@ def suggest_progression_path(
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
roadmap_edited = False
roadmap_structured = _roadmap_structured_from_body(body)
catalog_context = _resolve_planning_catalog_context(cur, body)
if body.roadmap_override is not None:
try:
@ -3858,7 +3836,6 @@ def suggest_progression_path(
cur=cur,
include_llm_start_target=body.include_llm_start_target,
structured=roadmap_structured,
catalog=catalog_context,
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
elif include_roadmap:
@ -3870,7 +3847,6 @@ def suggest_progression_path(
include_llm_roadmap=body.include_llm_roadmap,
include_llm_start_target=body.include_llm_start_target,
structured=roadmap_structured,
catalog=catalog_context,
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
@ -3931,7 +3907,6 @@ def suggest_progression_path(
semantic_brief=semantic_brief,
steps=eval_steps,
roadmap_ctx=roadmap_ctx,
catalog_context=catalog_context,
)
return {
"goal_query": goal_query,
@ -3962,7 +3937,7 @@ def suggest_progression_path(
start_situation=body.start_situation,
target_state=body.target_state,
roadmap_notes=body.roadmap_notes,
catalog_context=catalog_context,
catalog_context=_resolve_planning_catalog_context(cur, body),
)
path_skill_expectations: Optional[Dict[str, Any]] = None
if roadmap_ctx and roadmap_ctx.goal_analysis:
@ -4161,7 +4136,6 @@ def suggest_progression_path(
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
catalog=catalog_context,
)
if (
@ -4250,7 +4224,6 @@ def suggest_progression_path(
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
catalog=catalog_context,
)
llm_gap_specs = parse_llm_suggested_new_exercises(
@ -4339,7 +4312,6 @@ def suggest_progression_path(
reorder_notes=reorder_notes,
roadmap_qa_mode=roadmap_qa_mode,
multistage_qa=multistage_qa,
steps=steps,
)
if rematch_log:
path_qa["rematch_applied"] = True

View File

@ -9,8 +9,6 @@ import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
from planning_catalog_context import ProgressionPlanningCatalogContext
from planning_prompt_variables import merge_planning_prompt_variables
from exercise_ai import strip_html_to_plain
from openrouter_chat import (
effective_openrouter_model_for_prompt_row,
@ -322,7 +320,6 @@ def try_llm_qa_progression_path(
steps: Sequence[Mapping[str, Any]],
gaps: Sequence[Mapping[str, Any]],
bridge_inserts: Sequence[Mapping[str, Any]],
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[Dict[str, Any]], bool]:
api_key, _ = normalize_openrouter_env()
if not api_key or len(steps) < 2:
@ -357,18 +354,13 @@ def try_llm_qa_progression_path(
}
)
variables = merge_planning_prompt_variables(
cur,
{
"goal_query": goal_query or "",
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
"steps_json": json.dumps(step_payload, ensure_ascii=False),
"gaps_json": json.dumps(list(gaps), ensure_ascii=False),
"bridge_inserts_json": json.dumps(list(bridge_inserts), ensure_ascii=False),
},
catalog=catalog,
slug="planning_exercise_path_qa",
)
variables = {
"goal_query": goal_query or "",
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
"steps_json": json.dumps(step_payload, ensure_ascii=False),
"gaps_json": json.dumps(list(gaps), ensure_ascii=False),
"bridge_inserts_json": json.dumps(list(bridge_inserts), ensure_ascii=False),
}
try:
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables)
@ -696,160 +688,6 @@ def find_step_pair_index(
return None
def count_step_assignment_stats(steps: Optional[Sequence[Mapping[str, Any]]]) -> Dict[str, int]:
stats = {"total": 0, "empty": 0, "library_filled": 0, "ai_proposal": 0}
for raw in steps or []:
if not isinstance(raw, dict):
continue
stats["total"] += 1
if raw.get("exercise_id") is not None and not raw.get("is_ai_proposal"):
stats["library_filled"] += 1
elif raw.get("is_ai_proposal"):
stats["ai_proposal"] += 1
else:
stats["empty"] += 1
return stats
def compute_assignment_quality_score(
*,
steps: Optional[Sequence[Mapping[str, Any]]] = None,
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
) -> float:
"""QS der Übungsbesetzung — leere Slots stark abwerten."""
stats = count_step_assignment_stats(steps)
total = stats["total"]
if total <= 0:
return 0.45
empty = stats["empty"]
library = stats["library_filled"]
ai = stats["ai_proposal"]
fill_credit = (library + 0.55 * ai) / total
score = 0.1 + 0.84 * fill_credit
if empty > 0:
score -= 0.22 * (empty / total)
score -= 0.08 * len(off_topic_steps or [])
score -= 0.03 * len(gaps or [])
return max(0.08, min(0.98, round(score, 4)))
def compute_roadmap_quality_score(
*,
llm_qa: Optional[Mapping[str, Any]] = None,
llm_applied: bool = False,
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
) -> float:
"""QS der Roadmap-/Stufenlogik — unabhängig von Slot-Befüllung."""
if llm_applied and llm_qa and llm_qa.get("quality_score") is not None:
try:
return max(0.08, min(0.98, round(float(llm_qa["quality_score"]), 4)))
except (TypeError, ValueError):
pass
score = 0.9
score -= 0.05 * len(gaps or [])
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
score -= min(0.12, 0.015 * hint_count)
return max(0.35, min(0.98, round(score, 4)))
def build_assignment_qa_snapshot(
*,
steps: Optional[Sequence[Mapping[str, Any]]] = None,
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
) -> Dict[str, Any]:
off_topic = list(off_topic_steps or [])
stats = count_step_assignment_stats(steps)
score = compute_assignment_quality_score(
steps=steps,
off_topic_steps=off_topic,
gaps=gaps,
)
issues: List[str] = []
if stats["empty"] > 0:
issues.append(
f"{stats['empty']} von {stats['total']} Slot(s) ohne Übung — bitte Bibliothek oder KI-Vorschlag zuweisen",
)
if stats["ai_proposal"] > 0 and stats["library_filled"] == 0 and stats["empty"] > 0:
issues.append(
f"{stats['ai_proposal']} KI-Entwurf(e), aber noch {stats['empty']} leere Slot(s)",
)
for item in off_topic[:5]:
title = (item.get("title") or "Schritt").strip()
issues.append(f"{title}“ passt nicht zum Stufen-Ziel")
overall_ok = stats["empty"] == 0 and len(off_topic) == 0
return {
"overall_ok": overall_ok,
"quality_score": score,
"slot_count": stats["total"],
"empty_slot_count": stats["empty"],
"library_filled_count": stats["library_filled"],
"ai_proposal_count": stats["ai_proposal"],
"issues": issues,
}
def build_roadmap_qa_snapshot(
*,
llm_qa: Optional[Mapping[str, Any]] = None,
llm_applied: bool = False,
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
roadmap_qa_mode: Optional[str] = None,
) -> Dict[str, Any]:
score = compute_roadmap_quality_score(
llm_qa=llm_qa,
llm_applied=llm_applied,
gaps=gaps,
multistage_qa=multistage_qa,
)
issues: List[str] = []
if not llm_applied:
for gap in gaps or []:
issues.append(
f"Übergang „{gap.get('from_title')}“ → „{gap.get('to_title')}“ schwach (Score {gap.get('gap_score')})",
)
if llm_applied and llm_qa:
issues.extend(str(x).strip() for x in (llm_qa.get("issues") or []) if str(x).strip())
overall_ok = bool(llm_qa.get("overall_ok", True)) if llm_applied and llm_qa else len(gaps or []) == 0
snapshot: Dict[str, Any] = {
"overall_ok": overall_ok,
"quality_score": score,
"issues": issues[:8],
"llm_applied": bool(llm_applied),
"roadmap_qa_mode": roadmap_qa_mode,
}
if llm_applied and llm_qa:
snapshot["topic_coverage"] = llm_qa.get("topic_coverage")
snapshot["recommendations"] = list(llm_qa.get("recommendations") or [])
snapshot["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
return snapshot
def merge_path_quality_scores(
roadmap_qa: Mapping[str, Any],
assignment_qa: Mapping[str, Any],
) -> float:
"""Gesamt-QS: schwächere Dimension begrenzt — leere Slots senken den Pfad deutlich."""
try:
roadmap_score = float(roadmap_qa.get("quality_score"))
except (TypeError, ValueError):
roadmap_score = None
try:
assignment_score = float(assignment_qa.get("quality_score"))
except (TypeError, ValueError):
assignment_score = None
if roadmap_score is not None and assignment_score is not None:
return round(min(roadmap_score, assignment_score), 4)
if assignment_score is not None:
return assignment_score
if roadmap_score is not None:
return roadmap_score
return 0.5
def build_path_qa_summary(
*,
gaps: Sequence[Mapping[str, Any]],
@ -864,7 +702,6 @@ def build_path_qa_summary(
reorder_notes: Optional[Sequence[str]] = None,
roadmap_qa_mode: Optional[str] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
steps: Optional[Sequence[Mapping[str, Any]]] = None,
) -> Dict[str, Any]:
offers = list(gap_fill_offers or [])
off_topic = list(off_topic_steps or [])
@ -889,32 +726,31 @@ def build_path_qa_summary(
summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or [])
summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or [])
summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0)
assignment_qa = build_assignment_qa_snapshot(
steps=steps,
off_topic_steps=off_topic,
gaps=gaps,
)
roadmap_qa = build_roadmap_qa_snapshot(
llm_qa=llm_qa,
llm_applied=llm_applied,
gaps=gaps,
multistage_qa=multistage_qa,
roadmap_qa_mode=roadmap_qa_mode,
)
summary["assignment_qa"] = assignment_qa
summary["roadmap_qa"] = roadmap_qa
summary["quality_score"] = merge_path_quality_scores(roadmap_qa, assignment_qa)
summary["overall_ok"] = bool(
assignment_qa.get("overall_ok")
and roadmap_qa.get("overall_ok", True),
)
summary["topic_coverage"] = roadmap_qa.get("topic_coverage")
summary["recommendations"] = list(roadmap_qa.get("recommendations") or [])
summary["sequence_notes"] = list(roadmap_qa.get("sequence_notes") or [])
summary["issues"] = list(assignment_qa.get("issues") or []) + list(roadmap_qa.get("issues") or [])[:6]
if llm_qa:
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
summary["quality_score"] = llm_qa.get("quality_score")
summary["issues"] = list(llm_qa.get("issues") or [])
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
summary["topic_coverage"] = llm_qa.get("topic_coverage")
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
else:
summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0
summary["issues"] = [
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
for g in gaps
] if gaps else []
if off_topic:
summary["issues"] = list(summary["issues"]) + [
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
for o in off_topic
]
summary["quality_score"] = compute_deterministic_path_quality_score(
gaps=gaps,
off_topic_steps=off_topic,
steps=steps,
multistage_qa=multistage_qa,
)
return summary
@ -925,34 +761,31 @@ def compute_deterministic_path_quality_score(
steps: Optional[Sequence[Mapping[str, Any]]] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
) -> float:
"""Heuristische Pfad-QS ohne LLM — Roadmap + Besetzung kombiniert."""
roadmap_qa = build_roadmap_qa_snapshot(
llm_qa=None,
llm_applied=False,
gaps=gaps,
multistage_qa=multistage_qa,
)
assignment_qa = build_assignment_qa_snapshot(
steps=steps,
off_topic_steps=off_topic_steps,
gaps=gaps,
)
return merge_path_quality_scores(roadmap_qa, assignment_qa)
"""Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche."""
score = 0.92
score -= 0.08 * len(off_topic_steps or [])
score -= 0.05 * len(gaps or [])
if steps:
empty = sum(
1
for s in steps
if isinstance(s, dict)
and s.get("exercise_id") is None
and not s.get("is_ai_proposal")
)
score -= 0.06 * empty
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
score -= min(0.14, 0.02 * hint_count)
return max(0.35, min(0.98, round(score, 4)))
__all__ = [
"apply_llm_path_reorder",
"build_assignment_qa_snapshot",
"build_path_qa_summary",
"build_roadmap_qa_snapshot",
"compute_assignment_quality_score",
"compute_deterministic_path_quality_score",
"compute_roadmap_quality_score",
"count_step_assignment_stats",
"detect_off_topic_steps",
"detect_path_gaps",
"is_roadmap_planned_neighbor_pair",
"merge_path_quality_scores",
"strip_off_topic_steps_from_path",
"find_step_pair_index",
"insert_bridge_exercises",

View File

@ -1,62 +0,0 @@
"""
Zähler für produktive OpenRouter-Aufrufe innerhalb einer Planungs-API-Anfrage.
Wird per ContextVar gesetzt (Router: ``planning_llm_call_meter``); ``openrouter_chat_completion``
erhöht den Zähler nach erfolgreicher Antwort nur wenn ein Meter aktiv ist.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Iterator, Optional
_llm_call_counter: ContextVar[Optional["PlanningLlmCallCounter"]] = ContextVar(
"planning_llm_call_counter",
default=None,
)
class PlanningLlmCallCounter:
"""Anzahl erfolgreicher OpenRouter-Chat-Completions in einem Request-Kontext."""
__slots__ = ("count",)
def __init__(self) -> None:
self.count = 0
def record(self, amount: int = 1) -> None:
try:
n = int(amount)
except (TypeError, ValueError):
n = 1
if n > 0:
self.count += n
def current_planning_llm_call_counter() -> Optional[PlanningLlmCallCounter]:
return _llm_call_counter.get()
def record_planning_llm_call(amount: int = 1) -> None:
counter = _llm_call_counter.get()
if counter is not None:
counter.record(amount)
@contextmanager
def planning_llm_call_meter() -> Iterator[PlanningLlmCallCounter]:
"""Aktiviert LLM-Zählung für den umschlossenen Block (inkl. verschachtelter Aufrufe)."""
counter = PlanningLlmCallCounter()
token = _llm_call_counter.set(counter)
try:
yield counter
finally:
_llm_call_counter.reset(token)
__all__ = [
"PlanningLlmCallCounter",
"current_planning_llm_call_counter",
"planning_llm_call_meter",
"record_planning_llm_call",
]

View File

@ -18,8 +18,6 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from pydantic import BaseModel, Field, ValidationError
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
from planning_catalog_context import ProgressionPlanningCatalogContext
from planning_prompt_variables import merge_planning_prompt_variables
from openrouter_chat import (
effective_openrouter_model_for_prompt_row,
normalize_openrouter_env,
@ -192,20 +190,12 @@ def _run_prompt_json(
cur,
slug: str,
variables: Dict[str, str],
*,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Optional[Dict[str, Any]]:
api_key, _ = normalize_openrouter_env()
if not api_key or cur is None:
return None
merged = merge_planning_prompt_variables(
cur,
variables,
catalog=catalog,
slug=slug,
)
try:
prow, rendered = load_and_render_ai_prompt(cur, slug, merged)
prow, rendered = load_and_render_ai_prompt(cur, slug, variables)
model = effective_openrouter_model_for_prompt_row(prow)
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
return _extract_json_object(raw)
@ -222,7 +212,6 @@ def try_llm_start_target_extract(
goal_query: str,
brief: PlanningSemanticBrief,
user_notes: str = "",
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[StartTargetExtractArtifact], bool]:
obj = _run_prompt_json(
cur,
@ -232,7 +221,6 @@ def try_llm_start_target_extract(
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
"user_notes": (user_notes or "").strip(),
},
catalog=catalog,
)
if not obj:
return None, False
@ -248,7 +236,6 @@ def try_llm_goal_analysis(
*,
goal_query: str,
brief: PlanningSemanticBrief,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[GoalAnalysisArtifact], bool]:
obj = _run_prompt_json(
cur,
@ -257,7 +244,6 @@ def try_llm_goal_analysis(
"goal_query": goal_query or "",
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
},
catalog=catalog,
)
if not obj:
return None, False
@ -275,7 +261,6 @@ def try_llm_roadmap(
brief: PlanningSemanticBrief,
goal_analysis: GoalAnalysisArtifact,
max_steps: int,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[RoadmapArtifact], bool]:
obj = _run_prompt_json(
cur,
@ -286,7 +271,6 @@ def try_llm_roadmap(
"goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False),
"max_steps": str(int(max_steps)),
},
catalog=catalog,
)
if not obj:
return None, False
@ -320,7 +304,6 @@ def try_llm_stage_specs(
major_steps: Sequence[MajorStep],
intent_context: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[PlanningSemanticBrief] = None,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[List[StageSpecArtifact]], bool]:
obj = _run_prompt_json(
cur,
@ -335,7 +318,6 @@ def try_llm_stage_specs(
ensure_ascii=False,
),
},
catalog=catalog,
)
if not obj:
return None, False
@ -398,7 +380,6 @@ def resolve_roadmap_structured_input(
brief: PlanningSemanticBrief,
cur=None,
include_llm: bool = False,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[RoadmapStructuredInput, StartTargetResolveMeta, Optional[StartTargetExtractArtifact]]:
"""Priorität je Feld: Trainer-Eingabe > LLM-Extraktion > Regex (von … bis …)."""
user = structured or RoadmapStructuredInput()
@ -414,7 +395,6 @@ def resolve_roadmap_structured_input(
goal_query=goal_query,
brief=brief,
user_notes=user_notes,
catalog=catalog,
)
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
@ -1088,7 +1068,6 @@ def run_start_target_resolve_only(
cur=None,
include_llm_start_target: bool = True,
structured: Optional[RoadmapStructuredInput] = None,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> ProgressionRoadmapContext:
"""Nur Start/Ziel/Ergänzungen auflösen — ohne Roadmap-Stufen (Review vor Major Steps)."""
brief = semantic_brief or build_semantic_brief(goal_query)
@ -1098,7 +1077,6 @@ def run_start_target_resolve_only(
brief=brief,
cur=cur,
include_llm=include_llm_start_target,
catalog=catalog,
)
topic_override = None
if llm_extract and (llm_extract.primary_topic or "").strip():
@ -1134,7 +1112,6 @@ def run_progression_roadmap_pipeline(
include_llm_roadmap: bool = False,
include_llm_start_target: bool = False,
structured: Optional[RoadmapStructuredInput] = None,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> ProgressionRoadmapContext:
"""Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback."""
brief = semantic_brief or build_semantic_brief(goal_query)
@ -1144,7 +1121,6 @@ def run_progression_roadmap_pipeline(
brief=brief,
cur=cur,
include_llm=include_llm_start_target,
catalog=catalog,
)
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
llm_goal_query = _roadmap_llm_goal_block(
@ -1176,9 +1152,7 @@ def run_progression_roadmap_pipeline(
topic_override=topic_override,
)
if include_llm_roadmap and cur is not None:
llm_ga, ga_ok = try_llm_goal_analysis(
cur, goal_query=llm_goal_query, brief=brief, catalog=catalog
)
llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=llm_goal_query, brief=brief)
if ga_ok and llm_ga:
goal_analysis = _merge_structured_into_goal_analysis(
llm_ga,
@ -1198,7 +1172,6 @@ def run_progression_roadmap_pipeline(
brief=brief,
goal_analysis=goal_analysis,
max_steps=max_steps,
catalog=catalog,
)
if rm_ok and llm_rm:
roadmap = llm_rm
@ -1261,7 +1234,6 @@ def run_progression_roadmap_pipeline(
major_steps=roadmap.major_steps,
intent_context=intent.to_api_dict(),
semantic_brief=brief,
catalog=catalog,
)
if spec_ok and llm_specs:
stage_specs = list(llm_specs)

View File

@ -1,118 +0,0 @@
"""
Zentrale Mustache-Variablen für Planungs-KI-Prompts.
Orchestratoren bauen domänenspezifische Basis-Variablen; dieses Modul merged
erweiterbare Provider (Katalog-Slots, später weitere Kontexte).
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Mapping, Optional
from catalog_prompt_slots import all_placeholder_keys, empty_catalog_variables
from planning_catalog_context import ProgressionPlanningCatalogContext
PlanningPromptVariableProvider = Callable[..., Dict[str, str]]
def _catalog_slot_variables(
*,
cur,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
slug: Optional[str] = None,
**_: Any,
) -> Dict[str, str]:
if cur is None or catalog is None:
return empty_catalog_variables()
from catalog_prompt_slots import resolve_catalog_prompt_variables
resolved = resolve_catalog_prompt_variables(cur, catalog, slug=slug)
return {k: str(resolved.get(k) or "") for k in all_placeholder_keys()}
_PLANNING_PROMPT_VARIABLE_PROVIDERS: tuple[PlanningPromptVariableProvider, ...] = (
_catalog_slot_variables,
)
def merge_planning_prompt_variables(
cur,
base_variables: Mapping[str, str],
*,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
slug: Optional[str] = None,
) -> Dict[str, str]:
"""Merged Basis-Variablen mit allen registrierten Planungs-Providern."""
out = {str(k): "" if v is None else str(v) for k, v in base_variables.items()}
ctx: Dict[str, Any] = {"cur": cur, "catalog": catalog, "slug": slug}
for provider in _PLANNING_PROMPT_VARIABLE_PROVIDERS:
out.update(provider(**ctx))
return out
def planning_prompt_placeholder_catalog() -> dict:
"""Platzhalter-Katalog für Admin — Slot-Typ × Dimension + Aggregat."""
from catalog_prompt_slots import CATALOG_KINDS, SLOT_KEYS, placeholder_key
slot_labels = {
"description": "Allgemeine Beschreibung",
"hints_on_progression": "Hinweise Progressionsgraph / Stufen",
"hints_on_exercise": "Hinweise Übungsanlage / Gap-Fill",
"hints_on_path_qa": "Bewertungsmaßstäbe Pfad-QS",
"anti_patterns": "Anti-Patterns (Fehlbewertung vermeiden)",
"rematch_guard": "Rematch-Guard (primär Code, optional Prompt)",
}
kind_labels = {c.kind: c.label_de for c in CATALOG_KINDS}
slugs_common = [
"planning_exercise_path_qa",
"planning_progression_roadmap",
"planning_progression_stage_spec",
"planning_progression_goal_analysis",
"planning_progression_start_target",
]
defs = []
for cfg in CATALOG_KINDS:
for slot in SLOT_KEYS:
key = placeholder_key(cfg.kind, slot)
defs.append(
{
"key": key,
"placeholder": "{{" + key + "}}",
"description": (
f"{kind_labels.get(cfg.kind, cfg.kind)}"
f"{slot_labels.get(slot, slot)} (aktiver Eintrag aus planning_catalog_context)."
),
"used_by_slugs": slugs_common,
}
)
defs.extend(
[
{
"key": "catalog_guidance_block",
"placeholder": "{{catalog_guidance_block}}",
"description": "Aggregierter Markdown-Block aus aktiven Slots (slug-spezifisches Profil).",
"used_by_slugs": slugs_common,
},
{
"key": "catalog_context_json",
"placeholder": "{{catalog_context_json}}",
"description": "Audit-JSON der gewählten Katalog-Einträge und befüllten Slots.",
"used_by_slugs": slugs_common[:3],
},
{
"key": "has_catalog_guidance",
"placeholder": "{{has_catalog_guidance}}",
"description": "„true“ wenn mindestens ein LLM-Slot gesetzt; sonst leer.",
"used_by_slugs": slugs_common[:3],
},
]
)
return {"context": "planning", "placeholders": defs}
__all__ = [
"merge_planning_prompt_variables",
"planning_prompt_placeholder_catalog",
]

View File

@ -14,15 +14,9 @@ from auth import require_auth
from club_tenancy import is_superadmin
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import resolve_exercise_form_variables
from ai_prompt_planning_preview import (
PlanningPromptPreviewInput,
is_planning_prompt_slug,
resolve_planning_prompt_preview_variables,
)
from ai_prompt_runtime import render_ai_prompt_template_for_row
from db import get_cursor, get_db, r2d
from prompt_resolver import exercise_placeholder_catalog
from planning_prompt_variables import planning_prompt_placeholder_catalog
router = APIRouter(tags=["admin_ai_prompts"])
@ -68,22 +62,12 @@ class AiPromptUpdateBody(BaseModel):
class AiPromptPreviewBody(ExerciseFormAiPromptContext):
"""Preview-POST: Übungs-KI und Planungs-Prompts."""
goal_query: Optional[str] = Field(default=None, max_length=2000)
user_notes: Optional[str] = Field(default=None, max_length=2000)
max_steps: Optional[int] = Field(default=None, ge=2, le=10)
search_query: Optional[str] = Field(default=None, max_length=2000)
"""Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint)."""
@router.get("/api/admin/ai-prompts/catalog/placeholders")
def get_ai_prompt_placeholders_catalog(session: dict = Depends(_require_superadmin)):
exercise = exercise_placeholder_catalog()
planning = planning_prompt_placeholder_catalog()
return {
"context": "all",
"placeholders": list(exercise.get("placeholders") or []) + list(planning.get("placeholders") or []),
}
return exercise_placeholder_catalog()
@router.get("/api/admin/ai-prompts")
@ -239,17 +223,6 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
vars_map = resolve_exercise_form_variables(cur, slug, body)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
elif is_planning_prompt_slug(slug):
planning_in = PlanningPromptPreviewInput(
goal_query=(body.goal_query or "Mae Geri vom Grundschritt bis zur Kumite-Nähe").strip(),
user_notes=(body.user_notes or "").strip(),
max_steps=body.max_steps if body.max_steps is not None else 5,
search_query=(body.search_query or body.goal_query or "").strip() or None,
)
try:
vars_map = resolve_planning_prompt_preview_variables(cur, slug, planning_in)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
elif slug == "pipeline":
vars_map = {}
warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau."

View File

@ -1,97 +0,0 @@
"""
API: Katalog-Prompt-Slots (Stammdaten × Slot-Typ).
Globaler Admin-Katalog (wie catalogs.py) require_auth + Admin-Rolle, kein TenantContext.
Eingetragen in backend/scripts/check_access_layer_hints.py EXEMPT_ROUTERS.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from auth import require_auth
from catalog_prompt_slots import (
CATALOG_KINDS,
get_catalog_entry_slots,
list_slot_type_definitions,
upsert_catalog_entry_slots,
)
from db import get_cursor, get_db
router = APIRouter(prefix="/api", tags=["catalog_prompt_slots"])
_VALID_KINDS = frozenset(c.kind for c in CATALOG_KINDS)
class CatalogPromptSlotsBody(BaseModel):
slots: Dict[str, Optional[str]] = Field(default_factory=dict)
def _require_admin(session: dict = Depends(require_auth)) -> dict:
role = (session.get("role") or "").strip().lower()
if role not in ("admin", "superadmin"):
raise HTTPException(status_code=403, detail="Nur Admins")
return session
def _slots_table_ready(cur) -> bool:
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.catalog_prompt_slots",))
row = cur.fetchone()
if not row:
return False
val = row.get("t") if isinstance(row, dict) else row[0]
return val is not None and str(val).strip() != ""
@router.get("/catalog-prompt-slot-types")
def api_list_catalog_prompt_slot_types(session: dict = Depends(_require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
if not _slots_table_ready(cur):
raise HTTPException(status_code=503, detail="Tabelle catalog_prompt_slots fehlt.")
return {"slot_types": list_slot_type_definitions(cur)}
@router.get("/catalog-prompt-slots/{catalog_kind}/{catalog_id}")
def api_get_catalog_prompt_slots(
catalog_kind: str,
catalog_id: int,
session: dict = Depends(_require_admin),
):
kind = (catalog_kind or "").strip().lower()
if kind not in _VALID_KINDS:
raise HTTPException(status_code=400, detail=f"Unbekannter catalog_kind: {catalog_kind!r}")
with get_db() as conn:
cur = get_cursor(conn)
if not _slots_table_ready(cur):
raise HTTPException(status_code=503, detail="Tabelle catalog_prompt_slots fehlt.")
try:
return get_catalog_entry_slots(cur, kind, catalog_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/catalog-prompt-slots/{catalog_kind}/{catalog_id}")
def api_put_catalog_prompt_slots(
catalog_kind: str,
catalog_id: int,
body: CatalogPromptSlotsBody,
session: dict = Depends(_require_admin),
):
kind = (catalog_kind or "").strip().lower()
if kind not in _VALID_KINDS:
raise HTTPException(status_code=400, detail=f"Unbekannter catalog_kind: {catalog_kind!r}")
with get_db() as conn:
cur = get_cursor(conn)
if not _slots_table_ready(cur):
raise HTTPException(status_code=503, detail="Tabelle catalog_prompt_slots fehlt.")
try:
return upsert_catalog_entry_slots(cur, kind, catalog_id, body.slots or {})
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -4,7 +4,7 @@ Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
"""
import json
from typing import Any, Dict, List, Mapping, Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
@ -19,7 +19,6 @@ from club_tenancy import (
assert_library_content_editable,
assert_library_content_governance_transition,
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
)
@ -177,87 +176,6 @@ def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int
raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung")
def _exercise_allowed_in_progression_graph(
exercise_row: Mapping[str, Any],
*,
graph_visibility: str,
graph_club_id: Optional[int],
profile_id: int,
role: str,
) -> bool:
"""Prüft, ob eine Übung zur Sichtbarkeit des Progressionsgraphen passt."""
ex_vis = (exercise_row.get("visibility") or "private").strip().lower()
gvis = (graph_visibility or "private").strip().lower()
if gvis == "private":
if ex_vis == "official":
return True
if ex_vis == "club":
return True
if ex_vis == "private":
if is_platform_admin(role):
return True
try:
return int(exercise_row.get("created_by") or 0) == int(profile_id)
except (TypeError, ValueError):
return False
return False
if gvis == "club":
if ex_vis == "official":
return True
if ex_vis != "club":
return False
ex_club = exercise_row.get("club_id")
if ex_club is None:
return False
if graph_club_id is None:
return True
return int(ex_club) == int(graph_club_id)
return ex_vis == "official"
def _assert_exercises_allowed_in_graph(
cur,
graph_id: int,
profile_id: int,
role: str,
*exercise_ids: int,
) -> None:
"""400 wenn eine Übung nicht zur Graph-Sichtbarkeit passt."""
row = _graph_row(cur, graph_id)
gvis = (row.get("visibility") or "private").strip().lower()
gclub_raw = row.get("club_id")
gclub = int(gclub_raw) if gclub_raw is not None else None
unique = list(dict.fromkeys(exercise_ids))
if not unique:
return
ph = ",".join(["%s"] * len(unique))
cur.execute(
f"SELECT id, title, visibility, club_id, created_by FROM exercises WHERE id IN ({ph})",
tuple(unique),
)
by_id = {int(r2d(r)["id"]): r2d(r) for r in cur.fetchall()}
for eid in unique:
ex = by_id.get(int(eid))
if not ex:
continue
if _exercise_allowed_in_progression_graph(
ex,
graph_visibility=gvis,
graph_club_id=gclub,
profile_id=profile_id,
role=role,
):
continue
title = (ex.get("title") or "").strip() or f"#{eid}"
raise HTTPException(
status_code=400,
detail=(
f"Übung „{title}“ (Sichtbarkeit: {ex.get('visibility') or 'private'}) "
f"passt nicht zum Progressionsgraphen ({gvis})."
),
)
def _insert_edge_row(
cur,
graph_id: int,
@ -394,22 +312,6 @@ def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]:
return ids
def _graph_promotion_transition(graph_visibility: str, target_visibility: str) -> Optional[tuple[str, ...]]:
"""
Erlaubte Graph-Promotions und welche Übungs-Sichtbarkeiten mit angehoben werden müssen.
Returns None wenn kein Übungs-Promotion-Hinweis nötig.
"""
gvis = (graph_visibility or "private").strip().lower()
tvis = (target_visibility or "").strip().lower()
transitions: Dict[tuple[str, str], tuple[str, ...]] = {
("private", "club"): ("private",),
("private", "official"): ("private", "club"),
("club", "official"): ("private", "club"),
}
return transitions.get((gvis, tvis))
@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
def list_visibility_promotion_candidates(
graph_id: int,
@ -417,9 +319,7 @@ def list_visibility_promotion_candidates(
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
Unterstützt: privateclub, privateofficial, clubofficial.
Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
"""
profile_id = tenant.profile_id
role = tenant.global_role
@ -427,13 +327,11 @@ def list_visibility_promotion_candidates(
cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role)
graph_vis = (row.get("visibility") or "private").strip().lower()
target_vis = (target_visibility or "club").strip().lower()
need_vis = _graph_promotion_transition(graph_vis, target_vis)
if not need_vis:
if graph_vis != "private" or target_visibility != "club":
return {
"graph_id": graph_id,
"graph_visibility": graph_vis,
"target_visibility": target_vis,
"target_visibility": target_visibility,
"exercises": [],
}
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
@ -441,20 +339,19 @@ def list_visibility_promotion_candidates(
return {
"graph_id": graph_id,
"graph_visibility": graph_vis,
"target_visibility": target_vis,
"target_visibility": target_visibility,
"exercises": [],
}
vis_placeholders = ",".join(["%s"] * len(need_vis))
ph = ",".join(["%s"] * len(ref_ids))
cur.execute(
f"""
SELECT id, title, visibility, club_id, created_by
FROM exercises
WHERE id IN ({ph})
AND LOWER(TRIM(COALESCE(visibility, ''))) IN ({vis_placeholders})
AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private'
ORDER BY title
""",
list(ref_ids) + list(need_vis),
list(ref_ids),
)
exercises = []
for ex in cur.fetchall():
@ -462,10 +359,8 @@ def list_visibility_promotion_candidates(
if not library_content_visible_to_profile(
cur,
profile_id,
(exd.get("visibility") or "private").strip().lower(),
exd.get("club_id"),
exd.get("created_by"),
role,
exd,
):
continue
exercises.append(
@ -478,7 +373,7 @@ def list_visibility_promotion_candidates(
return {
"graph_id": graph_id,
"graph_visibility": graph_vis,
"target_visibility": target_vis,
"target_visibility": target_visibility,
"exercises": exercises,
}
@ -670,9 +565,6 @@ def create_progression_edge(
cur = get_cursor(conn)
_require_graph_write(cur, graph_id, profile_id, role)
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
_assert_exercises_allowed_in_graph(
cur, graph_id, profile_id, role, body.from_exercise_id, body.to_exercise_id
)
fv = body.from_exercise_variant_id
tv = body.to_exercise_variant_id
_assert_variant_for_exercise(cur, body.from_exercise_id, fv)
@ -721,7 +613,6 @@ def create_progression_sequence(
ex_ids = [s.exercise_id for s in steps]
_assert_exercises_exist(cur, *ex_ids)
_assert_exercises_allowed_in_graph(cur, graph_id, profile_id, role, *ex_ids)
try:
for i in range(n_seg):

View File

@ -7,7 +7,6 @@ from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
from planning_llm_usage import planning_llm_call_meter
from account_lifecycle import assert_min_account_state
from capabilities import probe_capability
from club_features import (
@ -47,25 +46,19 @@ def post_planning_exercise_suggest(
)
with get_db() as conn:
cur = get_cursor(conn)
with planning_llm_call_meter() as llm_meter:
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
if uses_ai and llm_meter.count > 0:
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
if uses_ai:
usage = consume_club_feature_with_usage(
feature_id="ai_calls",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
action="planning_suggest",
amount=llm_meter.count,
cur=cur,
tenant=tenant,
conn=conn,
)
result = merge_feature_usage_into_response(result, usage)
if isinstance(result, dict):
result["llm_call_count"] = llm_meter.count
elif uses_ai and isinstance(result, dict):
result["llm_call_count"] = 0
return result
@ -77,6 +70,7 @@ def post_progression_path_suggest(
uses_ai = (
body.include_llm_intent
or body.include_llm_path_qa
or body.include_ai_gap_fill
or body.include_llm_roadmap
or body.include_llm_start_target
or (body.start_target_only and body.include_llm_start_target)
@ -104,23 +98,17 @@ def post_progression_path_suggest(
)
with get_db() as conn:
cur = get_cursor(conn)
with planning_llm_call_meter() as llm_meter:
result = suggest_progression_path(cur, tenant=tenant, body=body)
if uses_ai and llm_meter.count > 0:
result = suggest_progression_path(cur, tenant=tenant, body=body)
if uses_ai:
usage = consume_club_feature_with_usage(
feature_id="ai_calls",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
action="progression_path_suggest",
amount=llm_meter.count,
cur=cur,
tenant=tenant,
conn=conn,
)
result = merge_feature_usage_into_response(result, usage)
if isinstance(result, dict):
result["llm_call_count"] = llm_meter.count
elif uses_ai and isinstance(result, dict):
result["llm_call_count"] = 0
return result

View File

@ -29,7 +29,6 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
"admin_rights.py", # Superadmin Rollen/Rechte (Capabilities, Kontingent-Bypass, Pläne); require_auth + is_superadmin — kein Vereinsmandant
"catalogs.py",
"catalog_prompt_slots.py", # Admin Stammdaten KI-Prompt-Slots; require_auth + admin/superadmin — globaler Katalog, kein Vereinsmandant
"skills.py",
"maturity_models.py",
"matrix_stack_bundle.py",

View File

@ -1,89 +0,0 @@
"""Admin-Vorschau für Planungs-Prompt-Slugs."""
from unittest.mock import MagicMock, patch
import pytest
from ai_prompt_planning_preview import (
PLANNING_PROMPT_SLUGS,
PlanningPromptPreviewInput,
is_planning_prompt_slug,
resolve_planning_prompt_preview_variables,
)
def test_is_planning_prompt_slug():
assert is_planning_prompt_slug("planning_progression_roadmap")
assert is_planning_prompt_slug("PLANNING_EXERCISE_PATH_QA")
assert not is_planning_prompt_slug("exercise_summary")
assert not is_planning_prompt_slug("")
def test_resolve_roadmap_preview_variables():
body = PlanningPromptPreviewInput(goal_query="Mae Geri Basics", max_steps=4)
vars_map = resolve_planning_prompt_preview_variables(
MagicMock(),
"planning_progression_roadmap",
body,
)
assert vars_map["goal_query"] == "Mae Geri Basics"
assert vars_map["max_steps"] == "4"
assert "goal_analysis_json" in vars_map
assert "semantic_brief_json" in vars_map
def test_resolve_stage_spec_includes_intent_context():
body = PlanningPromptPreviewInput(user_notes="Breitensport")
vars_map = resolve_planning_prompt_preview_variables(
MagicMock(),
"planning_progression_stage_spec",
body,
)
assert "intent_context_json" in vars_map
assert "major_steps_json" in vars_map
@patch("ai_prompt_planning_preview._load_catalog_variables")
def test_resolve_search_intent_includes_catalogs(mock_catalog):
mock_catalog.return_value = {
"skills_catalog_json": "[]",
"focus_areas_catalog_json": "[]",
"training_types_catalog_json": "[]",
"style_directions_catalog_json": "[]",
"target_groups_catalog_json": "[]",
}
body = PlanningPromptPreviewInput(search_query="Mae Geri nächster Schritt")
vars_map = resolve_planning_prompt_preview_variables(
MagicMock(),
"planning_exercise_search_intent",
body,
)
assert vars_map["search_query"] == "Mae Geri nächster Schritt"
assert vars_map["skills_catalog_json"] == "[]"
def test_non_planning_slug_raises():
with pytest.raises(ValueError, match="Kein Planungs-Prompt-Slug"):
resolve_planning_prompt_preview_variables(
MagicMock(),
"exercise_summary",
PlanningPromptPreviewInput(),
)
def test_all_registered_slugs_resolve():
for slug in PLANNING_PROMPT_SLUGS:
with patch("ai_prompt_planning_preview._load_catalog_variables") as mock_catalog:
mock_catalog.return_value = {
"skills_catalog_json": "[]",
"focus_areas_catalog_json": "[]",
"training_types_catalog_json": "[]",
"style_directions_catalog_json": "[]",
"target_groups_catalog_json": "[]",
}
vars_map = resolve_planning_prompt_preview_variables(
MagicMock(),
slug,
PlanningPromptPreviewInput(),
)
assert isinstance(vars_map, dict)
assert len(vars_map) >= 1

View File

@ -1,38 +0,0 @@
"""Tests Namens-Fallback für Katalog-Prompt-Slots."""
from catalog_slot_fallbacks import get_fallback_slots_for_entry, merge_stored_slots_with_fallbacks
from catalog_prompt_slots import _resolve_entry_slot_values
def test_karate_fallback_has_path_qa():
pack = get_fallback_slots_for_entry("focus_area", "Karate")
assert "Kohärente Progression" in pack.get("hints_on_path_qa", "")
def test_db_value_overrides_fallback():
merged = merge_stored_slots_with_fallbacks(
{"hints_on_path_qa": "Eigener QS-Text."},
catalog_kind="focus_area",
name="Karate",
stammdaten_description="Traditionelles Karate",
)
assert merged["hints_on_path_qa"] == "Eigener QS-Text."
def test_empty_db_uses_karate_fallback():
merged = _resolve_entry_slot_values(
{},
{"name": "Karate", "description": "Traditionelles Karate"},
"focus_area",
)
assert "Kihon-Progression" in merged["description"] or "Technik-Curriculum" in merged["description"]
assert "Kohärente Progression" in merged["hints_on_path_qa"]
def test_gewaltschutz_fallback_no_kumite():
merged = _resolve_entry_slot_values(
{},
{"name": "Gewaltschutz", "description": "Gewaltprävention"},
"focus_area",
)
assert "Deeskalation" in merged["hints_on_path_qa"]
assert "Kumite-Tiefe" in merged["anti_patterns"]

View File

@ -1,80 +0,0 @@
"""Sichtbarkeit: Progressionsgraph ↔ Übungen (Promotion, Kanten, Match)."""
from routers.exercise_progression_graphs import (
_exercise_allowed_in_progression_graph,
_graph_promotion_transition,
)
def test_graph_promotion_transition_private_to_club():
assert _graph_promotion_transition("private", "club") == ("private",)
def test_graph_promotion_transition_private_to_official():
assert _graph_promotion_transition("private", "official") == ("private", "club")
def test_graph_promotion_transition_club_to_official():
assert _graph_promotion_transition("club", "official") == ("private", "club")
def test_graph_promotion_transition_noop():
assert _graph_promotion_transition("club", "club") is None
assert _graph_promotion_transition("official", "club") is None
assert _graph_promotion_transition("private", "private") is None
def test_club_graph_rejects_private_exercise():
assert not _exercise_allowed_in_progression_graph(
{"visibility": "private", "club_id": None, "created_by": 1},
graph_visibility="club",
graph_club_id=5,
profile_id=1,
role="trainer",
)
def test_club_graph_accepts_matching_club_exercise():
assert _exercise_allowed_in_progression_graph(
{"visibility": "club", "club_id": 5, "created_by": 2},
graph_visibility="club",
graph_club_id=5,
profile_id=1,
role="trainer",
)
def test_club_graph_accepts_official_exercise():
assert _exercise_allowed_in_progression_graph(
{"visibility": "official", "club_id": None, "created_by": 99},
graph_visibility="club",
graph_club_id=5,
profile_id=1,
role="trainer",
)
def test_private_graph_accepts_own_private_exercise():
assert _exercise_allowed_in_progression_graph(
{"visibility": "private", "club_id": None, "created_by": 7},
graph_visibility="private",
graph_club_id=None,
profile_id=7,
role="trainer",
)
def test_official_graph_requires_official_exercise():
assert not _exercise_allowed_in_progression_graph(
{"visibility": "club", "club_id": 5, "created_by": 2},
graph_visibility="official",
graph_club_id=None,
profile_id=1,
role="trainer",
)
assert _exercise_allowed_in_progression_graph(
{"visibility": "official", "club_id": None, "created_by": 2},
graph_visibility="official",
graph_club_id=None,
profile_id=1,
role="trainer",
)

View File

@ -1,168 +0,0 @@
"""Tests Katalog-Prompt-Slots (H2)."""
from unittest.mock import MagicMock
from catalog_prompt_slots import (
build_catalog_guidance_for_prompt,
pick_active_catalog_item,
placeholder_key,
resolve_catalog_prompt_variables,
)
from planning_catalog_context import PlanningCatalogContextItem, ProgressionPlanningCatalogContext
from planning_prompt_variables import merge_planning_prompt_variables
def _mock_cur(
rows_by_table=None,
slots_by_kind_id=None,
slot_types_ready=True,
):
rows_by_table = rows_by_table or {}
slots_by_kind_id = slots_by_kind_id or {}
cur = MagicMock()
def execute(sql, params=None):
sql_l = (sql or "").lower()
if "to_regclass" in sql_l:
cur.fetchone.return_value = {"t": "catalog_prompt_slot_types" if slot_types_ready else None}
return
if "from catalog_prompt_slot_types" in sql_l:
cur.fetchall.return_value = []
return
if "from catalog_prompt_slots" in sql_l:
kind, cid = params[0], int(params[1])
slot_map = slots_by_kind_id.get((kind, cid), {})
cur.fetchall.return_value = [
{"slot_key": k, "content": v} for k, v in slot_map.items()
]
return
for table, rows in rows_by_table.items():
if f"from {table}" in sql_l:
item_id = int(params[0])
raw = rows.get(item_id)
if raw is None:
cur.fetchone.return_value = None
elif isinstance(raw, dict):
cur.fetchone.return_value = {
"id": item_id,
"name": raw.get("name", ""),
"description": raw.get("description", ""),
}
else:
cur.fetchone.return_value = {
"id": item_id,
"name": str(raw),
"description": "",
}
return
cur.fetchone.return_value = None
cur.fetchall.return_value = []
cur.execute.side_effect = execute
return cur
def test_pick_active_catalog_item_primary_wins():
items = [
PlanningCatalogContextItem(id=1, is_primary=False, weight=0.9),
PlanningCatalogContextItem(id=2, is_primary=True, weight=0.5),
]
assert pick_active_catalog_item(items).id == 2
def test_granular_placeholder_focus_area_hints_on_path_qa():
cur = _mock_cur(
rows_by_table={"focus_areas": {4: {"name": "Gewaltschutz"}}},
slots_by_kind_id={
("focus_area", 4): {
"description": "Planung zielt auf Prävention und Deeskalation.",
"hints_on_path_qa": "Lücken sind fehlende Deeskalations-Stufen.",
"anti_patterns": "Nicht nach Kumite-Tiefe bewerten.",
}
},
)
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)],
)
resolved = resolve_catalog_prompt_variables(cur, catalog, slug="planning_exercise_path_qa")
assert "Deeskalation" in resolved[placeholder_key("focus_area", "hints_on_path_qa")]
assert "Deeskalation" in resolved["catalog_guidance_block"]
assert resolved["has_catalog_guidance"] == "true"
def test_unknown_focus_uses_default_description_pack():
cur = _mock_cur(
rows_by_table={
"focus_areas": {
4: {
"name": "Sonderfokus Alpha",
"description": "Kurze Stammdaten-Beschreibung",
}
}
},
slots_by_kind_id={("focus_area", 4): {}},
)
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)],
)
resolved = resolve_catalog_prompt_variables(cur, catalog)
desc = resolved[placeholder_key("focus_area", "description")]
assert "Technik- oder Themen-Curriculum" in desc
assert resolved[placeholder_key("focus_area", "hints_on_path_qa")]
def test_empty_without_catalog():
cur = MagicMock()
out = build_catalog_guidance_for_prompt(cur, None)
assert out["has_catalog_guidance"] is False
assert out["catalog_guidance_block"] == ""
def test_unknown_entry_gets_default_technique_fallback():
cur = _mock_cur(rows_by_table={"focus_areas": {99: {"name": "Unbekannter Fokus XYZ"}}})
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=99, is_primary=True)],
)
out = build_catalog_guidance_for_prompt(cur, catalog)
assert out["has_catalog_guidance"] is True
assert "Unbekannter Fokus XYZ" in out["catalog_context_json"]
assert "Zwischenstufen" in out["catalog_guidance_block"] or "Progression" in out["catalog_guidance_block"]
def test_merge_planning_prompt_variables_granular_keys():
cur = _mock_cur(
rows_by_table={"focus_areas": {4: {"name": "Gewaltschutz"}}},
slots_by_kind_id={
("focus_area", 4): {"hints_on_path_qa": "Deeskalation und Grenzen."}
},
)
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)],
)
merged = merge_planning_prompt_variables(
cur,
{"goal_query": "Deeskalation Kinder"},
catalog=catalog,
slug="planning_exercise_path_qa",
)
assert merged[placeholder_key("focus_area", "hints_on_path_qa")].startswith("Deeskalation")
assert merged["has_catalog_guidance"] == "true"
def test_priority_order_in_guidance_block():
cur = _mock_cur(
rows_by_table={
"focus_areas": {1: {"name": "Gewaltschutz"}},
"training_types": {2: {"name": "Breitensport"}},
},
slots_by_kind_id={
("focus_area", 1): {"description": "Fokus-Text"},
("training_type", 2): {"description": "Stil-Text"},
},
)
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=1, is_primary=True)],
training_types=[PlanningCatalogContextItem(id=2, is_primary=True)],
)
block = build_catalog_guidance_for_prompt(cur, catalog)["catalog_guidance_block"]
assert block.index("Primärfokus") < block.index("Trainingsstil")

View File

@ -3,28 +3,19 @@ from planning_exercise_path_qa import compute_deterministic_path_quality_score
def test_deterministic_quality_score_penalizes_off_topic():
steps = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=steps)
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[])
with_off = compute_deterministic_path_quality_score(
gaps=[],
off_topic_steps=[{"roadmap_major_step_index": 1}],
steps=steps,
)
assert with_off < base
def test_deterministic_quality_score_penalizes_empty_slots():
filled = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=filled)
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=[])
with_empty = compute_deterministic_path_quality_score(
gaps=[],
off_topic_steps=[],
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}, {"roadmap_major_step_index": 1, "exercise_id": 2}],
)
all_empty = compute_deterministic_path_quality_score(
gaps=[],
off_topic_steps=[],
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}] * 4,
steps=[{"exercise_id": None}, {"exercise_id": 1}],
)
assert with_empty < base
assert all_empty <= 0.15

View File

@ -1,94 +0,0 @@
"""LLM-Zählung für Planungs-APIs (P1-C2)."""
from unittest.mock import MagicMock, patch
import pytest
from planning_llm_usage import (
current_planning_llm_call_counter,
planning_llm_call_meter,
record_planning_llm_call,
)
def test_meter_inactive_by_default():
assert current_planning_llm_call_counter() is None
record_planning_llm_call(3)
assert current_planning_llm_call_counter() is None
def test_meter_counts_within_scope():
with planning_llm_call_meter() as meter:
record_planning_llm_call(1)
record_planning_llm_call(2)
assert meter.count == 3
def test_openrouter_increments_active_meter():
from openrouter_chat import openrouter_chat_completion
fake_resp = MagicMock()
fake_resp.status_code = 200
fake_resp.json.return_value = {
"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
}
with planning_llm_call_meter() as meter:
with patch("openrouter_chat.httpx.Client") as client_cls:
client = MagicMock()
client.__enter__.return_value = client
client.post.return_value = fake_resp
client_cls.return_value = client
out = openrouter_chat_completion(
api_key="test-key",
model="test/model",
user_content="hello",
)
assert out == "ok"
assert meter.count == 1
def test_openrouter_skips_meter_on_http_error():
from openrouter_chat import OpenRouterError, openrouter_chat_completion
fake_resp = MagicMock()
fake_resp.status_code = 500
fake_resp.json.return_value = {"error": {"message": "fail"}}
fake_resp.text = "fail"
with planning_llm_call_meter() as meter:
with patch("openrouter_chat.httpx.Client") as client_cls:
client = MagicMock()
client.__enter__.return_value = client
client.post.return_value = fake_resp
client_cls.return_value = client
with pytest.raises(OpenRouterError):
openrouter_chat_completion(
api_key="test-key",
model="test/model",
user_content="hello",
)
assert meter.count == 0
def test_uses_ai_gap_fill_not_counted_without_openrouter():
"""Regression: Gap-Fill-Flag allein löst keinen OpenRouter-Aufruf aus."""
from planning_exercise_path_builder import ProgressionPathSuggestRequest
body = ProgressionPathSuggestRequest(
query="Mae Geri Progression",
include_llm_intent=False,
include_llm_path_qa=False,
include_llm_roadmap=False,
include_llm_start_target=False,
include_ai_gap_fill=True,
evaluate_only=True,
evaluate_steps=[],
)
uses_ai = (
body.include_llm_intent
or body.include_llm_path_qa
or body.include_llm_roadmap
or body.include_llm_start_target
or (body.start_target_only and body.include_llm_start_target)
)
assert uses_ai is False

View File

@ -1,62 +0,0 @@
"""Getrennte Roadmap- vs. Besetzungs-QS."""
from planning_exercise_path_qa import (
build_assignment_qa_snapshot,
build_path_qa_summary,
compute_assignment_quality_score,
merge_path_quality_scores,
)
def _empty_steps(n: int):
return [{"roadmap_major_step_index": i, "exercise_id": None} for i in range(n)]
def test_assignment_quality_all_empty_slots_is_low():
steps = _empty_steps(5)
score = compute_assignment_quality_score(steps=steps, off_topic_steps=[], gaps=[])
assert score <= 0.15
def test_assignment_quality_all_filled_is_high():
steps = [{"roadmap_major_step_index": i, "exercise_id": i + 1} for i in range(5)]
score = compute_assignment_quality_score(steps=steps, off_topic_steps=[], gaps=[])
assert score >= 0.9
def test_build_path_qa_summary_caps_llm_score_when_slots_empty():
steps = _empty_steps(4)
summary = build_path_qa_summary(
gaps=[],
bridge_inserts=[],
ai_proposals=[],
off_topic_steps=[],
stripped_off_topic=[],
llm_qa={
"overall_ok": True,
"quality_score": 0.88,
"topic_coverage": "Roadmap deckt Ziel gut ab",
"issues": [],
"recommendations": ["Feinschliff Stufe 3"],
},
llm_applied=True,
steps=steps,
)
assert summary["roadmap_qa"]["quality_score"] == 0.88
assert summary["assignment_qa"]["empty_slot_count"] == 4
assert summary["assignment_qa"]["quality_score"] <= 0.15
assert summary["quality_score"] <= 0.15
assert summary["overall_ok"] is False
def test_merge_path_quality_uses_minimum():
assert merge_path_quality_scores(
{"quality_score": 0.88},
{"quality_score": 0.12},
) == 0.12
def test_assignment_snapshot_reports_empty_slots():
snap = build_assignment_qa_snapshot(steps=_empty_steps(3), off_topic_steps=[], gaps=[])
assert snap["empty_slot_count"] == 3
assert snap["overall_ok"] is False
assert any("ohne Übung" in issue for issue in snap["issues"])

View File

@ -2,7 +2,6 @@
from planning_exercise_path_builder import (
_parse_slot_refs_from_text,
_problematic_slots_from_path_qa,
_slot_auto_select_ai,
_slot_auto_select_library,
_slot_suggestion_accepted,
)
@ -114,27 +113,6 @@ def test_slot_auto_select_requires_higher_score():
)
def test_slot_auto_select_empty_slot_requires_good_fit():
assert not _slot_auto_select_library(
baseline_slot_score=None,
proposed_slot_score=0.35,
baseline_exercise_id=None,
proposed_exercise_id=2,
)
assert _slot_auto_select_library(
baseline_slot_score=None,
proposed_slot_score=0.55,
baseline_exercise_id=None,
proposed_exercise_id=2,
)
def test_slot_auto_select_ai_when_library_not_selected():
assert _slot_auto_select_ai(library_auto_select=False, has_ai=True)
assert not _slot_auto_select_ai(library_auto_select=True, has_ai=True)
assert not _slot_auto_select_ai(library_auto_select=False, has_ai=False)
def test_off_topic_slot_gap_spec_for_filled_slot():
from planning_exercise_path_builder import _build_off_topic_slot_gap_spec

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.237"
APP_VERSION = "0.8.233"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607094"
DB_SCHEMA_VERSION = "20260607090"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
"exercise_enrichment_admin": "1.1.1", # Preview max 3/Request + parallel LLM (Gateway-504 vermeiden)
"admin_ai_prompts": "1.0.5", # H2: granulare Katalog-Slot-Platzhalter im Katalog
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
"ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile
@ -53,40 +53,6 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.237",
"date": "2026-05-22",
"changes": [
"Migration 094: catalog_prompt_slots vollständig befüllt (Karate, SV, alle Trainingsstile/Zielgruppen).",
"catalog_slot_fallbacks: Namens-Fallback bis Admin-Override — gleiche Qualität wie H1-Registry.",
],
},
{
"version": "0.8.236",
"date": "2026-05-22",
"changes": [
"Stammdaten-Katalog: CatalogPromptSlotsEditor für Fokus, Trainingsstil, Zielgruppe, Stilrichtung.",
"Migration 093: ai_prompts mit granularen Slot-Platzhaltern (focus_area_hints_on_path_qa etc.).",
],
},
{
"version": "0.8.235",
"date": "2026-05-22",
"changes": [
"Planungs-KI H2: catalog_prompt_slot_types + catalog_prompt_slots — Slot-Werte pro Katalog-Eintrag.",
"Granulare Platzhalter focus_area_hints_on_progression etc.; Resolver catalog_prompt_slots.py.",
"Admin-API GET/PUT /api/catalog-prompt-slots/{kind}/{id}; H1-Registry entfernt.",
],
},
{
"version": "0.8.234",
"date": "2026-05-22",
"changes": [
"Planungs-KI H1: Katalog-Snippets (planning_catalog_prompt_snippets) + zentrale Platzhalter (planning_prompt_variables).",
"Pfad-QS, Roadmap, Stufenspec: {{catalog_guidance_block}} aus Trainer-Katalog; Migration 091.",
"Admin: Planungs-Platzhalter-Katalog; Preview mit optional planning_catalog_context.",
],
},
{
"version": "0.8.226",
"date": "2026-05-22",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-22 (F15 Graph-Match & getrennte Pfad-QS, lokal nach **0.8.233**)
**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11F14, Katalog-Kontext); **F15** siehe §2.8 — DB unverändert (`DB_SCHEMA_VERSION`, Migration **088**).
**Stand:** 2026-05-22
**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11F14, Katalog-Kontext); DB siehe **`backend/version.py`** (`DB_SCHEMA_VERSION`, Migration **088**).
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -114,25 +114,11 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ **0.8.2310.8.232** |
| **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** |
| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** |
| **F15** | Unified Slot-Review (Match-Dialog), getrennte Pfad-QS, `findings_stale` | ✅ lokal (nach 0.8.233) |
| **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
**Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16.
**Validierung (Mae Geri, Härtetest):** Roadmap-QS nach Trainer-Roadmap oft **~8588 %** — gilt **Stufenlogik**, nicht leere Slots. **Gesamt-Pfad-QS** = Minimum aus **`roadmap_qa`** + **`assignment_qa`** (leere Slots → Besetzung ~815 %). Workbench universell; Mae Geri Referenzfall.
#### F15 — Match-Dialog, Bewertung, Pfad-QS (Stand 2026-05-22)
| Thema | Ist |
|--------|-----|
| **„Übungen matchen“** | Schritt 1: `evaluate_only` (wie „Graph bewerten“) · Schritt 2: `unified_slot_review: true` → Dialog **pro Slot** (Bewertung, Bibliotheks-Alternative, optional KI) |
| **Vorauswahl Dialog** | Bibliothek nur bei Stufen-Fit **≥ 50 %** und besser als aktuell; bei leerem Slot + schwacher Bibliothek → **KI-Vorschlag** vorausgewählt |
| **Übernahme** | Nur gewählte Slots speichern — **keine** automatische teure Nach-Bewertung |
| **Bewertung veraltet** | Nach Graph-Änderungen Hinweis im Findings-Panel; persistiert als **`findings_stale`** im `planning_roadmap`-Artefakt (mit Speichern) |
| **Getrennte QS** | `path_qa.roadmap_qa` (Stufen/Roadmap/LLM) + `path_qa.assignment_qa` (Slot-Befüllung); **`quality_score`** = Minimum beider |
| **UX-Fix** | Slot-Karten: stabiler React-Key (`slot-{index}`) — Lernziel editierbar ohne Fokusverlust |
**Code:** `ProgressionOptimizeCompareModal.jsx`, `planning_exercise_path_builder.py` (`_build_unified_slot_review_entry`, `_slot_auto_select_*`), `planning_exercise_path_qa.py` (`build_*_qa_snapshot`), `progression_graph_planning_artifact.py` (`findings_stale`), `progressionGraphDraft.js`
**Validierung (Mae Geri, Härtetest):** Pfad-QS vor Optimierung ~65 % → nach Trainer-Roadmap + KI-Gap-Fill **~88 % OK**. Workbench ist **universell** gedacht; Mae Geri war Referenzfall, kein Sonder-Patch.
**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_catalog_context.py`, `planning_path_rematch.py`, `planning_path_refine_stage.py`, `planning_path_qa_pipeline.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py`
@ -143,12 +129,12 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
**Offen (priorisiert):**
1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri)
2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor
3. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
4. Graph-Erweiterungsmodus (Start ab Knoten)
5. Phase D — Auto KI-Gap-Fill bei persistent leeren Slots
6. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
7. Technik-Katalog konfigurierbar (Backlog)
8. **H1** — Katalog-Prompt-Snippets (modulare LLM-Anweisungen)
3. QS-UI — positive LLM-Hinweise als Highlights
4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
5. Graph-Erweiterungsmodus (Start ab Knoten)
6. Phase D — Auto KI-Gap-Fill bei persistent leeren Slots
7. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
8. Technik-Katalog konfigurierbar (Backlog)
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
@ -285,7 +271,8 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung).
2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest.
3. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale.
4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert.
5. **Phase D:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`.
6. **Trainingsplanung G0G4:** Katalog in Einheits-Editor, Scopes `training_section`/`framework_slot`, Abschnitts-QS, Gruppenkontext-Pack — Details **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16, **`PLANNING_KI_ROADMAP.md`**.

View File

@ -1,216 +1,229 @@
# Planungs-KI — Katalog-Prompt-Slots (Snippets)
# Planungs-KI — Katalog-Snippets für modulare Prompts
**Stand:** 2026-05-22
**Status:** **H2** umgesetzt (0.8.235) · **H2.1** Admin-UI + granulare Prompts (0.8.236)
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py` · `catalog_prompt_slots.py`
**Status:** Spezifikation (Phase **H1** — Umsetzung offen)
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py`
---
## 1. Problem
Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring**.
Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring** (`PlanningTargetProfile`).
Die **LLM-Prompts** brauchen zusätzlich **fachliche Textbausteine** pro gewähltem Katalog-Eintrag — editierbar, ohne Code-Deploy, unabhängig von fest codierten Katalog-Namen.
Die **LLM-Prompts** (Roadmap, Stufen-Spec, Pfad-QS, Gap-Fill) erhalten diese Dimensionen **nicht** als differenzierte Bewertungs- und Planungslogik — höchstens indirekt über Freitext oder JSON-Kataloglisten in Intent-Prompts.
**Ziel:** Prompts in `ai_prompts` referenzieren **Slot-Typen** (Vokabular). Inhalte hängen an **Katalog-Zeilen** (Stammdaten). Der Resolver füllt Platzhalter zur Laufzeit.
**Folge:** Ein Breitensport-Pfad kann mit Leistungsgruppen-Kriterien bewertet werden; Gewaltschutz wird wie Technik-Curriculum behandelt; QS-Hinweise und Rematch-Vorschläge passen fachlich nicht zum gewählten Kontext.
**Ziel:** Gleiche **Prompt-Basis**, aber **kaskadierte Snippet-Blöcke** pro Katalog-Ausprägung — keine Matrix aus Voll-Prompt-Kopien.
---
## 2. Zwei Ebenen (Kern des Modells)
## 2. Priorität der Dimensionen (absteigend)
| Ebene | Was | Wer pflegt | Beispiel |
|-------|-----|------------|----------|
| **Slot-Typ** | Art des Textes (festes, erweiterbares Vokabular) | Produkt / Architektur | `hints_on_progression`, `hints_on_path_qa` |
| **Slot-Wert** | Konkreter Text pro Katalog-Eintrag | Admin / Redakteur | Gewaltschutz → `hints_on_path_qa`: „Lücken = fehlende Deeskalation …“ |
Verbindliche Reihenfolge bei Konflikten und beim Rollout:
**Nicht:** feste Attribute pro Eintrag im Code (`planning_lens`, `qa_criteria` …).
**Sondern:** beliebig viele Katalog-Einträge × definierte Slot-Typen.
| Rang | Dimension | DB-Tabelle | Snippet-Rolle |
|------|-----------|------------|----------------|
| **1** | **Primärfokus** | `focus_areas` | Definiert *worüber* geplant und bewertet wird (Technik-Curriculum vs. Gewaltschutz vs. Fitness …). **Dominant.** |
| **2** | **Trainingsstil** | `training_types` | Definiert *wie* trainiert wird (Methodik, Belastungsaufbau, Wettkampf vs. Breitenansatz im Training). |
| **3** | **Zielgruppe** | `target_groups` | Definiert *für wen* (Kinder, Breitensport, Leistungsgruppe) — Tempo, Komplexität, Sicherheit. |
| **4** | **Stilrichtung** | `style_directions` | Vereins-/Stil-Linie (Shotokan, WKF …) — **Nuancen**, kein neuer Planungstyp. |
**Kaskaden-Regel:** Bei widersprüchlichen Snippet-Aussagen gilt: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung.
---
## 3. Dimensionen & Priorität
## 3. Architektur — drei Schichten (Erinnerung)
| Rang | Dimension | `catalog_kind` | DB-Tabelle |
|------|-----------|----------------|------------|
| **1** | Primärfokus | `focus_area` | `focus_areas` |
| **2** | Trainingsstil | `training_type` | `training_types` |
| **3** | Zielgruppe | `target_group` | `target_groups` |
| **4** | Stilrichtung | `style_direction` | `style_directions` |
| Schicht | Heute | Mit H1 |
|---------|-------|--------|
| **Retrieval** | Katalog-Gewichte in `merge_catalog_context_into_target` | unverändert |
| **Technik-Gates** | `planning_exercise_semantics.py`, nur `topic_type=technique` | unverändert |
| **LLM-Prompts** | kaum Katalog-spezifisch | **`catalog_guidance_block`** pro Aufruf |
**Kaskade:** Bei widersprüchlichen Hinweisen gilt Fokus → Trainingsstil → Zielgruppe → Stilrichtung.
Pro Dimension im Request: **ein aktiver Eintrag** (`is_primary`, sonst höchstes `weight`).
Snippets **ersetzen** keine Technik-Disambiguierung und **duplizieren** keine Retrieval-Gewichte — sie steuern **Didaktik, Bewertungsmaßstäbe und Formulierung** für das Modell.
---
## 4. Slot-Typ-Register (Vokabular)
## 4. Snippet-Modell
Definiert in **`catalog_prompt_slot_types`** (DB) + Spiegel in `catalog_prompt_slots.py`.
### 4.1 Lookup-Schlüssel
| `slot_key` | Anzeige (DE) | LLM-Prompt | Code-only |
|------------|--------------|------------|-----------|
| `description` | Allgemeine Beschreibung | Roadmap, Goal-Analyse, Start/Ziel | — |
| `hints_on_progression` | Hinweise Progressionsgraph / Stufen | Roadmap, Stage-Spec | — |
| `hints_on_path_qa` | Bewertungsmaßstäbe Pfad-QS | Path-QA | — |
| `hints_on_exercise` | Hinweise Übungsanlage / Gap-Fill | Exercise-AI, Suggest (später) | — |
| `anti_patterns` | Explizit vermeiden (Fehlbewertung) | Path-QA, Stage-Spec | — |
| `rematch_guard` | Kein Auto-Rematch erzwingen | — | Rematch-Loop (H1.5) |
Pro Katalog-Eintrag ein stabiler **`snippet_key`** (nicht nur numerische ID — IDs können sich in Dev/Import unterscheiden):
**Erweiterung:** neuer Slot-Typ = eine Zeile in `catalog_prompt_slot_types` + Resolver/Admin-Katalog — **kein** neues Python-Feld pro Eintrag.
```
focus:{slug} z. B. focus:gewaltschutz
training_type:{slug} z. B. training_type:kumite
target_group:{slug} z. B. target_group:breitensport
style:{slug} z. B. style:shotokan
```
**Fallback `description`:** Wenn kein Slot-Wert gesetzt → `focus_areas.description` (bzw. `description`-Spalte der jeweiligen Katalog-Tabelle).
**Primär** aus `slug` der DB-Zeile; Fallback normalisierter `name` (ASCII, lower, `_`).
---
Mehrfachauswahl im UI: pro Dimension **höchstens ein Snippet** — die Zeile mit `is_primary: true`, sonst erste Zeile, sonst höchste `weight`.
## 5. Platzhalter in `ai_prompts`
### 4.2 Snippet-Inhalt (Struktur)
Mustache-Keys: **`{catalog_kind}_{slot_key}`** (nur `[a-z0-9_]`).
Jedes Snippet liefert strukturierte Textbausteine (Deutsch, für LLM):
| Feld | Pflicht | Inhalt |
|------|---------|--------|
| `planning_lens` | ja | 24 Sätze: Was ist das Planungsziel in dieser Dimension? |
| `qa_criteria` | ja | Bullet-artige Kriterien für Pfad-QS (was ist „gut“, was ist kein Mangel) |
| `roadmap_hints` | empfohlen | Stufenlogik, typische Phasen, was vermeiden |
| `anti_patterns` | optional | Explizite Fehlbewertungen vermeiden (z. B. „keine Wettkampf-Tiefe verlangen“) |
| `rematch_guard` | optional | Wann **kein** Auto-Rematch sinnvoll (Breitensport: keine Perfektions-Slots erzwingen) |
Phase **H1:** flache Markdown-Strings im Code-Modul.
Phase **H2 (optional):** Tabelle `planning_catalog_prompt_snippets` oder JSONB an Katalog-Zeilen, Admin-editierbar.
### 4.3 Platzhalter in `ai_prompts`
Neue **gemeinsame** Platzhalter (Mustache), in alle betroffenen Prompts einfügen:
| Platzhalter | Bedeutung |
|-------------|-----------|
| `{{focus_area_description}}` | Aktiver Primärfokus — Beschreibung |
| `{{focus_area_hints_on_progression}}` | … — Progressions-Hinweise |
| `{{focus_area_hints_on_path_qa}}` | … — QS-Hinweise |
| `{{focus_area_hints_on_exercise}}` | … — Übungsanlage |
| `{{focus_area_anti_patterns}}` | … — Anti-Patterns |
| `{{training_type_description}}` | Aktiver Trainingsstil — … |
| `{{training_type_hints_on_progression}}` | … |
| `{{target_group_hints_on_path_qa}}` | Aktive Zielgruppe — … |
| `{{style_direction_hints_on_progression}}` | Aktive Stilrichtung — … |
| *(analog für alle Slot-Typen × Dimension)* | |
| `{{catalog_guidance_block}}` | **Aggregat** (Abwärtskompatibel): kaskadierter Markdown-Block aus aktiven Slots |
| `{{catalog_context_json}}` | Audit: IDs, Namen, gesetzte Slot-Keys |
| `{{has_catalog_guidance}}` | `"true"` oder leer |
| `{{catalog_guidance_block}}` | Gerenderter Gesamttext (alle aktiven Snippets, kaskadiert) |
| `{{catalog_context_json}}` | Kompakte JSON-Zusammenfassung der gewählten IDs/Namen (Audit) |
| `{{#has_catalog_guidance}}``{{/has_catalog_guidance}}` | Block nur wenn mindestens ein Snippet aktiv |
**Hinweis:** `prompt_resolver` unterstützt **keine** `{{#if}}`-Blöcke — leere Slots = leerer String.
**Optional fein (später):** `{{catalog_focus_snippet}}`, `{{catalog_training_type_snippet}}`, … — Phase H1 nur `_block` + JSON.
### 5.1 Prompt-Profile (welche Slots im Aggregat)
### 4.4 Betroffene Prompt-Slugs (Reihenfolge Einbindung)
| Prompt-Slug | Aggregat enthält primär |
|-------------|-------------------------|
| `planning_exercise_path_qa` | `*_hints_on_path_qa`, `*_anti_patterns`, `*_description` |
| `planning_progression_roadmap` | `*_description`, `*_hints_on_progression`, `*_anti_patterns` |
| `planning_progression_stage_spec` | `*_hints_on_progression`, `*_anti_patterns` |
| `planning_progression_goal_analysis` | `*_description` |
| Priorität | Slug | Migration | Wirkung |
|-----------|------|-----------|---------|
| 1 | `planning_exercise_path_qa` | bestehend | Pfad-QS, `quality_score`, Empfehlungen |
| 2 | `planning_progression_roadmap` | 078 | Major Steps, Didaktik |
| 3 | `planning_progression_stage_spec` | 079 | Stufen-Gates, Erfolgskriterien |
| 4 | `planning_progression_start_target` | 087 | Start/Ziel-Extraktion |
| 5 | `planning_progression_goal_analysis` | 078 | Zielanalyse |
| 6 | Gap-Fill / Übungs-KI | 085+ | `planning_context` ergänzen |
Feinsteuerung: im Admin-Template **granulare** Platzhalter nutzen; Aggregat optional.
Intent-Prompts (`planning_exercise_search_intent`, …) **optional** Phase H1.5 — dort bereits Katalog-JSON.
---
## 6. Speicherung (DB)
## 5. Builder (Backend)
### 6.1 `catalog_prompt_slot_types`
**Neues Modul:** `backend/planning_catalog_prompt_snippets.py`
Metadaten je Slot-Typ (`slot_key`, `display_name`, `description`, `applicable_kinds[]`, `sort_order`, `for_llm`, `for_code`).
### 6.2 `catalog_prompt_slots`
```text
catalog_kind — focus_area | training_type | target_group | style_direction
catalog_id — FK auf jeweilige Stammtabelle (logisch, kein polymorpher FK)
slot_key — FK → catalog_prompt_slot_types
content — TEXT (Markdown/Plain für LLM)
UNIQUE (catalog_kind, catalog_id, slot_key)
```python
def build_catalog_guidance_for_prompt(
cur,
catalog: Optional[ProgressionPlanningCatalogContext],
) -> Dict[str, str]:
"""
Returns:
catalog_guidance_block: str
catalog_context_json: str
has_catalog_guidance: bool
snippet_keys: list[str] # Metadaten für Logs/Tests
"""
```
Neuer Katalog-Eintrag im Admin → **keine** Code-Änderung; Slots optional befüllen.
**Ablauf:**
1. `catalog` aus Request oder `planning_roadmap.planning_catalog_context` (wie F13).
2. Pro Dimension aktives Item auflösen → `snippet_key` → Text aus Registry.
3. Snippets in **Prioritätsreihenfolge** §2 zu `_block` zusammenfügen (Überschriften: „Primärfokus“, „Trainingsstil“, …).
4. Fehlende Snippets: Dimension **weglassen** (kein Default-Text) — besser kein Snippet als falscher.
**Einbindung:** Orchestratoren rufen Builder auf und mergen in `variables` vor `load_and_render_ai_prompt`:
- `planning_exercise_path_qa.py``try_llm_qa_progression_path`
- `planning_progression_roadmap.py` (Roadmap-/Stage-Pipeline)
- `planning_exercise_path_builder.py` (catalog an QA/Match durchreichen)
`ProgressionPathSuggestRequest` trägt `planning_catalog_context` bereits — kein neues API-Feld nötig.
---
## 7. Laufzeit-Architektur
## 6. Beispiel-Snippets (Review-Entwurf)
```text
planning_catalog_context (Request / Graph-Artefakt)
catalog_prompt_slots.resolve_catalog_prompt_variables(cur, catalog, slug?)
planning_prompt_variables.merge_planning_prompt_variables(...)
load_and_render_ai_prompt (ai_prompts Template)
```
### 6.1 Primärfokus — Gewaltschutz (`focus:gewaltschutz`)
**Module:**
**planning_lens:** Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.
| Modul | Rolle |
|-------|--------|
| `catalog_prompt_slots.py` | Slot-Typen, DB-Lese/Schreib, Resolver, Aggregat-Block |
| `planning_prompt_variables.py` | Zentrale Provider-Liste (erweiterbar) |
| `planning_catalog_prompt_snippets.py` | Deprecated Re-Exports (Tests/Kompatibilität) |
**qa_criteria:** Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.
**anti_patterns:** Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.
### 6.2 Primärfokus — Technik / Kumite-Beinarbeit (`focus:kumite` o. ä.)
**planning_lens:** Curriculum für eine Technik oder Kumite-Teilaspekt; aufeinander aufbauende Belastung und Anwendungsnähe sind erwünscht.
**qa_criteria:** Kohärente Progression Grundlagen → Anwendung → Vertiefung; Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten.
### 6.3 Trainingsstil — Breitensport (`training_type:breitensport` o. Name-Match)
**planning_lens:** Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung.
**qa_criteria:** Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; „Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke.
**rematch_guard:** Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.
### 6.4 Zielgruppe — Leistungsgruppe (`target_group:leistungsgruppe`)
**qa_criteria:** Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; Lücken in Spezialisierung können echte Hinweise sein.
*(Weitere Snippets iterativ ergänzen — nicht alle Katalog-Zeilen sofort.)*
---
## 8. Admin-API
## 7. Rollout-Phasen
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/api/catalog-prompt-slot-types` | Slot-Typ-Register |
| GET | `/api/catalog-prompt-slots/{kind}/{catalog_id}` | Alle Slots eines Eintrags |
| PUT | `/api/catalog-prompt-slots/{kind}/{catalog_id}` | Slots upserten (Admin) |
### H1 — Minimal viable (Progressionsgraph)
`kind`: `focus_area` · `training_type` · `target_group` · `style_direction`
**Später:** UI-Tabs am Katalog-Editor; Versionierung/Audit wie `ai_prompts`.
---
## 9. Rollout-Phasen
### H1 — Bootstrap (0.8.234) ✓
Hardcodierte `SNIPPET_REGISTRY` — Proof of Concept für `catalog_guidance_block`.
### H2 — Slot-Modell (0.8.235) ✓
- [x] Tabellen `catalog_prompt_slot_types`, `catalog_prompt_slots`
- [x] Seed aus H1-Texten (Name-Match auf Stammdaten)
- [x] Resolver mit granularen Platzhaltern + Aggregat
- [x] Admin-API GET/PUT
- [x] `SNIPPET_REGISTRY` aus Laufzeit-Pfad entfernt
### H2.1 — Admin-UI
- [x] Slot-Editor an Fokusbereich / Trainingsstil / Zielgruppe / Stilrichtung (`CatalogPromptSlotsEditor`, Stammdaten-Katalog)
- [x] Prompt-Templates mit granularen Platzhaltern (Migration 093)
- [ ] Platzhalter-Hilfe im KI-Prompt-Editor (erweitert)
- [ ] Modul `planning_catalog_prompt_snippets.py` + Registry (58 Snippets: 23 Foki, 2 Trainingsstile, 2 Zielgruppen)
- [ ] Einbindung in **`planning_exercise_path_qa`** + **`planning_progression_roadmap`** + **`planning_progression_stage_spec`**
- [ ] Migration Prompt-Templates: Abschnitt `{{#has_catalog_guidance}}…{{/has_catalog_guidance}}`
- [ ] Tests: gleicher Pfad + unterschiedlicher Katalog → unterschiedlicher `catalog_guidance_block`; Snapshot QA-Variablen
- [ ] Dev-Regression: Gewaltschutz, Breitensport, Kinder — **Hinweistexte** müssen zum Kontext passen (nicht Mae-Geri-Kriterien)
### H1.5
- [ ] `rematch_guard` im Rematch-Loop
- [ ] Intent-Prompts + Gap-Fill: `hints_on_exercise`
- [ ] Rematch/Refine: `rematch_guard` aus Snippets respektieren (weniger Ping-Pong bei Breitensport)
- [ ] Intent-Prompts + Gap-Fill-Kontext
### H3 — Trainingsplanung (Phase G)
### H2 — Betrieb
- [ ] Gleicher Resolver, andere Orchestratoren
- [ ] Snippets in DB, Admin-UI oder Markdown-Import
- [ ] Versionierung / Audit wie `ai_prompts`
### H3 — Phase G (Trainingsplanung)
- [ ] Gleicher Builder, anderer Orchestrator (Abschnitts-QS, Slot-Suggest)
---
## 10. Tests & Akzeptanz
## 8. Tests & Akzeptanz
| Test | Erwartung |
|------|-----------|
| Slot aus DB | Gewaltschutz + `hints_on_path_qa` → Platzhalter enthält Deeskalation |
| Ohne Katalog | Alle `{{focus_area_*}}` leer; `has_catalog_guidance` leer |
| Neuer Eintrag | Leere Slots, kein Crash; `description`-Fallback aus Stammdaten |
| Priorität | Aggregat: Primärfokus vor Trainingsstil |
| `test_catalog_prompt_snippets_priority` | Bei Konflikt gewinnt Fokus-Snippet-Text in `_block`-Reihenfolge |
| `test_path_qa_variables_include_guidance` | Mit Gewaltschutz-Kontext enthält gerendeter Prompt „Deeskalation“ o. ä., nicht „Kumite-Perfektion“ |
| `test_path_qa_no_snippet_without_catalog` | Ohne Katalog: `has_catalog_guidance=false`, Prompt unverändert wie heute |
| Manuell | Mae-Geri-Pfad + Breitensport-Kontext: QS-Highlights ohne Leistungs-Belastungs-Forderungen |
**Nicht Ziel von H1:** Retrieval-Gewichte neu kalibrieren; Technik-Tuples externalisieren (separates Backlog **H** in Roadmap).
---
## 11. Abgrenzung
## 9. Abgrenzung zu anderen Fixes
| Thema | Hinweis |
|-------|---------|
| Retrieval-Gewichte | `merge_catalog_context_into_target` — unverändert |
| Technik-Gates | `planning_exercise_semantics` — unverändert |
| Prompt-Text | `ai_prompts` — editierbar, referenziert Slot-Platzhalter |
| Thema | Dokument / Fix |
|-------|----------------|
| 88% vs. 65% falsche Pipeline | Evaluate-only für Pfad-QS; fairer Compare — Code-Stand 2026-05-22 |
| Triviale ID-Tausche im Dialog | `_filter_trivial_slot_diffs` |
| Katalog nur im Retrieval | F13 — bleibt, Snippets ergänzen LLM-Schicht |
Snippets lösen **fachliche Fehlbewertung** — nicht Pipeline-Inkonsistenz allein.
---
## 12. Changelog
## 10. Changelog
| Datum | Änderung |
|-------|----------|
| 2026-05-22 | **H2.1** (0.8.236): Admin-UI `CatalogPromptSlotsEditor`; Migration 093 granulare Prompt-Templates |
| 2026-05-22 | **H2** (0.8.235): Slot-Typ-Register + `catalog_prompt_slots` DB, granulare Platzhalter, Admin-API |
| 2026-05-22 | Konzept §4§8: zwei Ebenen Slot-Typ vs. Slot-Wert; Platzhalter `{kind}_{slot_key}` |
| 2026-05-22 | H1 (0.8.234): Bootstrap-Registry — durch H2 ersetzt |
| 2026-05-22 | Erstfassung |
| 2026-05-22 | Erstfassung — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung; H1H3 Rollout |

View File

@ -89,24 +89,14 @@ Details und Module: **`PLANNING_PROGRESSION_GRAPH_KI.md`**.
- [x] Vier Planungskontext-Dropdowns im Editor
- [x] `progressionGraphDraft.js` — Artefakt + API-Payload
### F15 — Match-Dialog & getrennte Pfad-QS (2026-05-22, lokal)
- [x] **`unified_slot_review`** — Dialog pro Slot (Bibliothek + KI, Stufen-Fit-Vergleich)
- [x] Vorauswahl: Bibliothek nur bei Stufen-Fit ≥ 50 %; sonst KI bei leerem/schwachem Slot
- [x] Übernahme ohne teure Auto-Nach-Bewertung; manuell „Graph bewerten“
- [x] **`path_qa.roadmap_qa`** + **`path_qa.assignment_qa`**; Gesamt = Minimum
- [x] **`findings_stale`** im Graph-Artefakt — Hinweis „Bewertung veraltet“ (persistiert)
- [x] Slot-Key-Fix — Lernziel editierbar ohne Fokusverlust
### Validierung (Referenz Mae Geri, 2026-05)
| Phase | Roadmap-QS | Besetzung | Gesamt | Ergebnis |
|-------|------------|-----------|--------|----------|
| Vor Roadmap/KI | — | — | ~65 % | Lücken, Off-Topic |
| Roadmap ok, Slots leer | ~88 % | ~815 % | **~815 %** | Besetzung fehlt |
| Nach Match + Fill | ~88 % | hoch | **~85 %+** | Vollständige Abdeckung |
| Phase | Pfad-QS | Ergebnis |
|-------|---------|----------|
| Vor Roadmap/KI | ~65 % | Lücken, falsche Reihenfolge, Off-Topic |
| Nach Trainer-Roadmap + KI-Gap-Fill | **~88 % OK** | Vollständige Abdeckung; positive LLM-Hinweise |
**Fazit:** Roadmap-QS und Besetzungs-QS getrennt betrachten; Workbench + Katalog + Roadmap universell.
**Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht.
---

View File

@ -157,10 +157,6 @@ flowchart TB
| `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) |
| `max_rematch_rounds` | int | Rematch-Runden 04 (Default **3**) |
| `include_path_qa`, `include_llm_path_qa`, `include_ai_gap_fill` | bool | QS, LLM-Ganzpfad, Lücken-Angebote |
| `evaluate_only` | bool | Nur QS auf `evaluate_steps[]` — kein Match |
| `unified_slot_review` | bool | Pro-Slot-Review (Bibliothek + optional KI) für Match-Dialog; erfordert `baseline_evaluate_steps` + Roadmap |
| `baseline_evaluate_steps` | array? | Slot-Stand für Schritt 1 / Review-Baseline |
| `baseline_path_qa_snapshot` | object? | `path_qa` aus evaluate_only (Schritt 1 des Match-Flows) |
### 4.2 Wichtige Response-Felder
@ -170,9 +166,7 @@ flowchart TB
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log`; **F15:** auch `roadmap_qa`, `assignment_qa` (siehe §8.1) |
| `slot_reviews[]` | Bei `unified_slot_review`: je Slot `library_alternative`, `ai_alternative`, `auto_select`-Flags |
| `findings_stale` | Im Graph-Artefakt (nicht API-Response): Bewertung veraltet seit letztem „Graph bewerten“ |
| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log` |
| `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) |
| `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` |
@ -227,13 +221,12 @@ Tests: `test_planning_roadmap_stage_match.py`, `test_planning_path_rematch.py`,
### Referenz-Validierung (Mae Geri, 2026-05)
| Phase | Roadmap-QS | Besetzung | Gesamt (min) | Ergebnis |
|-------|------------|-----------|--------------|----------|
| Vor Roadmap/KI-Anpassung | — | — | ~65 % | Strukturelle Lücken |
| Nach Trainer-Roadmap, **Slots leer** | ~8588 % | ~815 % | **~815 %** | Roadmap ok, Besetzung fehlt |
| Nach Match + befüllte Slots | ~8588 % | hoch | **~85 %+** | Vollständige Curriculum-Abdeckung |
| Phase | Pfad-QS | Ergebnis |
|-------|---------|----------|
| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) |
| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen |
**Lesson:** **`roadmap_qa`** und **`assignment_qa`** getrennt interpretieren; Gesamt-QS allein bei leerer Roadmap irreführend (historisch nur LLM-Roadmap-Score).
**Lesson:** Workbench + Katalog-Kontext + Roadmap sind der Hebel; Technik-Hardcoding allein reicht nicht für Didaktik.
---
@ -337,21 +330,6 @@ API: `path_qa.qa_tiers`, `path_qa.optimization_hints` — **kein** anfrage-spezi
Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match.
### 8.1 Getrennte Pfad-QS — Roadmap vs. Übungsbesetzung (F15)
`build_path_qa_summary()` in `planning_exercise_path_qa.py` liefert drei Ebenen:
| Feld | Inhalt | Score-Logik |
|------|--------|-------------|
| **`roadmap_qa`** | Stufenlogik, LLM `topic_coverage`, Roadmap-Hinweise | LLM-`quality_score` oder heuristisch (Lücken, Hints) |
| **`assignment_qa`** | Leere Slots, Off-Topic auf belegten Slots, Fill-Statistik | Stark abwertend bei leeren Slots (~815 % bei 100 % leer) |
| **`quality_score`** (gesamt) | Anzeige „Pfad-QS gesamt“ | **`min(roadmap_qa, assignment_qa)`** |
| **`overall_ok`** | Gesamt-OK | Beide Dimensionen müssen OK sein |
UI: **`ProgressionFindingsPanel`** — zwei Unterblöcke; Match-Dialog zeigt Roadmap- vs. Besetzungs-Prozent. Nach Graph-Änderung: **`findings_stale: true`** im Artefakt → Hinweis „Bewertung veraltet“ (bis erneut „Graph bewerten“ + Speichern).
Tests: `test_planning_path_qa_split.py`, `test_planning_deterministic_quality_score.py`
## 9. Fähigkeiten-Scoring-Anbindung
Modul: `planning_skill_expectations.py`
@ -401,7 +379,6 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.2310.8.232 |
| **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** |
| **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 0.8.233 |
| **F15** | Unified Slot-Review, getrennte Pfad-QS, `findings_stale`, Match-Vorauswahl | ✅ | lokal (2026-05-22) |
| **H1** | **Katalog-Prompt-Snippets** — modulare LLM-Anweisungen pro Dimension | 🔲 | Spec **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
| **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — |
| **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — |
@ -414,7 +391,8 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren
3. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“
4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert
5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
6. **Phase D** — automatisches KI-Gap-Fill bei persistent leeren Slots

View File

@ -13,7 +13,7 @@ import { Link, useLocation } from 'react-router-dom'
import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import ProgressionGraphEditor from './ProgressionGraphEditor'
import ProgressionGraphListCard from './ProgressionGraphListCard'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
@ -24,21 +24,6 @@ const VIS_OPTIONS = [
{ value: 'official', label: 'Offiziell' },
]
const GRAPH_VISIBILITY_PROMOTION_LABEL = {
club: 'Vereins-Sichtbarkeit',
official: 'offizielle Sichtbarkeit',
}
/** Graph-Promotion mit optionalem Übungs-Anheben (private→club/official, club→official). */
function shouldPromptGraphExercisePromotion(prevVis, nextVis) {
const p = (prevVis || 'private').trim().toLowerCase()
const n = (nextVis || 'private').trim().toLowerCase()
return (
(p === 'private' && (n === 'club' || n === 'official')) ||
(p === 'club' && n === 'official')
)
}
function edgeTypeLabel(type) {
if (type === 'next_exercise') return 'Nachfolger'
if (type === 'sibling') return 'Schwester'
@ -56,9 +41,7 @@ function ExerciseProgressionGraphPanel(
const { user } = useAuth()
const location = useLocation()
const isSuperadmin = user?.role === 'superadmin'
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
const filteredGraphVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
@ -78,37 +61,6 @@ function ExerciseProgressionGraphPanel(
const [metaName, setMetaName] = useState('')
const [metaDescription, setMetaDescription] = useState('')
const [metaVisibility, setMetaVisibility] = useState('private')
const [metaClubSelect, setMetaClubSelect] = useState('')
const memberClubIdSet = useMemo(
() => new Set(memberClubs.map((c) => Number(c.id))),
[memberClubs],
)
const sortedMemberClubs = useMemo(
() =>
[...memberClubs].sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
),
[memberClubs],
)
const sortedOtherGovernanceClubs = useMemo(() => {
if (!isSuperadmin || clubsForGovernanceForms.length === 0) return []
return clubsForGovernanceForms
.filter((c) => !memberClubIdSet.has(Number(c.id)))
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'de'))
}, [isSuperadmin, clubsForGovernanceForms, memberClubIdSet])
const showGovernanceClubOptgroups =
isSuperadmin && sortedMemberClubs.length > 0 && sortedOtherGovernanceClubs.length > 0
const governanceClubSelectOptions = useMemo(() => {
if (isSuperadmin && clubsForGovernanceForms.length > 0) {
return [...sortedMemberClubs, ...sortedOtherGovernanceClubs]
}
return sortedMemberClubs
}, [isSuperadmin, clubsForGovernanceForms.length, sortedMemberClubs, sortedOtherGovernanceClubs])
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
@ -173,25 +125,6 @@ function ExerciseProgressionGraphPanel(
}
}, [refreshGraphs, tenantClubDepKey])
useEffect(() => {
if (!isSuperadmin) {
setClubsForGovernanceForms([])
return undefined
}
let cancelled = false
;(async () => {
try {
const list = await api.listClubs()
if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
} catch {
if (!cancelled) setClubsForGovernanceForms([])
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, tenantClubDepKey])
useEffect(() => {
if (!selectedGraphId) {
setSkillProfileData(null)
@ -224,7 +157,6 @@ function ExerciseProgressionGraphPanel(
setMetaName('')
setMetaDescription('')
setMetaVisibility('private')
setMetaClubSelect('')
return
}
const g = graphs.find((x) => x.id === selectedGraphId)
@ -232,12 +164,6 @@ function ExerciseProgressionGraphPanel(
setMetaName(g.name || '')
setMetaDescription(g.description || '')
setMetaVisibility(g.visibility || 'private')
if (g.club_id != null) {
setMetaClubSelect(String(g.club_id))
} else {
const fallback = getDefaultClubIdForGovernanceForms(user)
setMetaClubSelect(fallback != null ? String(fallback) : '')
}
}
let cancelled = false
;(async () => {
@ -250,17 +176,7 @@ function ExerciseProgressionGraphPanel(
return () => {
cancelled = true
}
}, [selectedGraphId, graphs, refreshEdges, user])
const resolveGovernanceClubId = useCallback(() => {
const g = graphs.find((x) => x.id === selectedGraphId)
if (g?.club_id != null) return Number(g.club_id)
const sel = String(metaClubSelect || '').trim()
if (sel && /^\d+$/.test(sel)) return Number(sel)
return getDefaultClubIdForGovernanceForms(user)
}, [graphs, selectedGraphId, metaClubSelect, user])
}, [selectedGraphId, graphs, refreshEdges])
const filteredEdges = useMemo(() => {
if (!filterAnchorOnly || anchorExerciseId == null) return edges
@ -310,7 +226,13 @@ function ExerciseProgressionGraphPanel(
}
}
const resolvePromoteClubId = resolveGovernanceClubId
const resolvePromoteClubId = () => {
const g = graphs.find((x) => x.id === selectedGraphId)
if (g?.club_id != null) return Number(g.club_id)
const memberships = activeClubMemberships(user?.clubs)
const active = memberships.find((c) => c.is_active) || memberships[0]
return active?.club_id != null ? Number(active.club_id) : null
}
const handleSaveMeta = async () => {
if (!selectedGraphId) return
@ -325,58 +247,48 @@ function ExerciseProgressionGraphPanel(
setBusy(true)
try {
if (shouldPromptGraphExercisePromotion(prevVis, nextVis)) {
if (prevVis === 'private' && nextVis === 'club') {
const preview = await api.getProgressionGraphVisibilityPromotionCandidates(
selectedGraphId,
{ targetVisibility: nextVis },
{ targetVisibility: 'club' },
)
const promotionExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
if (promotionExercises.length > 0) {
const visLabel = GRAPH_VISIBILITY_PROMOTION_LABEL[nextVis] || nextVis
const titles = promotionExercises
const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
if (privateExercises.length > 0) {
const titles = privateExercises
.slice(0, 8)
.map((ex) => `${ex.title || `Übung #${ex.id}`}`)
.join('\n')
const more =
promotionExercises.length > 8
? `\n… und ${promotionExercises.length - 8} weitere`
privateExercises.length > 8
? `\n… und ${privateExercises.length - 8} weitere`
: ''
const promote = window.confirm(
`Der Graph wird auf „${visLabel}“ gestellt. Im Graph sind noch ${promotionExercises.length} Übung(en) mit niedrigerer Sichtbarkeit:\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf ${visLabel} anheben?`,
`Der Graph wird auf „Verein“ gestellt. Im Graph sind noch ${privateExercises.length} private Übung(en):\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf Vereins-Sichtbarkeit anheben?`,
)
if (promote) {
let clubId = null
if (nextVis === 'club') {
clubId = resolvePromoteClubId()
if (!clubId) {
throw new Error(
'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.',
)
const clubId = resolvePromoteClubId()
if (!clubId) {
alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.')
} else {
const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null)
const res = await api.bulkPatchExercisesMetadata({
exercise_ids: ids,
visibility: 'club',
club_id: clubId,
})
if ((res?.failed || []).length) {
const f = res.failed[0]
throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen')
}
}
const ids = promotionExercises.map((ex) => ex.id).filter((id) => id != null)
const bulkPayload = { exercise_ids: ids, visibility: nextVis }
if (nextVis === 'club' && clubId != null) bulkPayload.club_id = clubId
const res = await api.bulkPatchExercisesMetadata(bulkPayload)
if ((res?.failed || []).length) {
const f = res.failed[0]
throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen')
}
}
}
}
const promoteClubId = nextVis === 'club' ? resolvePromoteClubId() : null
if (nextVis === 'club' && !promoteClubId) {
throw new Error(
'Vereins-Sichtbarkeit: Bitte einen Verein unter „Verein zuordnen“ wählen oder den Vereins-Umschalter setzen.',
)
}
await api.updateExerciseProgressionGraph(selectedGraphId, {
name,
description: metaDescription.trim() || null,
visibility: metaVisibility,
...(promoteClubId != null ? { club_id: promoteClubId } : {}),
})
await refreshGraphs()
alert('Graph-Metadaten gespeichert.')
@ -627,14 +539,7 @@ function ExerciseProgressionGraphPanel(
<select
className="form-input"
value={metaVisibility}
onChange={(e) => {
const v = e.target.value
setMetaVisibility(v)
if (v === 'club' && !metaClubSelect) {
const fb = getDefaultClubIdForGovernanceForms(user)
if (fb != null) setMetaClubSelect(String(fb))
}
}}
onChange={(e) => setMetaVisibility(e.target.value)}
>
{filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}>
@ -643,42 +548,6 @@ function ExerciseProgressionGraphPanel(
))}
</select>
</div>
{metaVisibility === 'club' ? (
<div className="form-row">
<label className="form-label">Verein zuordnen</label>
<select
className="form-input"
value={metaClubSelect}
onChange={(e) => setMetaClubSelect(e.target.value)}
>
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{showGovernanceClubOptgroups ? (
<>
<optgroup label="Meine Vereine">
{sortedMemberClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</optgroup>
<optgroup label="Weitere Vereine">
{sortedOtherGovernanceClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</optgroup>
</>
) : (
governanceClubSelectOptions.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))
)}
</select>
</div>
) : null}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
Metadaten speichern

View File

@ -589,7 +589,6 @@ export default function ExerciseProgressionPathBuilder({
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = useState('')
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
const [graphGovernance, setGraphGovernance] = useState({ visibility: 'private', clubId: null })
const [wizardStep, setWizardStep] = useState(1)
const [pathInsertNotice, setPathInsertNotice] = useState('')
@ -671,10 +670,6 @@ export default function ExerciseProgressionPathBuilder({
.getExerciseProgressionGraph(Number(graphId))
.then((g) => {
if (cancelled) return
setGraphGovernance({
visibility: g?.visibility || 'private',
clubId: g?.club_id ?? null,
})
const art = g?.planning_roadmap
if (!art) return
if (art.goal_query) setGoalQuery(String(art.goal_query))
@ -1061,7 +1056,7 @@ export default function ExerciseProgressionPathBuilder({
setQuickSaving(true)
setQuickAiError('')
try {
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft, graphGovernance)
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
insertExerciseFromOffer(created, activeOffer)

View File

@ -11,8 +11,6 @@ import {
formatRefineLogEntry,
hasRematchSlotHints,
pathQaQualityPercent,
pathQaHasSplitDimensions,
pathQaSubsectionPercent,
pathQaShowsStrongResult,
resolveHintSlotIndex,
resolveOfferSlotIndex,
@ -28,46 +26,6 @@ function severityStyle(pathQa) {
}
}
function subsectionSeverityStyle(subsection) {
if (!subsection) return {}
return {
background: subsection.overall_ok
? 'color-mix(in srgb, var(--accent) 6%, var(--surface))'
: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
border: `1px solid ${subsection.overall_ok ? 'var(--border)' : 'color-mix(in srgb, var(--danger) 35%, var(--border))'}`,
}
}
function PathQaDimensionBlock({ title, subsection, children = null }) {
if (!subsection) return null
const pct = pathQaSubsectionPercent(subsection)
return (
<div
style={{
marginTop: '10px',
padding: '8px 10px',
borderRadius: '8px',
fontSize: '11px',
lineHeight: 1.45,
...subsectionSeverityStyle(subsection),
}}
>
<strong>
{title}: {subsection.overall_ok ? 'OK' : 'Hinweise'}
{pct != null ? ` (${pct} %)` : ''}
</strong>
{Array.isArray(subsection.issues) && subsection.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{subsection.issues.slice(0, 5).map((issue) => (
<li key={`${title}-${issue}`}>{issue}</li>
))}
</ul>
) : null}
{children}
</div>
)
}
function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) {
const { fixHints: optimizationHints } = useMemo(
() => splitPathQaHints(pathQa),
@ -352,9 +310,6 @@ export default function ProgressionFindingsPanel({
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
const qualityPct = pathQaQualityPercent(pathQa)
const strongResult = pathQaShowsStrongResult(pathQa)
const hasSplitQa = pathQaHasSplitDimensions(pathQa)
const roadmapQa = pathQa?.roadmap_qa || null
const assignmentQa = pathQa?.assignment_qa || null
return (
<div className="card" style={{ position: 'sticky', top: '12px' }}>
@ -409,14 +364,9 @@ export default function ProgressionFindingsPanel({
}}
>
<strong>
Pfad-QS gesamt: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{qualityPct != null ? ` (${qualityPct} %)` : ''}
</strong>
{hasSplitQa ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
Gesamt = schwächere Dimension (Roadmap vs. Übungsbesetzung).
</p>
) : null}
{pathQa.assignments_preserved ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
Bewertung des aktuellen Pfads. Übungen matchen öffnet einen Dialog mit Vorschlägen für
@ -428,23 +378,7 @@ export default function ProgressionFindingsPanel({
Starker Pfad KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
</p>
) : null}
{hasSplitQa ? (
<>
<PathQaDimensionBlock title="Roadmap & Stufen" subsection={roadmapQa}>
{roadmapQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{roadmapQa.topic_coverage}</p>
) : null}
</PathQaDimensionBlock>
<PathQaDimensionBlock title="Übungsbesetzung" subsection={assignmentQa}>
{assignmentQa?.empty_slot_count > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{assignmentQa.empty_slot_count} leere Slot(s) Übungen matchen oder manuell befüllen.
</p>
) : null}
</PathQaDimensionBlock>
</>
) : null}
{!hasSplitQa && pathQa.topic_coverage ? (
{pathQa.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
) : null}
{highlightTexts.length > 0 ? (
@ -481,7 +415,7 @@ export default function ProgressionFindingsPanel({
</ul>
</>
) : null}
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 && !hasSplitQa ? (
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.issues.map((issue) => (
<li key={issue}>{issue}</li>

View File

@ -4,8 +4,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { getDefaultClubIdForGovernanceForms } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ProgressionSlotCard from './ProgressionSlotCard'
@ -39,6 +37,7 @@ import {
compareDiffsForDialog,
dedupeGapOffersBySlot,
draftHasLibrarySlotAssignments,
draftRetrievalBoostExerciseIds,
EMPTY_PLANNING_CATALOG_CONTEXT,
filterGapOffersForUnfilledSlots,
hydrateProgressionGraphDraft,
@ -86,7 +85,6 @@ function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
}
export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
const { user } = useAuth()
const [graphMeta, setGraphMeta] = useState(null)
const [draft, setDraft] = useState(null)
const [busy, setBusy] = useState(false)
@ -477,6 +475,28 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
}
}
const buildMatchRequestBase = (synced) => {
const override = majorStepsToOverridePayload(synced.slots)
return {
query: (synced.goalQuery || '').trim(),
max_steps: synced.slots.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: false,
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
slot_assignments: slotsToSlotAssignments(synced),
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
...catalogApiPayload,
}
}
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
const baselineRes = await fetchPathEvaluate(synced)
@ -860,17 +880,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setSlotQuickSaving(true)
setSlotQuickError('')
try {
const graphVis = (graphMeta?.visibility || 'private').trim().toLowerCase()
const graphClubId =
graphMeta?.club_id != null
? graphMeta.club_id
: graphVis === 'club'
? getDefaultClubIdForGovernanceForms(user)
: null
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft, {
visibility: graphVis,
clubId: graphClubId,
})
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft)
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setDraft((prev) => ({
@ -1139,7 +1149,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
{draft.slots.map((slot, idx) => (
<ProgressionSlotCard
key={`slot-${idx}`}
key={`slot-${idx}-${slot.learning_goal?.slice(0, 12) || 'x'}`}
slot={slot}
slotIndex={idx}
slotCount={draft.slots.length}

View File

@ -6,9 +6,7 @@ import FormModalOverlay from './FormModalOverlay'
import {
compareSlotReviews,
defaultSelectedCompareDiffs,
pathQaHasSplitDimensions,
pathQaQualityPercent,
pathQaSubsectionPercent,
qualityDeltaPercent,
rejectedCompareDiffs,
slotFitScorePercent,
@ -191,9 +189,6 @@ function SlotReviewRow({ review, selected, onToggle, applying }) {
<strong>KI-Vorschlag nutzen</strong>
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{ai.title_hint || 'Neue Übung per KI entwerfen'}
{ai.auto_select
? ' — empfohlen, Bibliothek passt nicht ausreichend zum Stufen-Ziel'
: ''}
</span>
</span>
</label>
@ -228,9 +223,6 @@ export default function ProgressionOptimizeCompareModal({
const baselineQa = comparison.baseline_path_qa
const baselinePct = pathQaQualityPercent(baselineQa)
const hasSplitQa = pathQaHasSplitDimensions(baselineQa)
const roadmapPct = pathQaSubsectionPercent(baselineQa?.roadmap_qa)
const assignmentPct = pathQaSubsectionPercent(baselineQa?.assignment_qa)
const rejectedCount = rejected.length
const reviewError = comparison.review_error || null
@ -266,9 +258,8 @@ export default function ProgressionOptimizeCompareModal({
{title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Vorauswahl:
Bibliothek nur bei klar besserem Stufen-Fit; bei leeren oder schwach passenden Slots eher
KI-Vorschlag.
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur
vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.
</p>
{reviewError ? (
@ -298,19 +289,9 @@ export default function ProgressionOptimizeCompareModal({
>
<strong>Dein Pfad</strong>
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
{hasSplitQa ? (
<div style={{ marginTop: '6px', fontSize: '11px', color: 'var(--text2)', lineHeight: 1.45 }}>
Roadmap {roadmapPct != null ? `${roadmapPct} %` : '—'}
{' · '}
Besetzung {assignmentPct != null ? `${assignmentPct} %` : '—'}
</div>
) : null}
{!hasSplitQa && baselineQa?.topic_coverage ? (
{baselineQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
) : null}
{hasSplitQa && baselineQa?.roadmap_qa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.roadmap_qa.topic_coverage}</p>
) : null}
</div>
{rejectedCount > 0 ? (

View File

@ -1,166 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from '../../utils/api'
const KIND_LABELS = {
focus_area: 'Fokusbereich',
training_type: 'Trainingsstil',
target_group: 'Zielgruppe',
style_direction: 'Stilrichtung',
}
/**
* Pflege der Katalog-Prompt-Slots (Planungs-KI) an einem Stammdaten-Eintrag.
*/
export default function CatalogPromptSlotsEditor({ catalogKind, catalogId, entryName = '' }) {
const [slotTypes, setSlotTypes] = useState([])
const [slots, setSlots] = useState({})
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loaded, setLoaded] = useState(false)
const [storedSlots, setStoredSlots] = useState({})
const applicableTypes = useMemo(() => {
const kind = (catalogKind || '').trim()
return (slotTypes || []).filter((t) => {
const kinds = t.applicable_kinds || []
return kinds.length === 0 || kinds.includes(kind)
})
}, [slotTypes, catalogKind])
const load = useCallback(async () => {
if (!catalogId || !catalogKind) return
setLoading(true)
setError('')
try {
const [typesRes, slotsRes] = await Promise.all([
api.listCatalogPromptSlotTypes(),
api.getCatalogPromptSlots(catalogKind, catalogId),
])
setSlotTypes(Array.isArray(typesRes?.slot_types) ? typesRes.slot_types : [])
setSlots(slotsRes?.slots && typeof slotsRes.slots === 'object' ? { ...slotsRes.slots } : {})
setStoredSlots(
slotsRes?.stored_slots && typeof slotsRes.stored_slots === 'object' ? { ...slotsRes.stored_slots } : {}
)
setLoaded(true)
} catch (e) {
setError(e.message || String(e))
} finally {
setLoading(false)
}
}, [catalogKind, catalogId])
useEffect(() => {
setLoaded(false)
setSlots({})
if (catalogId && catalogKind) {
load()
}
}, [catalogId, catalogKind, load])
async function handleSave() {
if (!catalogId || !catalogKind) return
setSaving(true)
setError('')
try {
const res = await api.updateCatalogPromptSlots(catalogKind, catalogId, { slots })
setSlots(res?.slots && typeof res.slots === 'object' ? { ...res.slots } : {})
} catch (e) {
setError(e.message || String(e))
} finally {
setSaving(false)
}
}
if (!catalogId || !catalogKind) {
return null
}
const kindLabel = KIND_LABELS[catalogKind] || catalogKind
return (
<div
className="catalog-prompt-slots"
style={{
marginTop: '20px',
paddingTop: '16px',
borderTop: '1px solid var(--border)',
}}
>
<h4 style={{ margin: '0 0 8px' }}>Planungs-KI Prompt-Texte</h4>
<p style={{ margin: '0 0 12px', fontSize: '13px', color: 'var(--text2)' }}>
Texte für KI-Prompts (Progressionsgraph, Pfad-QS). Platzhalter:{' '}
<code>{'{{' + catalogKind + '_<slot_key>}}'}</code>
{entryName ? (
<>
{' '}
Eintrag: <strong>{entryName}</strong>
</>
) : null}
</p>
{error ? (
<div className="admin-matrix-alert" style={{ marginBottom: '12px' }}>
{error}
</div>
) : null}
{loading && !loaded ? (
<div className="spinner" style={{ minHeight: '48px' }} />
) : (
<>
{applicableTypes.map((st) => {
const key = st.slot_key
const ph = `{{${catalogKind}_${key}}}`
const isCodeOnly = st.for_code && !st.for_llm
const fromFallback =
!(storedSlots[key] || '').trim() && (slots[key] || '').trim() && key !== 'description'
return (
<div key={key} className="form-row">
<label className="form-label">
{st.display_name || key}
{fromFallback ? (
<span style={{ marginLeft: '6px', fontSize: '12px', color: 'var(--accent)' }}>
(Standard-Vorlage)
</span>
) : null}
{isCodeOnly ? (
<span style={{ marginLeft: '6px', fontSize: '12px', color: 'var(--text3)' }}>
(primär Code)
</span>
) : null}
</label>
{st.description ? (
<p style={{ margin: '0 0 6px', fontSize: '12px', color: 'var(--text3)' }}>{st.description}</p>
) : null}
<textarea
className="form-input"
rows={key === 'description' ? 3 : 4}
value={slots[key] || ''}
onChange={(e) => setSlots((prev) => ({ ...prev, [key]: e.target.value }))}
placeholder={
key === 'description'
? 'Leer = Stammdaten-Beschreibung als Fallback'
: `Text für ${ph}`
}
/>
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)', fontFamily: 'monospace' }}>
{ph}
</p>
</div>
)
})}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Speichert…' : 'KI-Texte speichern'}
</button>
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading || saving}>
Neu laden
</button>
</div>
</>
)}
</div>
)
}

View File

@ -1,6 +1,5 @@
import React, { useState } from 'react'
import { api } from '../../utils/api'
import CatalogPromptSlotsEditor from './CatalogPromptSlotsEditor'
function DetailPanel({ item, onUpdate, focusAreas }) {
const type = item._type
@ -88,7 +87,6 @@ function FocusAreaDetail({ item, onUpdate }) {
</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
<CatalogPromptSlotsEditor catalogKind="focus_area" catalogId={item.id} entryName={form.name} />
</div>
)
}
@ -171,7 +169,6 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
<CatalogPromptSlotsEditor catalogKind="style_direction" catalogId={item.id} entryName={form.name} />
</div>
)
}
@ -254,7 +251,6 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
<CatalogPromptSlotsEditor catalogKind="training_type" catalogId={item.id} entryName={form.name} />
</div>
)
}

View File

@ -31,22 +31,8 @@ export default function AdminAiPromptsPage() {
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
const [pvHint, setPvHint] = useState('')
const [pvFocusId, setPvFocusId] = useState('')
const [pvGoalQuery, setPvGoalQuery] = useState(
'Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe'
)
const [pvUserNotes, setPvUserNotes] = useState('Fokus Breitensport, ohne Wettkampfdruck.')
const [pvMaxSteps, setPvMaxSteps] = useState('5')
const [pvSearchQuery, setPvSearchQuery] = useState('')
const [pvPreview, setPvPreview] = useState(null)
const selectedSlug = (detail?.slug || '').trim().toLowerCase()
const isExercisePreviewSlug = [
'exercise_summary',
'exercise_skill_suggestions',
'exercise_instruction_rewrite',
].includes(selectedSlug)
const isPlanningPreviewSlug = selectedSlug.startsWith('planning_')
const loadList = useCallback(async () => {
const [pList, cat] = await Promise.all([
api.listAdminAiPrompts(),
@ -147,23 +133,15 @@ export default function AdminAiPromptsPage() {
if (!detail?.id) return
setError('')
try {
const body = {}
if (isPlanningPreviewSlug) {
body.goal_query = pvGoalQuery.trim() || undefined
body.user_notes = pvUserNotes.trim() || undefined
const ms = parseInt(String(pvMaxSteps).trim(), 10)
if (Number.isFinite(ms) && ms >= 2 && ms <= 10) body.max_steps = ms
const sq = pvSearchQuery.trim()
if (sq) body.search_query = sq
} else if (isExercisePreviewSlug) {
body.title = pvTitle
body.goal = pvGoal
body.execution = pvExec
body.focus_hint = pvHint || undefined
const fid = parseInt(String(pvFocusId).trim(), 10)
if (Number.isFinite(fid) && fid >= 1) {
body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }]
}
const body = {
title: pvTitle,
goal: pvGoal,
execution: pvExec,
focus_hint: pvHint || undefined,
}
const fid = parseInt(String(pvFocusId).trim(), 10)
if (Number.isFinite(fid) && fid >= 1) {
body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }]
}
const r = await api.previewAdminAiPrompt(detail.id, body)
setPvPreview(r)
@ -193,8 +171,8 @@ export default function AdminAiPromptsPage() {
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
</div>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden
serverseitig aufgelöst die Vorschau unten ruft kein externes Modell auf.
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig
aufgelöst die Vorschau unten ruft kein externes Modell auf.
</p>
{error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null}
@ -323,89 +301,33 @@ export default function AdminAiPromptsPage() {
<section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
{isPlanningPreviewSlug ? (
<>
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 0 }}>
Beispielkontext für Planungs-Prompts echte Katalog-Auszüge aus der Datenbank, übrige Felder
sind repräsentative Demo-Daten.
</p>
<div className="form-row">
<label className="form-label">Zielanfrage (goal_query)</label>
<textarea
className="form-input"
rows={3}
value={pvGoalQuery}
onChange={(e) => setPvGoalQuery(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Trainer-Notizen (user_notes)</label>
<textarea
className="form-input"
rows={2}
value={pvUserNotes}
onChange={(e) => setPvUserNotes(e.target.value)}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row">
<label className="form-label">max_steps (Roadmap)</label>
<input
className="form-input"
type="number"
min={2}
max={10}
value={pvMaxSteps}
onChange={(e) => setPvMaxSteps(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Suchanfrage (optional)</label>
<input
className="form-input"
placeholder="Leer = goal_query"
value={pvSearchQuery}
onChange={(e) => setPvSearchQuery(e.target.value)}
/>
</div>
</div>
</>
) : isExercisePreviewSlug ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row">
<label className="form-label">Titel</label>
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Fokus-ID (optional, RetrievalRaster)</label>
<input
className="form-input"
placeholder="numerisch"
value={pvFocusId}
onChange={(e) => setPvFocusId(e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Fokus-Hinweistext</label>
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Ziel (HTML möglich)</label>
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Durchführung (HTML möglich)</label>
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
</div>
</>
) : (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
Für diesen Slug ist noch kein Beispielkontext hinterlegt es wird nur das Roh-Template ohne
Ersetzung angezeigt.
</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row">
<label className="form-label">Titel</label>
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Fokus-ID (optional, RetrievalRaster)</label>
<input
className="form-input"
placeholder="numerisch"
value={pvFocusId}
onChange={(e) => setPvFocusId(e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Fokus-Hinweistext</label>
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Ziel (HTML möglich)</label>
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Durchführung (HTML möglich)</label>
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
</div>
<button type="button" className="btn btn-secondary" onClick={() => runPreview()}>
Platzhalter auflösen
</button>

View File

@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
import PageSectionNav from '../components/PageSectionNav'
import CatalogPromptSlotsEditor from '../components/admin/CatalogPromptSlotsEditor'
const CATALOG_SUBTABS = [
{ id: 'focus-areas', label: 'Fokusbereiche' },
@ -63,38 +62,6 @@ export default function AdminCatalogsPage() {
// M:N Assignment Matrix
const [assignments, setAssignments] = useState([])
const [matrixLoading, setMatrixLoading] = useState(false)
const [openKiSlots, setOpenKiSlots] = useState(null)
function toggleKiSlots(kind, id) {
const key = `${kind}:${id}`
setOpenKiSlots((prev) => (prev === key ? null : key))
}
function renderKiSlotsToggle(kind, id, label = 'KI-Planungstexte') {
const key = `${kind}:${id}`
const open = openKiSlots === key
return (
<button
type="button"
className="btn btn-secondary"
onClick={() => toggleKiSlots(kind, id)}
>
{open ? 'KI-Texte ausblenden' : label}
</button>
)
}
function renderKiSlotsPanel(kind, id, entryName) {
const key = `${kind}:${id}`
if (openKiSlots !== key) return null
return (
<CatalogPromptSlotsEditor
catalogKind={kind}
catalogId={id}
entryName={entryName}
/>
)
}
useEffect(() => {
loadData()
@ -108,22 +75,14 @@ export default function AdminCatalogsPage() {
const data = await api.listFocusAreas()
setFocusAreas(data)
} else if (activeTab === 'training-styles') {
const [data, areas] = await Promise.all([
api.listStyleDirections(),
api.listFocusAreas(),
])
const data = await api.listStyleDirections()
setTrainingStyles(data)
setFocusAreas(areas)
} else if (activeTab === 'training-characters') {
const data = await api.listTrainingCharacters()
setTrainingCharacters(data)
} else if (activeTab === 'training-types') {
const [data, areas] = await Promise.all([
api.listTrainingTypes(),
api.listFocusAreas(),
])
const data = await api.listTrainingTypes()
setTrainingTypes(data)
setFocusAreas(areas)
} else if (activeTab === 'skill-categories') {
const data = await api.listSkillCategories()
setSkillCategories(data)
@ -472,11 +431,6 @@ export default function AdminCatalogsPage() {
<button className="btn btn-primary" onClick={() => updateFocusArea(fa.id, editingFA)}>Speichern</button>
<button className="btn" onClick={() => setEditingFA(null)}>Abbrechen</button>
</div>
<CatalogPromptSlotsEditor
catalogKind="focus_area"
catalogId={fa.id}
entryName={editingFA.name}
/>
</div>
) : (
<div>
@ -495,12 +449,10 @@ export default function AdminCatalogsPage() {
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn" onClick={() => setEditingFA(fa)}>Bearbeiten</button>
{renderKiSlotsToggle('focus_area', fa.id)}
<button className="btn" onClick={() => deleteFocusArea(fa.id)}>Löschen</button>
</div>
{renderKiSlotsPanel('focus_area', fa.id, fa.name)}
</div>
)}
</div>
@ -587,11 +539,6 @@ export default function AdminCatalogsPage() {
<button className="btn btn-primary" onClick={() => updateStyleDirection(ts.id, editingTS)}>Speichern</button>
<button className="btn" onClick={() => setEditingTS(null)}>Abbrechen</button>
</div>
<CatalogPromptSlotsEditor
catalogKind="style_direction"
catalogId={ts.id}
entryName={editingTS.name}
/>
</div>
) : (
<div>
@ -607,12 +554,10 @@ export default function AdminCatalogsPage() {
</p>
)}
<p style={{ margin: '8px 0', color: 'var(--text2)' }}>{ts.description}</p>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn" onClick={() => setEditingTS(ts)}>Bearbeiten</button>
{renderKiSlotsToggle('style_direction', ts.id)}
<button className="btn" onClick={() => deleteStyleDirection(ts.id)}>Löschen</button>
</div>
{renderKiSlotsPanel('style_direction', ts.id, ts.name)}
</div>
)}
</div>
@ -785,11 +730,6 @@ export default function AdminCatalogsPage() {
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
<button className="btn" onClick={() => setEditingTT(null)}>Abbrechen</button>
</div>
<CatalogPromptSlotsEditor
catalogKind="training_type"
catalogId={tt.id}
entryName={editingTT.name}
/>
</div>
) : (
<div>
@ -804,12 +744,10 @@ export default function AdminCatalogsPage() {
<span style={{ color: 'var(--accent)' }}>{tt.focus_area_icon} {tt.focus_area_name}</span>
</p>
)}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '12px' }}>
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
{renderKiSlotsToggle('training_type', tt.id)}
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>
</div>
{renderKiSlotsPanel('training_type', tt.id, tt.name)}
</div>
)}
</div>
@ -1018,11 +956,6 @@ export default function AdminCatalogsPage() {
<button className="btn btn-primary" onClick={() => updateTargetGroup(tg.id, tg)}>Speichern</button>
<button className="btn" onClick={() => setEditingTG(null)}>Abbrechen</button>
</div>
<CatalogPromptSlotsEditor
catalogKind="target_group"
catalogId={tg.id}
entryName={tg.name}
/>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@ -1037,12 +970,10 @@ export default function AdminCatalogsPage() {
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
)}
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn" onClick={() => setEditingTG(tg.id)}>Bearbeiten</button>
{renderKiSlotsToggle('target_group', tg.id)}
<button className="btn" style={{ background: 'var(--danger)', color: 'white' }} onClick={() => deleteTargetGroup(tg.id)}>Löschen</button>
</div>
{renderKiSlotsPanel('target_group', tg.id, tg.name)}
</div>
)}
</div>

View File

@ -626,21 +626,6 @@ export async function getAdminAiPromptPlaceholdersCatalog() {
return request('/api/admin/ai-prompts/catalog/placeholders')
}
export async function listCatalogPromptSlotTypes() {
return request('/api/catalog-prompt-slot-types')
}
export async function getCatalogPromptSlots(catalogKind, catalogId) {
return request(`/api/catalog-prompt-slots/${encodeURIComponent(catalogKind)}/${catalogId}`)
}
export async function updateCatalogPromptSlots(catalogKind, catalogId, data) {
return request(`/api/catalog-prompt-slots/${encodeURIComponent(catalogKind)}/${catalogId}`, {
method: 'PUT',
body: JSON.stringify(data || {}),
})
}
// ============================================================================
// Reifegradmodelle / Fähigkeitsmatrix
// ============================================================================
@ -1104,9 +1089,6 @@ export const api = {
previewAdminAiPrompt,
resetAdminAiPromptTemplate,
getAdminAiPromptPlaceholdersCatalog,
listCatalogPromptSlotTypes,
getCatalogPromptSlots,
updateCatalogPromptSlots,
listStyleDirections,
listTrainingStyles,
createStyleDirection,

View File

@ -208,27 +208,11 @@ export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketc
}
}
/**
* Sichtbarkeit/club_id für Schnellanlage (z. B. aus Progressionsgraph).
* @param {{ visibility?: string, clubId?: number|null }} [governance]
*/
function resolveQuickCreateGovernance(governance) {
const rawVis = (governance?.visibility || 'private').trim().toLowerCase()
const vis = rawVis === 'club' || rawVis === 'official' ? rawVis : 'private'
let clubId = null
if (vis === 'club' && governance?.clubId != null && governance.clubId !== '') {
const n = Number(governance.clubId)
if (Number.isFinite(n) && n > 0) clubId = n
}
return { visibility: vis, club_id: clubId }
}
/**
* createExercise-Payload aus bearbeitetem Entwurf.
* @param {{ visibility?: string, clubId?: number|null }} [governance]
* @throws {Error}
*/
export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
export function buildQuickCreateExercisePayloadFromDraft(draft) {
const title = (draft?.title || '').trim()
if (title.length < 3) {
throw new Error('Titel: mindestens 3 Zeichen.')
@ -255,7 +239,6 @@ export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
if (summary && !stripHtmlToText(summary).trim()) summary = null
const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
return {
title,
@ -264,7 +247,7 @@ export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
execution,
preparation: prep,
trainer_notes: trainerNotes,
visibility,
visibility: 'private',
status: 'draft',
equipment: [],
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
@ -273,16 +256,15 @@ export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
target_groups_multi: [],
age_groups: [],
skills,
club_id: clubId,
club_id: null,
}
}
/**
* createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus).
* @param {{ visibility?: string, clubId?: number|null }} [governance]
* @throws {Error}
*/
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain, ...governance } = {}) {
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const fieldMap = {}
for (const c of preview?.instructionChoices || []) {
@ -306,7 +288,6 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
}
const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
const fid = Number(focusAreaId)
if (!Number.isFinite(fid) || fid < 1) {
@ -320,7 +301,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
execution,
preparation: prep,
trainer_notes: trainerNotes,
visibility,
visibility: 'private',
status: 'draft',
equipment: [],
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
@ -329,7 +310,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
target_groups_multi: [],
age_groups: [],
skills,
club_id: clubId,
club_id: null,
}
}

View File

@ -173,19 +173,8 @@ export function pathQaQualityPercent(pathQa) {
return Math.round(Number(pathQa.quality_score) * 100)
}
export function pathQaSubsectionPercent(subsection) {
if (subsection?.quality_score == null || !Number.isFinite(Number(subsection.quality_score))) return null
return Math.round(Number(subsection.quality_score) * 100)
}
export function pathQaHasSplitDimensions(pathQa) {
return Boolean(pathQa?.roadmap_qa || pathQa?.assignment_qa)
}
export function pathQaShowsStrongResult(pathQa) {
const pct = pathQaQualityPercent(pathQa)
const assignmentOk = pathQa?.assignment_qa ? pathQa.assignment_qa.overall_ok !== false : true
if (!assignmentOk) return false
if (pathQa?.overall_ok && pct != null && pct >= 85) return true
return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length)
}
@ -1084,16 +1073,9 @@ export function compareDiffsForDialog(comparison) {
export function defaultSelectedCompareDiffs(comparison) {
const reviews = compareSlotReviews(comparison)
if (reviews.length > 0) {
const keys = []
for (const review of reviews) {
const midx = review.roadmap_major_step_index
if (review?.ai_alternative?.auto_select) {
keys.push(slotReviewSelectionKey(midx, 'ai'))
} else if (review?.library_alternative?.auto_select) {
keys.push(slotReviewSelectionKey(midx, 'library'))
}
}
return keys
return reviews
.filter((review) => review?.library_alternative?.auto_select)
.map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library'))
}
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
}