Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e5a7b466f | |||
| f10c28e5cd | |||
| 5ea225ec6b | |||
| 2e22a7c8e0 | |||
| 32b28a93f3 | |||
| 6c7c24e887 | |||
| 3181cc126d | |||
| b719f85c4e | |||
| 35fe31774b | |||
| 90e8f51566 | |||
| 7e5ef4561a | |||
| 53f2b027cc | |||
| 9cee862c32 | |||
| 0b203489f7 | |||
| 1c67a50ce4 | |||
| 87d9fa9b65 | |||
| 4b9374765b | |||
| b629f192ac | |||
| 313d613b7c | |||
| ea7de64061 | |||
| 7265cd5a01 | |||
| 5e5f4ca8d4 | |||
| f0e581a9f5 | |||
| cd457e3ea0 | |||
| e9bf5bd1a5 | |||
| 3468b2066e | |||
| a1e4ad66df | |||
| 85fccdd093 | |||
| 19bbcdaf50 | |||
| cec96ae473 | |||
| 53f1c7161f | |||
| 89c6780294 | |||
| 3f130aa8ad | |||
| 69ce3f6975 | |||
| dccb065181 | |||
| e828a5da32 | |||
| 5bca5ef9eb | |||
| 5ed06002d9 | |||
| b8f65e04c5 | |||
| f3710ac0a1 | |||
| dbc2dfacb9 | |||
| 6ab2f20f08 | |||
| a4e73c830f | |||
| 63c99b0ec5 | |||
| d448c3191f | |||
| 8a4be795f4 | |||
| a49987408b | |||
| f36a747efa | |||
| de9fdf3ac0 | |||
| 9b4d091637 | |||
| df93da9a03 | |||
| de939481ba | |||
| 6d130a7e09 | |||
| b2fbf6b4af | |||
| ca2adbd55e | |||
| ad051c015f | |||
| b464047c3a | |||
| 7203c871fc | |||
| 480890d0c6 | |||
| 8f1dad53ab | |||
| 044ce2ee60 | |||
| f63b09fc9c | |||
| 713a344d17 | |||
| 1d94c2ebf1 | |||
| a152218c45 | |||
| 4ef3f00e6b | |||
| 3c12363b8f | |||
| 07e147bc76 | |||
| 18547613ea | |||
| 48d51c07c5 | |||
| 3b483346de | |||
| e0ddfa6ce5 | |||
| ee22b22970 | |||
| c1bf9279ad | |||
| 97efe66306 | |||
| 8d5f0b533c | |||
| 800189ff8f | |||
| 3be7606d90 | |||
| ca3a9c6fa4 | |||
| 5692931d07 | |||
| 98b279fa89 | |||
| 1e7941f57b | |||
| 0adf20c9e1 | |||
| 4724da28b1 | |||
| d4b1780193 | |||
| f2650dac57 | |||
| fad1058d54 | |||
| 9dd44ce3ca | |||
| 87f258be38 | |||
| 779e2477ba | |||
| f074a8bef0 | |||
| 0677663268 | |||
| d4e9bded23 | |||
| 7411543a97 | |||
| dd0fae4bf5 | |||
| a9a6153ed5 | |||
| 4130a63dfe | |||
| 9d52aeab67 | |||
| b68185842e | |||
| 40641594ac | |||
| e4cb491d46 | |||
| 8404a42b6c | |||
| fa10450315 | |||
| 37785135b1 | |||
| 8ee8f52e0f | |||
| 8718cf5c70 | |||
| 91dae7b614 | |||
| 20927a5969 | |||
| 7db77f4738 | |||
| 3e87f7515a | |||
| a2f60d3f46 | |||
| 30dc30c7aa | |||
| 7cfbca40bb | |||
| c294c27de8 | |||
| 50c9beb4b3 | |||
| bd5a409fa7 | |||
| 3450a9296a | |||
| 29a5db63e0 | |||
| 8d1dd59c3c | |||
| 5b73d1a1f5 | |||
| c2c736dafc | |||
| c6b8c396ad | |||
| a19ed02300 | |||
| 6db31e7312 | |||
| a34e748be5 | |||
| 16187fbbd0 | |||
| b2157d8a40 | |||
| 50aff849d8 | |||
| a0a891e550 | |||
| 9ba35dc022 | |||
| 46fae3da33 | |||
| f4196c3580 | |||
| d1d8539b42 | |||
| a8633235f2 | |||
| 5c882985e0 | |||
| 04cc77d501 | |||
| 8e68261bc1 | |||
| b0611b9f7f | |||
| 614c2dcfaa | |||
| f5c886fc13 | |||
| d019c20338 | |||
| 905bce198f | |||
| 45e3b5f4f6 | |||
| 207817376d | |||
| 128a9d752e | |||
| d7d45a8927 | |||
| fc5748bef1 | |||
| 9d880e2346 | |||
| c816e50c68 | |||
| 294740b780 | |||
| 675cfa85f0 | |||
| 4725eaa90b | |||
| 9f4678f418 | |||
| 5331eab39c | |||
| 93b8d09d05 | |||
| 0551bb3d80 | |||
| 3bf012a8f4 | |||
| e22266a18c | |||
| d58db3d5dd | |||
| cdeddc7cec | |||
| 2148d0aa7f | |||
| 69f238d9b8 | |||
| f9e295bce0 | |||
| 888d0bd009 | |||
| 1942585546 | |||
| a28a9d399a | |||
| 9be69ace5c | |||
| 286c36e9d7 | |||
| 294b09a5d9 | |||
| e5291256d0 | |||
| 4d36bbf634 | |||
| e4451e1362 |
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
**Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
|
**Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
|
||||||
|
|
||||||
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
|
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**, **F15** Match-Dialog + getrennte Pfad-QS lokal): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8.
|
||||||
|
|
||||||
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
|
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
|
||||||
|
|
||||||
|
|
@ -83,7 +83,9 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
||||||
- [x] **Varianten** (CRUD, Reorder, Voraussetzung) + Anzeige im Detail
|
- [x] **Varianten** (CRUD, Reorder, Voraussetzung) + Anzeige im Detail
|
||||||
- [x] **Progressionsgraph zwischen Übungen** (Bibliotheks-Container, Kanten, Sequenz-Bulk, Varianten-Knoten — Zwischenstand, siehe TRAINING_FRAMEWORK_SPEC §4)
|
- [x] **Progressionsgraph zwischen Übungen** (Bibliotheks-Container, Kanten, Sequenz-Bulk, Varianten-Knoten — Zwischenstand, siehe TRAINING_FRAMEWORK_SPEC §4)
|
||||||
- [x] Medien (Upload/Embed, rollenabhängige Größenlimits)
|
- [x] Medien (Upload/Embed, rollenabhängige Größenlimits)
|
||||||
- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen)
|
- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen; **Freigabelevel** als UI-Begriff für `visibility`)
|
||||||
|
- [x] **Übungsformular:** Registerkarten (Stammdaten … Medien & Mehr), kompakte Chip-Editoren, Varianten-Speichern über Aktionsleiste
|
||||||
|
- [x] **Fähigkeiten-Intensität** ohne Primär-Flag (`niedrig`/`mittel`/`hoch`; Backend `is_primary` immer false)
|
||||||
- [x] Exercise Blocks (Bausteine)
|
- [x] Exercise Blocks (Bausteine)
|
||||||
- [x] Saved Searches (wo implementiert)
|
- [x] Saved Searches (wo implementiert)
|
||||||
|
|
||||||
|
|
|
||||||
100
.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md
Normal file
100
.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# KI-Unterstützung bei Übungen – Produkt-Vision
|
||||||
|
|
||||||
|
**Version:** 0.1
|
||||||
|
**Datum:** 2026-05-22
|
||||||
|
**Status:** Zielbild / Anforderungsgrundlage (nicht gleich Ist-Spec – technische Schnittstellen: **`technical/KI_FEATURES_SPEC.md`**, **`technical/AI_PROMPT_SYSTEM_SPEC.md`**, **`technical/AI_TRAINING_PLANNING_CONCEPT.md` §1.1**)
|
||||||
|
**Zielgruppe:** Product, Trainer-UX, später Admin-Werkzeuge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Übergeordnete Prinzipien
|
||||||
|
|
||||||
|
1. **Immer Vorschlag, nie blind überschreiben**
|
||||||
|
Die KI liefert **Vorschläge** (Änderungen, Ergänzungen, Strukturen). Bestehende Inhalte werden **nicht** still ersetzt. Übernahme erfolgt durch den Nutzer: **teilweise** (Felder/Stellen/Blöcke) oder **komplett** („Vorschlag gesamt akzeptieren“).
|
||||||
|
|
||||||
|
2. **Granulare Anforderung im Editor**
|
||||||
|
Innerhalb einer Übung soll KI-Unterstützung **feldbasiert oder bereichsbasiert** auslösbar sein (z. B. nur „Anleitung schärfen“, nur „Fähigkeiten“, nur „Variantenrahmen“) **oder** als **Komplettüberarbeitung** mit klarem Warnhinweis (Umfang/transparenter Diff).
|
||||||
|
|
||||||
|
3. **Nachweisliche Herkunft**
|
||||||
|
Übernommene KI-Inhalte werden technisch dort abgebildet, wo bereits vorgesehen (z. B. **`summary_ai_generated`**, **`exercise_skills.ai_suggested`**) und um analogen Hinweis für weitergehende Textfelder/Varianten **erweitert**, sobald Implementierung konkret wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Funktionsbereiche (Vision)
|
||||||
|
|
||||||
|
### 2.1 Von der Idee zur kompletten Übung („Zielausbau“)
|
||||||
|
|
||||||
|
**Einstieg minimal:** Kurzbeschreibung oder Stichwort, **Ziel** („was soll erreicht werden?“), wenige **Rahmenparameter** (z. B. Fokusbereich, Trainingszeit, Teilnehmerzahl, Alter, Platzausstattung, Sicherheitshinweise – konkrete Dropdowns/Freifelder in UX später festlegen).
|
||||||
|
|
||||||
|
**KI-Aufgabe:** aus diesem dünnen Kontext einen **übernehmbaren Entwurf** einer **ganzen Übung** erzeugen: Titel‑Vorschlag, Ziel-/Durchführungstext, Sicherheit/Organisation, ggf. Trainerhinweise – **immer als Vorschlagspaket**, nicht als Speicher ohne Bestätigung.
|
||||||
|
|
||||||
|
**Abgrenzung:** Kombinationsübungen / komplexe Methodenprofile können **phasenweise** später einbezogen werden (Verweis Fachspez Trainingsmodule).
|
||||||
|
|
||||||
|
### 2.2 Anleitung (Durchführung / „Ausführung“) maximal hilfreich
|
||||||
|
|
||||||
|
**Ziel:** Die **Ausführungs-/Anleitungsbereiche** sollen sich **didaktisch klar**, **teilbar** und **wieder verwendbar** lesen – ohne den Trainer zu entmindigen.
|
||||||
|
|
||||||
|
**KI-Aufgabe:** Überarbeitungsvorschlag für Struktur (nummerierte Schritte, Zeiten pro Block, häufige Fehler, Progressionshinweise innerhalb der Übung wo sinnvoll). **Selektiver** Aufruf: nur diese Felder oder nur ein markierter Abschnitt (wenn UX Textauswahl unterstützt).
|
||||||
|
|
||||||
|
### 2.3 Kurzbeschreibung (`summary`)
|
||||||
|
|
||||||
|
**KI-Aufgabe:** Aus den **relevanten Übungstexten** eine **Liste-/Karte-taugliche** Kurzfassung generieren — wie in **`KI_FEATURES_SPEC.md`** beschrieben, mit **Ablehnen / Bearbeiten / Übernehmen**.
|
||||||
|
|
||||||
|
### 2.4 Einordnung – primär **Fähigkeiten**
|
||||||
|
|
||||||
|
**KI-Aufgabe:** automatische Erkennung und **Zuordnung** zum **globale Skills-Katalog** inklusive:
|
||||||
|
|
||||||
|
- **Intensität** (`exercise_skills`)
|
||||||
|
- **Skill-Level**: `required_level` / `target_level` nach **kanonischen Slugs** (Backend-konform)
|
||||||
|
- **`is_primary`** / Priorisierung wo fachlich sinnvoll
|
||||||
|
|
||||||
|
**Prompt-Kontext für Qualität:** Stammfelder wie `skills.description`, **`karate_relevance`**, **`relevance_level`**, **`focus_areas`**, optional **`skill_level_definitions`** nur für eine **kurze Kandidatenliste** (zweite Runde möglich) – keine vollständigen Romane für den gesamten Katalog auf einmal.
|
||||||
|
|
||||||
|
### 2.5 Varianten (optional, später prioritär erwägenswert)
|
||||||
|
|
||||||
|
**Vision:** Aus Ziel-/Durchführungstext **mehrere sinnvolle Ausprägungen** als **Übungsvarianten** vorschlagen oder einzelne erzeugen (**progression**, **Schwierigkeit**, andere Paararbeit, Gerätevariation) mit **übernehmbarem** Datenmodell gleich dem bestehenden `exercise_variants`.
|
||||||
|
|
||||||
|
**Randbedingungen:** Validierung gegen Übungstyp (Kombinationsübungen ohne Varianten laut Produktstand), keine Halluzination fremder IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Kontextbezug später: Nachbearbeitung aus der Trainingsplanung
|
||||||
|
|
||||||
|
**Vision:** Hinweise aus der **Nachbearbeitung** einer Trainingseinheit (Ist‑Minuten, Trainer-Notizen, Abweichungen „was lief nicht?“ – je nach Datenmodell) fließen **optional** als Kontext in eine **erneute KI-Überarbeitung der betroffenen Übung** ein („Übung aus den Erfahrungen der Gruppe verbessern“).
|
||||||
|
|
||||||
|
**Konsequenz technisch später:** Zugriffsrechte, Mandant, keine unzulässige Verknüpfung personenbezogener Sportlerdaten; Aggregation auf **Einheit-/Gruppe** und **bereits dokumentierte Trainer-Insights**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Admin: Massenverarbeitung und Analyse
|
||||||
|
|
||||||
|
**Vision für Plattform-/Vereins-Admins:**
|
||||||
|
|
||||||
|
| Thema | Richtungsziel |
|
||||||
|
|-------|----------------|
|
||||||
|
| **Massenverarbeitung** | Batch: z. B. Zusammenfassungen nachziehen, fehlende Skills vorschlagen, einheitlicher Stil bei importiertem Bestand — immer mit **Review-Queue**, nicht ohne menschliche Freigabe skalierungskritisch. |
|
||||||
|
| **Analyse / Qualität** | Werkzeugkasten oder Berichte: **welche Übungen** sollten überarbeitet werden? z. B. leere/kurze `summary`, fehlende `goal`/`execution`, **fehlende oder widersprüchliche Skill-Zuordnung**, Import-Herkunft ohne Plausibilität, Kombi-Slots unvollständig, sehr alte Imports. |
|
||||||
|
| **Lückenkarten** | Z. B. Abgleich gegen **Skill-Discovery**/Profil-Analysen („keine Übung deckt Fähigkeit X ab“ auf gewähltem Korpus); Verbindung zu **`skill-discovery`** entscheidend später im Detail (kein automatischer Rewrite ohne Policy). |
|
||||||
|
|
||||||
|
**Governance:** Sichtbarkeit (`official`, Verein), Rechte (**Superadmin** vs. Vereinsinhalt), Audit der KI-Anwendung bei Massenjobs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phasierung (überarbeitungsfähig)
|
||||||
|
|
||||||
|
| Phase | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| **P0** | KI-Service + Prompts aus DB + **Suggestion-only** UX; Kern: **Summary** + **Skills** (wie Spec-Minimum), **ein Feld / Komplettpaket mit Diff** nach UX. |
|
||||||
|
| **P1** | **Anleitung überarbeiten** + **„von Idee zur Übung“** (Zielausbau) mit Rahmenparameter-Form |
|
||||||
|
| **P2** | **Variantenvorschläge** mit strenger Validation |
|
||||||
|
| **P3** | **Planungs-/Nachbereitungskontext** |
|
||||||
|
| **P4** | **Admin** Massen-/Analyse (Queue + Reports + Governance) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Offene Produkt-/Fachfragen
|
||||||
|
|
||||||
|
- Minimaler **Parameterbau** beim Zielausbau (Pflicht vs. optional).
|
||||||
|
- Umgang mit **Medien**/Inline-Verweisen beim KI-Text – nichts zerstören, Platzhalter erhalten (siehe Medien-Spec §11).
|
||||||
|
- **Kombinationsübungen:** welche Teilaspekte dürfen KI anfassen?
|
||||||
|
- Limits: **Tokens**, **Rate-Limits**, Kostenüberwachung pro Verein/global.
|
||||||
|
|
@ -57,7 +57,7 @@ Haupt-Kategorie (KARATE / ALLGEMEINE)
|
||||||
- Selbstverteidigung ✓
|
- Selbstverteidigung ✓
|
||||||
- Gewaltschutz ✓
|
- Gewaltschutz ✓
|
||||||
|
|
||||||
**Technische Umsetzung:** M:N Beziehungen mit `is_primary` Flag.
|
**Technische Umsetzung:** M:N-Beziehungen mit optionalem `is_primary`-Flag bei **Fokusbereichen, Stilrichtungen, Trainingsstilen und Zielgruppen** — nicht bei `exercise_skills` (dort nur Intensität `niedrig|mittel|hoch`).
|
||||||
|
|
||||||
### 3. Hierarchischer Kontext (§8.1)
|
### 3. Hierarchischer Kontext (§8.1)
|
||||||
|
|
||||||
|
|
@ -465,6 +465,8 @@ skill_level_definitions (
|
||||||
|
|
||||||
**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4.
|
**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4.
|
||||||
|
|
||||||
|
**KI-Planung (Workbench, Stand 0.8.233):** Am Graph können Trainer neben Kanten ein **`planning_roadmap`**-Artefakt (Curriculum-Stufen) und **`planning_catalog_context`** (Primärfokus, Stilrichtung, Trainingsstil, Zielgruppe aus den Katalog-Dimensionen §1) pflegen. Die Roadmap-first-Pipeline matcht Übungen pro Stufe; Didaktik und Reihenfolge kommen aus Roadmap + QS, nicht aus Technik-Hardcoding. **Geplant (H1):** Katalog-Dimensionen zusätzlich als **Prompt-Snippets** in LLM-Aufrufen (Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`**. Technische Details: **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**. Für **Trainingsplanung** (Einheit, Abschnitt, Rahmen-Slot) gelten dieselben Katalog- und Retrieval-Bausteine mit anderen Scopes — Phase G, siehe Roadmap **`PLANNING_KI_ROADMAP.md`**.
|
||||||
|
|
||||||
### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009)
|
### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009)
|
||||||
|
|
||||||
**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR‑010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR‑013**).
|
**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR‑010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR‑013**).
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ Ausführliche fachliche Inhalte:
|
||||||
| [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§ 10.4)**, kanonische Archetyp-IDs **§ 10.2.1**, **Anhang A** Implementierungsabgleich |
|
| [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§ 10.4)**, kanonische Archetyp-IDs **§ 10.2.1**, **Anhang A** Implementierungsabgleich |
|
||||||
| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand |
|
| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand |
|
||||||
| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
|
| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
|
||||||
|
| [**KI-Unterstützung Übungen (Vision)**](./AI_EXERCISE_ASSISTANT_VISION.md) | Zielbild Zielausbau, Vorschlags-UX (teilweise/komplett), Skills/Varianten, später Planungskontext, Admin-Masse/Qualität |
|
||||||
|
| [**KI Übungen – Umsetzungsplan**](../working/AI_EXERCISE_IMPLEMENTATION_PLAN.md) | Stufen S0–S6, Driftschutz-Regeln, Checkliste gegen Specs |
|
||||||
|
|
||||||
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
|
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
||||||
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
||||||
|
|
||||||
- Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht.
|
- Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht.
|
||||||
- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, Sichtbarkeit, Status).
|
- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, **Freigabelevel**, Status).
|
||||||
- **Filter-Chips** unter der Suchleiste; Klick entfernt einen Filter; Badge am Filter-Button = Anzahl Chips.
|
- **Filter-Chips** unter der Suchleiste; Klick entfernt einen Filter; Badge am Filter-Button = Anzahl Chips.
|
||||||
- **Kein Vollbild-Spinner** bei jeder Suche: nur noch **`listFetching`** — Suchfelder bleiben im DOM (**Fokus/Cursor** bleiben erhalten); Liste zeigt optional „Aktualisiere Treffer…“.
|
- **Kein Vollbild-Spinner** bei jeder Suche: nur noch **`listFetching`** — Suchfelder bleiben im DOM (**Fokus/Cursor** bleiben erhalten); Liste zeigt optional „Aktualisiere Treffer…“.
|
||||||
- **`<datalist>`** mit Titeln der aktuellen Treffer; **`autoComplete="on"`** für Browser-Vorschläge.
|
- **`<datalist>`** mit Titeln der aktuellen Treffer; **`autoComplete="on"`** für Browser-Vorschläge.
|
||||||
|
|
@ -76,14 +76,47 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Frontend – Übung bearbeiten (`ExerciseFormPage.jsx`)
|
## 6. Frontend – Übung bearbeiten (`ExerciseFormPageRoot.jsx`)
|
||||||
|
|
||||||
|
**Routing:** `/exercises/new`, `/exercises/:id/edit` — keine separaten Varianten-Routen.
|
||||||
|
|
||||||
|
### 6.1 Tab-Navigation (Registerkarten)
|
||||||
|
|
||||||
|
Horizontale **`PageSectionNav`** über **`ExerciseFormTabBar`** / **`ExerciseFormPanel`** (`ExerciseFormLayout.jsx`); farbige linke Panel-Ränder (CSS `.exercise-form-edit`, `.exercise-form-panel--*`).
|
||||||
|
|
||||||
|
| Tab | Inhalt |
|
||||||
|
|-----|--------|
|
||||||
|
| **Stammdaten** | Titel, Kurztext, Dauer/Gruppe, Equipment, **Freigabelevel** (`visibility`), Status, Verein |
|
||||||
|
| **Anleitung** | Ziel, Durchführung, Vorbereitung, Trainerhinweise (Rich-Text inkl. Inline-Medien) |
|
||||||
|
| **Einordnung** | Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen, Altersgruppen, **Fähigkeiten** (kompakte Chip-Editoren) |
|
||||||
|
| **Kombination** | nur bei `exercise_kind=combination`: Slots, Archetyp, `method_profile` |
|
||||||
|
| **Varianten** | nur nach erstem Speichern; **nicht** bei Kombinationsübungen |
|
||||||
|
| **Medien & Mehr** | Medien, Progressionsgraph, KI-Hilfen, Löschen — nach erstem Speichern |
|
||||||
|
|
||||||
|
Neue Übungen: Tabs **Varianten** und **Medien & Mehr** deaktiviert bis zur ersten Speicherung.
|
||||||
|
|
||||||
|
### 6.2 Freigabelevel (UI-Begriff)
|
||||||
|
|
||||||
|
Feld **`exercises.visibility`** heißt in der UI durchgängig **Freigabelevel** (`frontend/src/constants/exerciseGovernanceLabels.js`) — Liste, Filter, Bulk, Picker, Formular. API/DB-Feldname **`visibility`** unverändert.
|
||||||
|
|
||||||
|
### 6.3 Fähigkeiten am Übungsobjekt
|
||||||
|
|
||||||
|
- Intensität je Fähigkeit: **`niedrig` \| `mittel` \| `hoch`**, Standard **`mittel`** (`exerciseSkillIntensity.js`).
|
||||||
|
- Kein „Primär“-Schalter mehr in der UI; **`is_primary`** bei `exercise_skills` ist Legacy — Backend speichert immer **`false`**, Scoring ignoriert das Feld.
|
||||||
|
- Kompakte **Chip-Editoren** für Katalog-Zuordnungen und Fähigkeiten (`ExerciseCatalogAssocEditor`, `ExerciseSkillsEditor`).
|
||||||
|
|
||||||
|
### 6.4 Varianten-Editor
|
||||||
|
|
||||||
|
- Tab **Varianten**: **eine Variante zur Zeit** (Dropdown oder „Erste Variante anlegen“); Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Löschen pro Variante.
|
||||||
|
- **Speichern über Aktionsleiste:** `performSaveAttempt` ruft zuerst **`persistPendingVariantChanges()`** auf (geänderte Varianten per PUT, danach optional Entwurf **`createVariantFromDraft()`**).
|
||||||
|
- Button **„Variante anlegen“** (`type="button"`, kein verschachteltes `<form>`): legt Entwurf sofort per API an; alternativ mitgesichert über **Speichern** in der Aktionsleiste.
|
||||||
|
- Snapshot **`variantsSavedSnapshotRef`** für Dirty-Erkennung; Hinweis im Panel: Änderungen werden mit Speichern in der Aktionsleiste mitgesichert.
|
||||||
|
|
||||||
|
### 6.5 Medien & Progressionsgraph
|
||||||
|
|
||||||
- **Varianten-Editor**: eingeklappter Bereich (`<details>`), **eine Variante zur Zeit** über Dropdown oder „Neue Variante“; Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Speichern/Löschen pro Variante.
|
|
||||||
- **Medien:** Upload/Embed, **Archiv verknüpfen** (`from-asset`), Medienliste mit Vorschau, Reaktivierung bei Archiv-Konflikt — Details **§12**.
|
- **Medien:** Upload/Embed, **Archiv verknüpfen** (`from-asset`), Medienliste mit Vorschau, Reaktivierung bei Archiv-Konflikt — Details **§12**.
|
||||||
- Block **Progressionsgraph** (Edit): Kanten mit Bezug zur aktuellen Übung.
|
- Block **Progressionsgraph** (Edit): Kanten mit Bezug zur aktuellen Übung.
|
||||||
|
|
||||||
Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Bearbeitung erfolgt unter **`/exercises/:id/edit`** (Routing-Doku ggf. anpassen).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Frontend – Übung Detail (`ExerciseDetailPage.jsx`)
|
## 7. Frontend – Übung Detail (`ExerciseDetailPage.jsx`)
|
||||||
|
|
@ -192,7 +225,21 @@ Norm: **`technical/SKILL_SCORING_SPEC.md`**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. Verweise
|
## 16. Übungen – Governance & Berechtigungen (Ist, Stand 2026-05-20)
|
||||||
|
|
||||||
|
**Owner:** `exercises.created_by` (Ersteller). **Varianten** haben kein eigenes `created_by` — Rechte leiten sich von der Eltern-Übung ab.
|
||||||
|
|
||||||
|
| Aktion | `private` | `club` | `official` |
|
||||||
|
|--------|-----------|--------|------------|
|
||||||
|
| **Lesen** | Ersteller; Plattform-Admin | Aktive Vereinsmitglieder des Objekt-`club_id`; Plattform-Admin ohne Mitgliedschaft (Audit) | Plattform-weit |
|
||||||
|
| **Bearbeiten** (Übung inkl. Varianten/Medien) | Ersteller; Plattform-Admin | Ersteller; Plattform-Admin; **`can_plan_in_club`** im Objekt-Verein (`trainer`, `content_editor`, `division_lead`, `club_admin`) | Plattform-Admin |
|
||||||
|
| **Löschen** | Ersteller; Vereins-Admin gemeinsamer Vereine mit Ersteller | Nur **`club_admin`** im Objekt-Verein | Nur Plattform-Admin |
|
||||||
|
|
||||||
|
**Code:** `backend/club_tenancy.py` (`exercise_visible_to_profile`, `can_plan_in_club`), `backend/routers/exercises.py` (`_assert_can_edit_exercise`, `_assert_can_delete_exercise`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Verweise
|
||||||
|
|
||||||
| Thema | Dokument |
|
| Thema | Dokument |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
|
|
|
||||||
|
|
@ -79,16 +79,18 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
||||||
|
|
||||||
### Stufe E – Capabilities dokumentieren (ohne UI für Custom Roles)
|
### Stufe E – Capabilities dokumentieren (ohne UI für Custom Roles)
|
||||||
|
|
||||||
- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `content.share_club`, `planning.edit_unit`, `org.manage_members`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen.
|
- **Verbindliche Spez v1:** `CAPABILITY_CATALOG.v1.md` — Capability-IDs, Account-Lifecycle, Rollen-Matrix, Endpoint-Mapping.
|
||||||
|
- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `exercises.ai.suggest`, `org.members.manage`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen (siehe Katalog §5–6).
|
||||||
- Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen – keine zweite Philosophie.
|
- Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen – keine zweite Philosophie.
|
||||||
|
|
||||||
### Stufe F – Community (eigenes Epic)
|
### Stufe F – Community (eigenes Epic)
|
||||||
|
|
||||||
- Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen.
|
- Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen.
|
||||||
|
|
||||||
### Zurückgestellt – Vereinsabo / Limits
|
### Zurückgestellt – Vereinsabo / Limits (Konzept liegt vor)
|
||||||
|
|
||||||
- Wiederöffnen wenn ACCESS_LAYER Stufe C/D stabil; dann Enforcement vor ausgewählten Writes an einen Billing-Stripe binden.
|
- **Spez v1:** `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` — Feature-Registry (Mitai-v9c-Pattern), `club_plans`/`club_subscriptions`, Kontingente an `club_id`.
|
||||||
|
- Implementierung/Billing (Stripe) weiter zurückgestellt; Schema- und Enforcement-Hooks gemäß 4-Phasen-Rollout (Mitai-Vorbild) vorbereiten, sobald Stufe C/D stabil.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -117,10 +119,28 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
||||||
|
|
||||||
## 7. Referenzen
|
## 7. Referenzen
|
||||||
|
|
||||||
|
- **`CAPABILITY_CATALOG.v1.md`** – Rollen, Capabilities, CRUD-Mapping, `GET /api/me/entitlements`.
|
||||||
|
- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** – Vereinsabo, Feature-Limits, Mitai-Mapping, Ziel-Schema.
|
||||||
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe.
|
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe.
|
||||||
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
|
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
|
||||||
- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
|
- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, `can_plan_in_club`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2026-05-07
|
## 8. Anhang – Übungen (Ist-Implementierung, Referenz)
|
||||||
|
|
||||||
|
**Stand:** 2026-05-20 · **Detail:** `EXERCISES_API_SPEC.md` Permissions, `FEATURES_DELIVERED_2026-Q2.md` §16
|
||||||
|
|
||||||
|
| Feld / Konzept | Semantik |
|
||||||
|
|----------------|----------|
|
||||||
|
| `created_by` | Owner der Übung; Varianten erben Rechte |
|
||||||
|
| `visibility` | UI: **Freigabelevel** — `private` \| `club` \| `official` |
|
||||||
|
| Lesen | `exercise_visible_to_profile` — `official` global; `private` Ersteller + Plattform-Admin; `club` aktive Mitglieder (+ Plattform-Admin Audit) |
|
||||||
|
| Bearbeiten | Ersteller; Plattform-Admin; bei `club` zusätzlich `can_plan_in_club` (Trainer, Content-Editor, Spartenleitung, Vereins-Admin) |
|
||||||
|
| Löschen | `official` → Plattform-Admin; `club` → `club_admin` im Objekt-Verein; `private` → Ersteller oder Vereins-Admin mit gemeinsamem Verein |
|
||||||
|
|
||||||
|
**Hinweis:** Dieser Anhang dokumentiert den **produktiven Code-Pfad** in `exercises.py`; die Roadmap in §4 bleibt für die langfristige Vereinheitlichung aller Bibliotheksartefakte maßgeblich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Letzte Aktualisierung:** 2026-05-20
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
# KI-Prompt-System – Universelle Admin-Konfiguration
|
# KI-Prompt-System – Universelle Admin-Konfiguration
|
||||||
|
|
||||||
**Version:** 1.0
|
**Version:** 1.1
|
||||||
**Datum:** 2026-04-24
|
**Datum:** 2026-05-30
|
||||||
**Status:** DRAFT
|
**Status:** Kern umgesetzt (`ai_prompts`, `prompt_resolver`, Superadmin-HTTP-API); Kaskaden geplant (Abschnitt 8)
|
||||||
|
|
||||||
|
**Zielbild (Roadmap):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Planung/Rahmen, Phasenplan.
|
||||||
|
|
||||||
|
**Ist-Stand API (Superadmin):**
|
||||||
|
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
|
||||||
|
- Spalte **`openrouter_model`** (Migration **070**): Optional pro Prompt-Zeile; OpenRouter **`model`**-Parameter; **`NULL`/leer ⇒ `OPENROUTER_MODEL`** aus der Umgebung.
|
||||||
|
|
||||||
**Autor:** Claude Code
|
**Autor:** Claude Code
|
||||||
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
||||||
|
|
||||||
|
**Verwandt (Skill-Katalog in Übungs-KI):** `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md` — Tabelle **`ai_skill_retrieval_profiles`** (`config`-JSON ergänzt inhaltliche Prompt-/Katalog-Steuerung neben Platzhaltern).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Konzept
|
## 1. Konzept
|
||||||
|
|
@ -28,6 +37,7 @@ steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.
|
||||||
|-------------|-----------|
|
|-------------|-----------|
|
||||||
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
|
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
|
||||||
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
|
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
|
||||||
|
| `exercise_instruction_rewrite` | Überarbeitet Anleitung: goal, execution, preparation, trainer_notes (JSON, prägnantes HTML) |
|
||||||
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
|
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
|
||||||
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
|
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
|
||||||
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
|
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
|
||||||
|
|
@ -174,10 +184,9 @@ Wähle maximal 5 passende Fähigkeiten. Für jede gib an:
|
||||||
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
|
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
|
||||||
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
|
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
|
||||||
- intensity: Trainingsintensität (niedrig|mittel|hoch)
|
- intensity: Trainingsintensität (niedrig|mittel|hoch)
|
||||||
- is_primary: true wenn Hauptfähigkeit
|
|
||||||
|
|
||||||
Antworte NUR als JSON-Array:
|
Antworte NUR als JSON-Array:
|
||||||
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch"}]
|
||||||
|
|
||||||
Wenn keine Fähigkeit passt, antworte mit [].$$,
|
Wenn keine Fähigkeit passt, antworte mit [].$$,
|
||||||
'exercise', 'json', true, NULL, 2),
|
'exercise', 'json', true, NULL, 2),
|
||||||
|
|
@ -597,6 +606,19 @@ AI_PROMPT_SYSTEM_SPEC: ai_service.run_ai_prompt("exercise_summary", ...)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version:** 1.0
|
## 8. Prompt-Kaskaden (geplant — nicht implementiert)
|
||||||
**Datum:** 2026-04-24
|
|
||||||
**Status:** DRAFT
|
**Ziel:** Vorlagen, die andere Prompts einbinden oder in feste Stufen (System → Fach → Ausgabeformat) zerlegt werden — ohne die DB-Templates mit duplizierten Fliesstexten zu zersplittern.
|
||||||
|
|
||||||
|
**Konzeptskizze:**
|
||||||
|
- Optional neues Feld `base_slug` oder eigene Tabelle `ai_prompt_composition` (Reihenfolge, Rolle: `system|user|prepend`).
|
||||||
|
- Platzhaltersyntax z. B. `{{include_prompt:slug}}` mit **maximaler Verschachtelungstiefe** und Zykluserkennung.
|
||||||
|
- Auflösungsreihenfolge: (1) eingebundene Slugs expandieren, (2) Kontext-Variablen wie heute ersetzen.
|
||||||
|
|
||||||
|
Bis zur Umsetzung bleiben zusammengesetzte Anweisungen im **einen** Template pro Slug (wie `exercise_skill_suggestions` mit `{{skills_catalog}}`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.1
|
||||||
|
**Datum:** 2026-05-30
|
||||||
|
**Status:** Teile umgesetzt (DB 067/069, Resolver, Superadmin-API + UI); Kaskaden offen
|
||||||
|
|
|
||||||
166
.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
Normal file
166
.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# KI-Prompt-System — Zielarchitektur (Shinkan Jinkendo)
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Datum:** 2026-05-30
|
||||||
|
**Status:** VERBINDLICHE ZIELRICHTUNG (Roadmap — nicht alles bereits umgesetzt)
|
||||||
|
**Ergänzt:** `AI_PROMPT_SYSTEM_SPEC.md` (aktueller Ist-Stand APIs/DB/UI), Mitai-Anleihen aus gleichnamigen Konzepten (Admin-Prompts, Platzhalter)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zweck
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt das **Zielbild**, damit spätere Arbeiten (**Trainingsplanung**, **mehrstufige Rahmenprogramme**, **Phasen/Streams**, weitere KI-Artefakte) **nicht** zu wiederholten Refaktoren von Übungs-KI oder OpenRouter-Anbindung zwingen.
|
||||||
|
|
||||||
|
**Leitkriterien:** wenige stabile Schnittflächen, Kontext pro Domäne, komponierbare Prompts, gültige Ausgaben, Betrieb ohne Code-Deploy für kleine Tweaks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Leitprinzipien
|
||||||
|
|
||||||
|
### 2.1 Eine stabile Ausführungsschicht
|
||||||
|
|
||||||
|
Alle produktiven KI-Aufrufe sollten mittelfristig über eine **einheitliche Fassade** laufen:
|
||||||
|
|
||||||
|
- **Eingabe:** `slug` (+ optional Kontext-Arten-Enum), **serialisierter Domän-Kontext** (Pydantic pro Kind), Konfiguration (Modell, Temperatur, … aus Env/DB).
|
||||||
|
- **Ausgabe:** Text oder validiertes JSON, Metadaten (`model`, `slug`, ggf. `prompt_version`/Hash), strukturierte Fehler.
|
||||||
|
|
||||||
|
Router und Frontend rufen diese Schicht oder schmale Orchestratoren — **nicht** direkt `httpx`/OpenRouter an jeder Ecke verteilt.
|
||||||
|
|
||||||
|
**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` (`load_ai_prompt_row`, `load_and_render_ai_prompt`, Kontext-Arten) sowie `backend/ai_prompt_job.py` (Pydantic `ExerciseFormAiPromptContext` fuer Uebungs-Prompts — Admin-Vorschau + erweiterbare Router-Nutzung); `exercise_ai` orchestriert OpenRouter nach dem Rendern.
|
||||||
|
|
||||||
|
### 2.2 Trennung: Semantik vs. Transport
|
||||||
|
|
||||||
|
- **Semantik:** Was soll das Modell liefern? Das hängt an **Prompt-Definition**, **Ausgabeformat** (`text`/`json`) und nachvollziehbarer Validierung — nicht am HTTP-Client.
|
||||||
|
- **Transport:** OpenRouter, Modellwahl, Retry, Timeouts bleiben in einem oder wenigen Hilfsmodulen.
|
||||||
|
|
||||||
|
### 2.3 Kontext-Namespaces für Platzhalter
|
||||||
|
|
||||||
|
Platzhalter und erlaubte Keys sind **pro logischer Kontext-Art** definiert, z. B.:
|
||||||
|
|
||||||
|
- `exercise_form_ai` — heute: Übungsformular-Vorschläge.
|
||||||
|
- später: `training_unit`, `framework_program_slot`, `import_wiki`, …
|
||||||
|
|
||||||
|
Damit kann der Katalog wachsen, ohne dass alle Keys in einen globalen Soup-Namespace müssen (`exercise_*` vs. `framework_*` ohne Kollisionen). Optional später **präfixierte** Keys (`exercise.title`, `slot.index`).
|
||||||
|
|
||||||
|
### 2.4 Komposition / Kaskade explizit
|
||||||
|
|
||||||
|
**Ziel:** Mehrteilige Prompts („System“–„Nutzer“–Anhänge) und **Einbindung anderer Vorlagen** als **Daten** (Kompositionsmodell), nicht nur als unbearbeiteter Freitext mit `{{include}}`.
|
||||||
|
|
||||||
|
Skizzen (noch nicht vollständig umgesetzt):
|
||||||
|
|
||||||
|
- Tabelle oder JSON-Spalte `composition`/`ai_prompt_segments`: geordnete Segmente mit `role` (`system` \| `user` \| äquivalent zum jeweiligen API-Shape), Quelle (`inline`, `ref_slug`), optional `ref_slug`, Schema-Version.
|
||||||
|
- Einbindungen mit **Maximaltiefe** und **Zykluserkennung** — keine unbegrenzten Makro-Ketten.
|
||||||
|
|
||||||
|
Bis dahin bleiben zusammenhängende Anweisungen in **einem** DB-Template pro Slug tragbar (`exercise_skill_suggestions` + `{{skills_catalog}}` bleiben gültig).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Zieldatenmodell (Schichten)
|
||||||
|
|
||||||
|
### 3.1 Definition (`ai_prompts` — bereits vorhanden, evolviert)
|
||||||
|
|
||||||
|
| Konzept | Bedeutung |
|
||||||
|
|--------|-----------|
|
||||||
|
| `slug`, `category`, `output_format`, `active` | Adressierung & Schalter |
|
||||||
|
| `template` | aktueller Inhalt |
|
||||||
|
| `default_template` | Referenz zum Zurücksetzen (Migration **069**) |
|
||||||
|
| `output_schema` (JSONB) | optional: JSON-Outputs validieren |
|
||||||
|
|
||||||
|
**Ausbaustufen:**
|
||||||
|
|
||||||
|
1. Nur `template`-Text (**heute**, plus Mustache über `prompt_resolver`).
|
||||||
|
2. Zusätzlich **Versionierung**: Historie oder `template_version`/Audit (wer hat wann geändert).
|
||||||
|
3. **Segmentierte Composition** wie in Abschnitt 2.4.
|
||||||
|
|
||||||
|
### 3.2 Kontext-Builder pro Domäne
|
||||||
|
|
||||||
|
Pro **Kontext-Art** eine klar genannte Routine (Pattern: registrierbare Builder):
|
||||||
|
|
||||||
|
| Kontext-Art | Beispiel-Input aus der App | Beispiel-Platzhalter / Daten |
|
||||||
|
|-------------|----------------------------|------------------------------|
|
||||||
|
| `exercise_form_ai` | Titel, Ziel/Durchführung (HTML→Plain), Fokuskontext, Retrieval-Profil-Influenza | `exercise_*`, `skills_catalog` |
|
||||||
|
| `training_unit` (geplant) | Sektionen, Zeiten, Phasen/Streams, verknüpfte Übungs-IDs | `unit_*`, `sections_summary_*` |
|
||||||
|
| `framework_program` (geplant) | Ziele pro Woche/Schicht, Slots, bereits geplante Einheiten, Skill-Scores | `framework_*`, `slot_*`, aggregierte KPIs |
|
||||||
|
|
||||||
|
**Regel:** Planungs-UI baut keine Prompt-Strings; sie liefert **Domän-DTOs** → Builder erzeugen **Platzhalter-Map + ggf. Anhänge**.
|
||||||
|
|
||||||
|
### 3.3 Skill-Retrieval und Prompts
|
||||||
|
|
||||||
|
`ai_skill_retrieval_profiles` steuert **Katalog‑Zusammenstellung** vor dem Platzhalter `{{skills_catalog}}` — das bleibt **orthogonal** zur Prompt-Verwaltung: Prompt ändert *Anweisung*, Profil ändert *welche Skills im Kontextfenster sind*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Trainingsplanung & Rahmen — erwartete Komplexität
|
||||||
|
|
||||||
|
Risiken: sehr große Kontexte (viele Slots, Streams, Bibliotheken), wiederholte KI-Anfragen, Token-Limits.
|
||||||
|
|
||||||
|
**Vorbereitende Strategien:**
|
||||||
|
|
||||||
|
1. **Gestufte Kontexte:** Rohdaten → interne Kurzfassungen (optional zweiter Prompt oder heuristisch) → finale Generator-Prompt nur mit komprimierten Summaries.
|
||||||
|
2. **Slug-Pro-Use-Case:** z. B. `training_unit_trainer_notes`, `framework_slot_coach_hint` — jeweils schmaler Vertrag statt „ein Prompt für alles“.
|
||||||
|
3. **Output-Verträge:** JSON-Schema + Server-Validierung vor UI; Fehlermeldungen mit Referenz auf Slug/Version.
|
||||||
|
4. **Feature-Flags / Modell-Overrides** pro Slug (optional in DB oder Env) für Dev/Prod ohne große Codepfade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Mitai (Jinkendo)
|
||||||
|
|
||||||
|
Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter, Preview), **andere** Kontext-Builder und ggf. andere Mandanten/Overlays. Eine gemeinsame **Resolver-/Mustache-Ebene** ist wünschenswert; **Shinkan-spezifische** Planungs- und Rahmenkontexte bleiben in Shinkan gekapselt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Betrieb, Sicherheit, Observability
|
||||||
|
|
||||||
|
- **Audit:** `updated_by` / Änderungshistorie für Templates (Backlog), heute: Timestamps.
|
||||||
|
- **Prompt-Injection:** System-/User-Segmente trennen; sensible Regeln in `system`/`developer`-äquivalenten Blöcken (wenn API das hergibt).
|
||||||
|
- **Logging:** weiter `SHINKAN_AI_DEBUG`; langfristig Hash/Länge des **aufgelösten** Prompts pro Request (ohne Secrets).
|
||||||
|
- **Kosten/Latenz:** Timeouts, max. Token-Hinweise pro Slug-Konfiguration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phasenplan (empfohlen, ohne Big-Bang)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph laufzeit
|
||||||
|
A[ai_prompts DB]
|
||||||
|
B[prompt_resolver Mustache]
|
||||||
|
C[ai_prompt_runtime]
|
||||||
|
J[ai_prompt_job Pydantic]
|
||||||
|
D[exercise_ai OpenRouter]
|
||||||
|
end
|
||||||
|
A --> C
|
||||||
|
C --> B
|
||||||
|
J --> D
|
||||||
|
C --> D
|
||||||
|
B --> D
|
||||||
|
```
|
||||||
|
|
||||||
|
| Phase | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. |
|
||||||
|
| **P1** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **`ExerciseFormAiPromptContext`** in `ai_prompt_context.py`; **`run_exercise_form_ai_suggestion`**; Übungs-API und Admin-Vorschau nutzen denselben Kontext. |
|
||||||
|
| **P2** | Versionierung oder Audit-Spalten; **teilweise:** optionales OpenRouter-Modell pro Zeile (`openrouter_model`, Migration 070, Fallback `OPENROUTER_MODEL`); weitere Overrides (Temperatur) offen. |
|
||||||
|
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
|
||||||
|
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Was bewusst vermieden werden soll
|
||||||
|
|
||||||
|
- Vollständige „Workflow-Engine“ mit beliebigen Graphen, bevor 2–3 konkrete Planungs-Anwendungsfälle live sind.
|
||||||
|
- Pro-Verein-Prompt-Kopien vor klar definierter Produkt-Anforderung (sonst Daten- und Pflege-Spirale).
|
||||||
|
- Unbegrenzte `include`-rekursive Textmakros ohne Tiefenschutz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Querverweise
|
||||||
|
|
||||||
|
- Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
|
||||||
|
- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||||
|
- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`
|
||||||
|
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_context.py`, `backend/ai_prompt_job.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.0 · **Datum:** 2026-05-30
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
# KI-gestützte Trainingsplanung – Zentrales Konzept
|
# KI-gestützte Trainingsplanung – Zentrales Konzept
|
||||||
|
|
||||||
**Version:** 0.1
|
**Version:** 0.3
|
||||||
**Datum:** 2026-05-16
|
**Datum:** 2026-05-22
|
||||||
**Status:** Arbeitsdokument (Verfeinerung durch fachliche Konzept-Agentur vorgesehen)
|
**Status:** Arbeitsdokument (Verfeinerung durch fachliche Konzept-Agentur vorgesehen)
|
||||||
**Ziel:** Einheitlicher Rahmen für **stufenweise** KI-Unterstützung bei der Planung (Abschnitte, Einheiten, später mehrtägig/Rahmen) – ohne vollständigen Katalog im Prompt zu spiegeln.
|
**Ziel:** Einheitlicher Rahmen für **stufenweise** KI-Unterstützung – zuerst **Übungsanlage** (Zusammenfassung, Fähigkeiten, Texte), später **Planung** (Abschnitte, Einheiten, Rahmen) – ohne vollständigen Übungskatalog im Prompt.
|
||||||
|
|
||||||
|
**Maßgebende Version zum Abgleich:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, relevante Einträge in `MODULE_VERSIONS`).
|
||||||
|
|
||||||
**Verwandte Dokumente:**
|
**Verwandte Dokumente:**
|
||||||
`functional/DOMAIN_MODEL.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (u. a. CURR-003 zu Progressions-/KI-Automatik) · `technical/TRAINING_FRAMEWORK_SPEC.md` · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `docs/FACHLICHE_NUTZERFUNKTIONEN.md` · `docs/HANDOVER.md`
|
`functional/DOMAIN_MODEL.md` · **`functional/AI_EXERCISE_ASSISTANT_VISION.md`** (Übungs-KI: Zielbild vor Planungs-KI) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (u. a. CURR-003 zu Progressions-/KI-Automatik) · **`working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md`** (mehrstufige Planungs-KI: Daten-„Graph“, Pipeline-Stufen, Code-Schnitte – Vorschau gegen späteres Refactoring) · `technical/TRAINING_FRAMEWORK_SPEC.md` · **`technical/SKILL_SCORING_SPEC.md`** (Fähigkeits-Profilierung, Discovery) · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/SKILLS_MATRIX_SPEC.md` · `docs/FACHLICHE_NUTZERFUNKTIONEN.md` · `docs/HANDOVER.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -16,13 +18,30 @@
|
||||||
- **Human-in-the-loop:** KI liefert **Vorschläge** (Liste, Reihenfolge, Begründung); schreibende Übernahme in Pläne nur nach **Trainer-Bestätigung** oder expliziter Aktion (analog „Manual First“ in `KI_FEATURES_SPEC.md`).
|
- **Human-in-the-loop:** KI liefert **Vorschläge** (Liste, Reihenfolge, Begründung); schreibende Übernahme in Pläne nur nach **Trainer-Bestätigung** oder expliziter Aktion (analog „Manual First“ in `KI_FEATURES_SPEC.md`).
|
||||||
- **Governance-first:** Nur Übungen, die die API bereits für den Mandanten/Kontext **sichtbar** freigibt, dürfen in Kandidatenlisten landen – **vor** Retrieval und **vor** jedem Prompt.
|
- **Governance-first:** Nur Übungen, die die API bereits für den Mandanten/Kontext **sichtbar** freigibt, dürfen in Kandidatenlisten landen – **vor** Retrieval und **vor** jedem Prompt.
|
||||||
|
|
||||||
|
### 1.1 Abgleich: aktueller Code- und Schema-Stand (Stand Review 2026-05-22)
|
||||||
|
|
||||||
|
| Thema | Ist im Repo | Konsequenz für dieses Konzept |
|
||||||
|
|--------|-------------|-------------------------------|
|
||||||
|
| **OpenRouter / LLM im Backend** | Produktiver Aufruf für Übungs‑Suggest in `openrouter_chat.py`, `exercise_ai.py`; Endpunkte **`POST …/exercises/ai/suggest`** und **`POST …/{id}/ai/regenerate`**; Migration **067** (`ai_prompts`, `summary_ai_generated`). **`db.py`**-Bootstrap nutzt **`display_name`**. | **Übungs-Assistent (P0)** vorhanden; generalisierter Service + **Planungs-KI** folgen. |
|
||||||
|
| **Übungs-KI laut Spec** | P0: Kurzfassung + Skill‑Vorschläge (`include_summary` / `include_skills`); **kein** Auto-KI beim Speichern (S5 im Umsetzungsplan). | Feinspez: `summary_ai_generated` bei manueller Kurzfassung zurücksetzen; Rate-Limits; Prompt-Admin-UI. |
|
||||||
|
| **Fähigkeiten-Stammdaten** | Migration **`065_skills_wiki_karate_relevance`:** `skills.karate_relevance` (Text), `skills.relevance_level` (1–3, optional); dazu weiterhin `description`, `focus_areas`, Kategorien, `skill_level_definitions` (Level 1–5 je Skill). | Diese Felder sind **expliziter Prompt-Kontext** für Skill-Vorschläge (Disambiguierung Karate vs. universal) – siehe §6. |
|
||||||
|
| **Skill-Scoring & Discovery (ohne LLM)** | Router `skill_profiles.py` + Modul `skill_scoring.py`: u. a. `GET …/skill-profile` für **Rahmenprogramm**, **Trainingsmodul**, **Progressionsgraph**; `POST /skill-profiles/batch-summaries`; **`GET /api/skill-discovery/suggestions`** (Match Bibliotheksartefakte ⇄ `skill_ids`, mit `library_content_visibility_sql`). | Ergänzt §3 **Stufe 3**: deterministische **Skill-Abdeckung / Artefakt-Discovery** ist **bereits vorhanden** und kann später die **Planungs-KI** speisen (Ziel-Skill-Mengen, Vergleich „Profil des Rahmens“) – ersetzt aber **nicht** die Top‑K-Selektion aus dem **Übungskatalog** für eine konkrete Session. |
|
||||||
|
| **Profil / Planungs-Präferenzen** | `profiles.training_planning_prefs` (JSONB, vgl. `MODULE_VERSIONS` → `profiles`), Planungsmodul mit u. a. **Vorlagen inkl. Split-Sessions** (`planning`), `training_units` mit **Publish in Rahmen-Slot-Blueprint**. | Zukünftige KI-Planung kann **Prefs** und **Vorlagen-Struktur** als weiche Constraints einbeziehen; Rahmen↔Einheit-Fluss ist produktiv erweitert – für KI nur relevant, sobald Planungs-Endpunkte angebunden werden. |
|
||||||
|
| **Übungsliste API** | Keyset-Pagination u. a. `cursor_updated_at` + Tie-break `id` (`exercises`-Modul laut `MODULE_VERSIONS`). | Retrieval-Pipelines sollten **cursorbasiert** paginieren, nicht „alle IDs auf einmal“ laden. |
|
||||||
|
|
||||||
|
**Nächster produktiver Fokus:** Prompt-/Admin‑UI zur Pflege von `ai_prompts`, **Rate-Limits**, optional **Auto-KI beim Speichern**; danach Übergang zur **Planungs-KI** laut diesem Dokument.
|
||||||
|
|
||||||
|
**Architektur-Vorschau (Planungs-KI):** Damit die **kleinere, starre** Übungs-Pipeline nicht zur stillen Vorlage für Planung wird, sind **eigenes Modul**, **stufenweise Outputs mit Validierung** und ein **kompaktes Kontext-DTO** vorgesehen — siehe **`working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md`**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Kernproblem: Skalierung des Kontextes
|
## 2. Kernproblem: Skalierung des Kontextes
|
||||||
|
|
||||||
Aus einer **großen Übungssammlung** („>1000 Übungen“) können weder alle **Felder** (Ziele, Ablauf, Skills, Varianten …) noch alle **Zeilen** sinnvoll in einen LLM-Prompt.
|
Aus einer **großen Übungssammlung** („>1000 Übungen“) können weder alle **Felder** (Ziele, Ablauf, Skills, Varianten …) noch alle **Zeilen** sinnvoll in einen LLM-Prompt.
|
||||||
|
|
||||||
**Festlegung:** Der LLM-Prompt erhält immer nur ein **begrenztes Kontext-Paket** mit:
|
**Abgrenzung Übungsanlage (aktueller Prioritätspfad):** Hier geht der Prompt typischerweise von **einzelnen** Freitexten (`title`, `goal`, `execution`, …) und einem **Skills-Katalog-Auszug** aus – nicht vom gesamten Übungsbestand. Trotzdem gilt: Aktive Skills **paginieren** oder **stufig** laden (Subset + zweite Runde nur für Kurzliste), keine vollständigen Romane aus `skill_level_definitions` für hunderte Fähigkeiten auf einmal.
|
||||||
|
|
||||||
|
**Festlegung (Planungs-KI):** Der LLM-Prompt erhält immer nur ein **begrenztes Kontext-Paket** mit:
|
||||||
|
|
||||||
| Paketteil | Zweck |
|
| Paketteil | Zweck |
|
||||||
|-----------|--------|
|
|-----------|--------|
|
||||||
|
|
@ -70,6 +89,7 @@ Mindestens **eine** der folgenden Optionen – kombinierbar:
|
||||||
2. **Diversitäts-/Wiederholungsstrafe:** häufig in letzten Wochen geübte Übungen abwerten.
|
2. **Diversitäts-/Wiederholungsstrafe:** häufig in letzten Wochen geübte Übungen abwerten.
|
||||||
3. **Textsuche:** PostgreSQL **`tsvector`/Volltext** auf `title`, `summary`, ggf. gekürzte `goal` – für Trainer-Stichwort „Koordination Sprung“.
|
3. **Textsuche:** PostgreSQL **`tsvector`/Volltext** auf `title`, `summary`, ggf. gekürzte `goal` – für Trainer-Stichwort „Koordination Sprung“.
|
||||||
4. **Semantische Suche:** Embeddings + **Ähnlichkeitsuche** auf Kurztexte (siehe §5).
|
4. **Semantische Suche:** Embeddings + **Ähnlichkeitsuche** auf Kurztexte (siehe §5).
|
||||||
|
5. **Skill-Discovery über Planungs-Artefakte (bereits implementiert):** `GET /api/skill-discovery/suggestions` matching **Bibliotheksartefakte** (u. a. Rahmenprogramm, Trainingsmodul, Progressionsgraph) gegen gegebene `skill_ids`; `GET …/skill-profile` liefert **gewichtete Fähigkeitsprofile** aus den dort verknüpften Übungen (siehe `SKILL_SCORING_SPEC.md`). Das ist ein **deterministischer** Baustein für „welche Artefakte passen zu diesen Skills?“, **nicht** der Ersatz für **Top‑K-Übung**-Auswahl in einer konkreten Session – dort weiter Stufen 1–2 + Punkte 1–4/LLM.
|
||||||
|
|
||||||
Ergebnis: sortierte Liste, **Top‑K** für den Prompt.
|
Ergebnis: sortierte Liste, **Top‑K** für den Prompt.
|
||||||
|
|
||||||
|
|
@ -128,7 +148,8 @@ Sinnvoller zeitlicher Punkt oder technische Auslöser:
|
||||||
|
|
||||||
Retrieval‑Qualität hängt stärker an **Metadaten** als an der Embedding-Technologie allein:
|
Retrieval‑Qualität hängt stärker an **Metadaten** als an der Embedding-Technologie allein:
|
||||||
|
|
||||||
- verlässliche **Skills** (`exercise_skills`, ggf. KI-geholfen bereits laut Spez beim Übungs-Anlegen);
|
- verlässliche **Skills** (`exercise_skills`, ggf. KI-geholfen bereits laut Spez beim Übungs-Anlegen); `exercise_skills.ai_suggested` und kanonische Stufen (`required_level` / `target_level` als Slugs) für Nachvollziehbarkeit.
|
||||||
|
- **`skills`-Stamm:** `description`, **`karate_relevance`**, **`relevance_level` (1–3)**, **`focus_areas`**, Kategorien/Keywords für **Prompt-Kontext** beim Skill-Mapping bei der Übungsanlage; optional **`skill_level_definitions`** für Stufen 1–5 **gezielt** in die zweite Prompt-Runde (nur Kurzliste Kandidaten).
|
||||||
- sinnvolle **`summary`**-Felder für Karten/Liste/KI-Pack;
|
- sinnvolle **`summary`**-Felder für Karten/Liste/KI-Pack;
|
||||||
- **Progressionsgraph** dort, wo pädagogische Ketten gefestigt sind;
|
- **Progressionsgraph** dort, wo pädagogische Ketten gefestigt sind;
|
||||||
- konsistente **Fokusbereich/Stil**-Zuordnung.
|
- konsistente **Fokusbereich/Stil**-Zuordnung.
|
||||||
|
|
@ -139,15 +160,18 @@ Das fachliche Konzept sollte entscheiden: **wie viel automatische Pflege vs. Tra
|
||||||
|
|
||||||
## 7. Produkt-/Release-Stufen (Anknüpfung)
|
## 7. Produkt-/Release-Stufen (Anknüpfung)
|
||||||
|
|
||||||
|
Priorität **jetzt**: **Übungsanlage**, danach **Planung**.
|
||||||
|
|
||||||
| Stufe | Nutzen | Technik-Schwerpunkt |
|
| Stufe | Nutzen | Technik-Schwerpunkt |
|
||||||
|-------|--------|---------------------|
|
|-------|--------|---------------------|
|
||||||
| A | Backend-KI-Service + Prompt-Slugs unter `technical/AI_PROMPT_SYSTEM_SPEC.md` | OpenRouter, Timeouts, 503 ohne Key |
|
| **A0** | **Zentraler KI-Service** (ein Modul/Hilfslayer), Prompts aus `ai_prompts` | OpenRouter oder vereinbarter Provider, Timeouts, `503` ohne Key, Parsing/Validation |
|
||||||
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 1–3 + Prompt mit Top‑K |
|
| **A1** | **Übungsanlage** (vgl. `KI_FEATURES_SPEC`): `summary`, Skill-Vorschläge inkl. Stufen/Intensität, optional Textglättung | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate`; Prompt-Kontext: Skills mit `description`, `karate_relevance`, `relevance_level`, optional `skill_level_definitions` für Kurzliste; DB: `summary_ai_generated`, `exercise_skills.ai_suggested` |
|
||||||
|
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 1–3 + Prompt mit Top‑K (Übungsliste **keyset-pagination** beachten) |
|
||||||
| C | Reihenfolge / Zeitslots innerhalb bestehender Sektion | Graph + LLM Ranking |
|
| C | Reihenfolge / Zeitslots innerhalb bestehender Sektion | Graph + LLM Ranking |
|
||||||
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertere JSON-Ausgabe, strikte Schema-Validation |
|
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertes JSON + strikte Schema-Validation gegen bestehende `PUT`-Payloads |
|
||||||
| E | Mehreinheiten / Rahmen‑Alignment | Ziele aus Rahmenprogramm, Serie von Slots |
|
| E | Mehreinheiten / Rahmen‑Alignment | Ziele aus Rahmenprogramm, Serie von Slots; **Skill-Profile** (`…/skill-profile`) als Kontextuelle Verstärker |
|
||||||
|
|
||||||
Die **Selektions‑Pipeline §3 bleibt** über alle Stufen konsistent und wird nur parametrierbar erweitert.
|
Die **Selektions‑Pipeline §3** bleibt für **Planungs**-KI konsistent und wird parametrierbar erweitert; **§1.1** spiegelt den **aktuellen Implementierungs**-Vorsprung (Skill-Scoring ohne LLM) wider.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
331
.claude/docs/technical/CAPABILITY_CATALOG.v1.md
Normal file
331
.claude/docs/technical/CAPABILITY_CATALOG.v1.md
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
# Capability-Katalog Shinkan v1
|
||||||
|
|
||||||
|
**Status:** Konzept (verbindliche Zieldefinition; M3 teilweise umgesetzt)
|
||||||
|
**Stand:** 2026-06-06
|
||||||
|
**Bezüge:** `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` (Stufe E), `MULTI_TENANCY_RBAC_ARCHITECTURE.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`** (Produktentscheidungen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zweck
|
||||||
|
|
||||||
|
Dieses Dokument definiert **benannte Capabilities** (Wer darf welche **Funktion** ausführen?) — getrennt von:
|
||||||
|
|
||||||
|
- **Governance** (Darf ich *dieses Objekt* lesen/ändern? → `visibility`, `club_id`, `created_by`)
|
||||||
|
- **Feature-Limits** (Wie viel darf der **Verein**? → `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`)
|
||||||
|
|
||||||
|
Capabilities beantworten: *„Darf ein Trainer mit Rolle X die Funktion Y im Verein Z überhaupt nutzen?“*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Namenskonvention
|
||||||
|
|
||||||
|
```
|
||||||
|
{domain}.{action}[.{qualifier}]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Segment | Beispiele |
|
||||||
|
|---------|-----------|
|
||||||
|
| `domain` | `exercises`, `media`, `planning`, `org`, `platform` |
|
||||||
|
| `action` | `read`, `create`, `update`, `delete`, `manage`, `execute` |
|
||||||
|
| `qualifier` | `ai.suggest`, `join_request`, `inbox.review` |
|
||||||
|
|
||||||
|
**CRUD-Mapping:**
|
||||||
|
|
||||||
|
| Aktion | Capability-Suffix | Bedeutung |
|
||||||
|
|--------|-------------------|-----------|
|
||||||
|
| Lesen (Listen/Detail) | `.read` | Navigation + API-Lesen erlaubt |
|
||||||
|
| Anlegen | `.create` | POST/INSERT |
|
||||||
|
| Bearbeiten | `.update` | PUT/PATCH (eigenes + berechtigtes Fremdes) |
|
||||||
|
| Löschen | `.delete` | DELETE (strenger als update) |
|
||||||
|
| Verwalten | `.manage` | Org-Funktionen, Freigaben, Mitglieder |
|
||||||
|
| Ausführen (ohne Persistenz) | `.execute` | z. B. KI-Vorschau, Coach-Lauf |
|
||||||
|
|
||||||
|
Objektbezogene Feinheiten (nur Ersteller, nur Vereinsadmin des Objekt-Vereins) bleiben in **Governance** — Capabilities sind das **Tür-Schloss** davor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Account-Lifecycle (Voraussetzung für Capabilities)
|
||||||
|
|
||||||
|
| `account_state` | Bedingung | Typische Capabilities |
|
||||||
|
|-----------------|-----------|------------------------|
|
||||||
|
| `anonymous` | Keine Session | nur öffentliche Routen (`/login`, Rechtstexte, `clubs/public-directory`) |
|
||||||
|
| `unverified` | Session, `email_verified=false` | `account.resend_verification`, `account.logout` |
|
||||||
|
| `verified_pending_club` | Verifiziert, keine aktive `club_members` | `club.join_request`, `club.creation_request` (M7), `account.settings` — **kein** Lesezugriff auf Domänen-Inhalte (siehe Entscheidungs-Doc §1.1) |
|
||||||
|
| `active_member` | Mind. eine aktive Vereinsmitgliedschaft | Domänen-Capabilities gemäß Vereinsrolle |
|
||||||
|
| `platform_admin` | `role` ∈ `admin`, `superadmin` | `platform.*` zusätzlich |
|
||||||
|
|
||||||
|
**Regel:** Domänen-Capabilities (`exercises.*`, `planning.*`, …) erfordern mindestens `active_member`, sofern nicht `platform_admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rollen-Scopes
|
||||||
|
|
||||||
|
### 4.1 Portal-Rollen (`profiles.role`)
|
||||||
|
|
||||||
|
| Rolle | Scope | Kurz |
|
||||||
|
|-------|-------|------|
|
||||||
|
| `user` | Portal | Standard nach Registrierung (Zielbild; heute oft `trainer` Legacy) |
|
||||||
|
| `trainer` | Portal | Legacy — mittelfristig durch `user` + Vereinsrollen ersetzen |
|
||||||
|
| `admin` | Portal | Plattform-Admin (Vereine anlegen, erweiterte Ops) |
|
||||||
|
| `superadmin` | Portal | Vollzugriff Plattform + Superadmin-Werkzeuge |
|
||||||
|
|
||||||
|
### 4.2 Vereinsrollen (`club_member_roles.role_code`)
|
||||||
|
|
||||||
|
| Rolle | Fachlich |
|
||||||
|
|-------|----------|
|
||||||
|
| `club_admin` | Vereinsorganisation, Mitglieder, Struktur |
|
||||||
|
| `trainer` | Planung, Übungen, Durchführung |
|
||||||
|
| `content_editor` | Inhalte pflegen (Bibliothek) |
|
||||||
|
| `division_lead` | Spartenleitung (später division-scope) |
|
||||||
|
|
||||||
|
Mehrfachrollen pro Mitgliedschaft sind möglich (OR-Verknüpfung der Capabilities).
|
||||||
|
|
||||||
|
### 4.3 Mapping heutiger Helfer → Capabilities
|
||||||
|
|
||||||
|
| Heutiger Code (`club_tenancy.py`) | Ziel-Capability-Cluster |
|
||||||
|
|-----------------------------------|-------------------------|
|
||||||
|
| `can_manage_club_org` | `org.structure.manage`, `org.members.manage`, `org.inbox.review` |
|
||||||
|
| `can_plan_in_club` | `planning.*`, `exercises.create/update`, `modules.*`, `framework.*` |
|
||||||
|
| `is_platform_admin` | `platform.*` (Bypass Mandant, Audit-Pflicht) |
|
||||||
|
| `is_superadmin` | `platform.superadmin.*` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Capability-Katalog (v1)
|
||||||
|
|
||||||
|
Legende Spalten:
|
||||||
|
|
||||||
|
- **Min. Account:** `verified_pending_club` | `active_member` | `platform_admin`
|
||||||
|
- **Vereinsrollen:** leer = alle aktiven Mitglieder; sonst mindestens eine Rolle
|
||||||
|
- **Feature-ID:** optionales Kontingent (siehe Club-Membership-Doc); leer = kein Limit
|
||||||
|
- **Governance:** zusätzliche Objektprüfung ja/nein
|
||||||
|
|
||||||
|
### 5.1 Account & Onboarding
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI |
|
||||||
|
|---------------|--------------|---------------|------------|----------------|
|
||||||
|
| `account.settings.read` | `unverified` | — | — | `GET /profiles/me`, Einstellungen |
|
||||||
|
| `account.settings.update` | `unverified` | — | — | `PUT /profiles/{id}` (eigenes Profil) |
|
||||||
|
| `account.password.change` | `unverified` | — | — | `PUT /api/auth/pin` |
|
||||||
|
| `account.resend_verification` | `unverified` | — | — | `POST /api/auth/resend-verification` |
|
||||||
|
| `club.directory.read` | `verified_pending_club` | — | — | `GET /clubs/public-directory` |
|
||||||
|
| `club.join_request.create` | `verified_pending_club` | — | — | `POST /me/club-join-requests`, Registrierung mit `requested_club_id` |
|
||||||
|
| `club.join_request.withdraw` | `verified_pending_club` | — | — | `DELETE /me/club-join-requests/{id}` |
|
||||||
|
| `club.join_request.read_own` | `verified_pending_club` | — | — | `GET /me/club-join-requests` |
|
||||||
|
|
||||||
|
### 5.2 Organisation (Verein)
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI |
|
||||||
|
|---------------|--------------|---------------|------------|----------------|
|
||||||
|
| `org.club.read` | `active_member` | * | — | `GET /clubs`, `GET /clubs/{id}` (eigene Vereine) |
|
||||||
|
| `org.club.create` | `platform_admin` | — | — | `POST /clubs` |
|
||||||
|
| `org.club.update` | `platform_admin` | `club_admin` | — | `PUT /clubs/{id}` |
|
||||||
|
| `org.club.delete` | `platform_admin` | — | — | `DELETE /clubs/{id}` |
|
||||||
|
| `org.structure.manage` | `active_member` | `club_admin` | `training_groups` | Sparten, Gruppen CRUD |
|
||||||
|
| `org.members.read` | `active_member` | `club_admin` | — | `GET /clubs/{id}/members` |
|
||||||
|
| `org.members.manage` | `active_member` | `club_admin` | `active_members` | POST/PUT/DELETE Mitglieder |
|
||||||
|
| `org.members.directory` | `active_member` | * | — | `GET /clubs/{id}/members/directory` (ohne E-Mail für Nicht-Admins) |
|
||||||
|
| `org.join_request.review` | `active_member` | `club_admin` | — | Join-Request accept/reject, Inbox |
|
||||||
|
| `org.inbox.read` | `active_member` | `club_admin` | — | Posteingang Join + Content-Reports |
|
||||||
|
|
||||||
|
### 5.3 Übungen
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `exercises.read` | `active_member` | * | — | ja (visibility) |
|
||||||
|
| `exercises.create` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | `exercises` | — |
|
||||||
|
| `exercises.update` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | — | ja |
|
||||||
|
| `exercises.delete` | `active_member` | `club_admin` (+ Ersteller privat) | — | ja |
|
||||||
|
| `exercises.bulk_metadata` | `active_member` | `content_editor`, `club_admin` | — | ja |
|
||||||
|
| `exercises.ai.suggest` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | — |
|
||||||
|
| `exercises.ai.regenerate` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | ja (edit) |
|
||||||
|
| `exercises.media.read` | `active_member` | * | — | ja |
|
||||||
|
| `exercises.media.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja |
|
||||||
|
| `exercises.variants.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||||
|
|
||||||
|
**Representative Endpoints:** `/api/exercises*`, `/api/exercises/ai/*`, Medien-Datei-Download.
|
||||||
|
|
||||||
|
### 5.4 Medien-Bibliothek (Archiv)
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `media.library.read` | `active_member` | * | — | ja |
|
||||||
|
| `media.library.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja |
|
||||||
|
| `media.library.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||||
|
| `media.library.lifecycle` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
| `media.rights.declare` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
| `media.admin.rights_review` | `platform_admin` | — | — | Plattform-Admin Legacy-Review |
|
||||||
|
|
||||||
|
### 5.5 Trainingsmodule & Rahmenprogramme
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `modules.read` | `active_member` | * | — | ja |
|
||||||
|
| `modules.create` | `active_member` | `trainer`, `content_editor`, `club_admin` | `training_programs` | — |
|
||||||
|
| `modules.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||||
|
| `modules.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja |
|
||||||
|
| `framework.read` | `active_member` | * | — | ja |
|
||||||
|
| `framework.create` | `active_member` | `trainer`, `club_admin` | `training_programs` | — |
|
||||||
|
| `framework.update` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
| `framework.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja |
|
||||||
|
| `plan_templates.read` | `active_member` | * | — | ja |
|
||||||
|
| `plan_templates.manage` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
|
||||||
|
### 5.6 Progressionspfade
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `progression.read` | `active_member` | * | — | ja |
|
||||||
|
| `progression.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||||
|
|
||||||
|
### 5.7 Planung & Durchführung
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `planning.calendar.read` | `active_member` | * | — | ja (Gruppe/Verein) |
|
||||||
|
| `planning.units.create` | `active_member` | `trainer`, `club_admin`, `division_lead` | `training_units` | ja |
|
||||||
|
| `planning.units.update` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja |
|
||||||
|
| `planning.units.delete` | `active_member` | `club_admin`, `trainer` (eigene) | — | ja |
|
||||||
|
| `planning.units.run` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja |
|
||||||
|
| `planning.coach.execute` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||||
|
| `planning.ai.suggest` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — |
|
||||||
|
| `planning.ai.progression_path` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — |
|
||||||
|
|
||||||
|
### 5.8 Fähigkeiten & Scoring
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||||
|
|---------------|--------------|---------------|------------|------------|
|
||||||
|
| `skills.catalog.read` | `active_member` | * | — | globaler Katalog |
|
||||||
|
| `skills.discovery.read` | `active_member` | `trainer`, `content_editor` | — | — |
|
||||||
|
| `skill_profiles.read` | `active_member` | * | — | ja (Artefakt) |
|
||||||
|
|
||||||
|
### 5.9 Governance & Meldungen
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID |
|
||||||
|
|---------------|--------------|---------------|------------|
|
||||||
|
| `governance.content_report.create` | `active_member` | * | — |
|
||||||
|
| `governance.content_report.review` | `active_member` | `club_admin` | — |
|
||||||
|
| `governance.change_request.*` | `active_member` | `content_editor`, `club_admin` | — |
|
||||||
|
|
||||||
|
### 5.10 Plattform (nur Portal-Admin / Superadmin)
|
||||||
|
|
||||||
|
| Capability-ID | Min. Account | Portal-Rolle | Feature-ID |
|
||||||
|
|---------------|--------------|--------------|------------|
|
||||||
|
| `platform.admin.access` | `platform_admin` | `admin`, `superadmin` | — |
|
||||||
|
| `platform.users.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.catalogs.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.maturity_models.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.wiki_import.execute` | `platform_admin` | `superadmin` | `wiki_import` |
|
||||||
|
| `platform.ai_prompts.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.exercise_enrichment.execute` | `platform_admin` | `superadmin` | `ai_calls` |
|
||||||
|
| `platform.user_content.moderate` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.legal_documents.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.media_storage.manage` | `platform_admin` | `superadmin` | — |
|
||||||
|
| `platform.club_creation.approve` | `platform_admin` | `superadmin` | — |
|
||||||
|
|
||||||
|
*Geplant:* `club.creation_request.submit` → `verified_pending_club`; Freigabe über `platform.club_creation.approve`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Standard-Zuordnung Vereinsrolle → Capabilities (v1, fest)
|
||||||
|
|
||||||
|
Diese Tabelle ist die **initiale** Grant-Matrix (`club_role_capability_grants`). Später durch Custom Roles ersetzbar — gleiche Capability-IDs.
|
||||||
|
|
||||||
|
| Capability-Cluster | `club_admin` | `trainer` | `content_editor` | `division_lead` |
|
||||||
|
|--------------------|:------------:|:---------:|:----------------:|:---------------:|
|
||||||
|
| `org.structure.manage` | ✓ | — | — | ✓ (eigene Sparte, später) |
|
||||||
|
| `org.members.manage` | ✓ | — | — | — |
|
||||||
|
| `org.join_request.review` | ✓ | — | — | — |
|
||||||
|
| `exercises.read` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `exercises.create/update` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `exercises.delete` | ✓ | — | — | — |
|
||||||
|
| `exercises.ai.*` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `media.library.*` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `modules.*` / `framework.*` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `planning.*` | ✓ | ✓ | — | ✓ |
|
||||||
|
| `planning.coach.execute` | ✓ | ✓ | — | ✓ |
|
||||||
|
| `governance.content_report.review` | ✓ | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API-Vertrag (Ziel)
|
||||||
|
|
||||||
|
### 7.1 Effektive Rechte für Frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/me/entitlements?club_id={optional}
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort (Ausschnitt):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"account_state": "active_member",
|
||||||
|
"portal_role": "user",
|
||||||
|
"club_id": 12,
|
||||||
|
"club_roles": ["trainer"],
|
||||||
|
"capabilities": {
|
||||||
|
"exercises.read": true,
|
||||||
|
"exercises.ai.suggest": true,
|
||||||
|
"org.members.manage": false
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"ai_calls": { "allowed": true, "used": 4, "limit": 50, "remaining": 46, "reset_at": "2026-07-01T00:00:00Z" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend: Navigation und Buttons nur aus dieser Antwort — **keine** duplizierten Rollen-Checks in JSX (Ausnahme: rein kosmetische Labels).
|
||||||
|
|
||||||
|
### 7.2 Backend-Enforcement
|
||||||
|
|
||||||
|
Zentral (Zielmodul `authorization/capabilities.py` oder Erweiterung `club_tenancy.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert_capability(tenant, "exercises.ai.suggest", club_id=tenant.effective_club_id)
|
||||||
|
assert_club_feature(tenant, "ai_calls", club_id=tenant.effective_club_id) # siehe Club-Membership-Doc
|
||||||
|
# + bestehende Governance auf Objekt-Ebene
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementierungsreihenfolge (Capabilities)
|
||||||
|
|
||||||
|
| Phase | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| C0 | Account-Gates (`unverified`, `verified_pending_club`) — ohne Capability-DB |
|
||||||
|
| C1 | `capabilities` + `club_role_capability_grants` seed aus §5–6 |
|
||||||
|
| C2 | `GET /api/me/entitlements` + Frontend-Nav |
|
||||||
|
| C3 | Enforcement: KI-Endpoints, `exercises.create`, `planning.*` |
|
||||||
|
| C4 | Restliche Router schrittweise; Audit in `ACCESS_LAYER_ENDPOINT_AUDIT.md` |
|
||||||
|
| C5 | Custom Roles (optional) — gleiche IDs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Abgrenzung & Drift-Schutz
|
||||||
|
|
||||||
|
1. **Neue Nutzerfunktion** → `register_capability()` in `rights_registrations/<modul>.py`, dann Endpoint mit `probe_capability`. Namenskonvention hier dokumentieren — **kein** Bulk-Seed in Migrationen.
|
||||||
|
2. **Kontingent** → `register_feature()` im selben Modul; Consume über `consume_club_feature_with_usage`.
|
||||||
|
3. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback.
|
||||||
|
4. Capability ≠ Feature: `exercises.ai.suggest` (darf ich?) vs. `ai_calls` (wie viel übrig?).
|
||||||
|
5. Plattform-Admin-Bypass dokumentieren und auditieren (`platform_admin` sieht Mandant, nicht automatisch alle Quotas).
|
||||||
|
|
||||||
|
Siehe **`docs/working/RIGHTS_AND_FEATURES_REGISTRY.md`** (Registry-first, ersetzt Katalog-first aus 079).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Referenzen
|
||||||
|
|
||||||
|
| Dokument | Inhalt |
|
||||||
|
|----------|--------|
|
||||||
|
| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Vereinsabo, Feature-Registry, Kontingente |
|
||||||
|
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | TenantContext, Governance, Stufe E |
|
||||||
|
| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` | §4.6 Vereinsabo-Zielbild |
|
||||||
|
| `ACCESS_LAYER_ENDPOINT_AUDIT.md` | Endpoint-Pflege |
|
||||||
|
| Mitai `FEATURE_ENFORCEMENT.md` | 4-Phasen-Rollout-Vorbild |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Changelog**
|
||||||
|
|
||||||
|
- 2026-06-06: v1 — Initial-Katalog aus Ist-Code (`club_tenancy`, Router-Inventar) + Ziel-Onboarding.
|
||||||
478
.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Normal file
478
.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
# Vereins-Membership & Feature-System Shinkan v1
|
||||||
|
|
||||||
|
**Status:** Konzept + M1–M3 teilweise produktiv (siehe Entscheidungs-Doc §2)
|
||||||
|
**Stand:** 2026-06-06
|
||||||
|
**Bezüge:** Schwesterprojekt Mitai (`v9c_subscription_system.sql`, `FEATURE_ENFORCEMENT.md`), `CAPABILITY_CATALOG.v1.md`, `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zweck
|
||||||
|
|
||||||
|
Shinkan verkauft und limitiert **nicht Einzelpersonen** (wie Mitai), sondern **Vereine**. Dieses Dokument definiert:
|
||||||
|
|
||||||
|
- das **Feature-Registry**-Muster (limitierbare Funktionen),
|
||||||
|
- das **Vereins-Abo** (`club_plans`, `club_subscriptions`),
|
||||||
|
- **Kontingente** und Enforcement,
|
||||||
|
- die **Abbildung von Mitai** und **Vermeidung von Refactoring-Schulden**.
|
||||||
|
|
||||||
|
Capabilities (Rollen: *darf ich die Funktion?*) → `CAPABILITY_CATALOG.v1.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Grundprinzip: Zwei Achsen
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph cap [Achse 1 — Capabilities]
|
||||||
|
CR[club_role_capability_grants]
|
||||||
|
PR[portal_role_capability_grants]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph feat [Achse 2 — Features / Kontingente]
|
||||||
|
FP[club_plans]
|
||||||
|
FPL[club_plan_limits]
|
||||||
|
FS[club_subscriptions]
|
||||||
|
FU[club_feature_usage]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph gov [Achse 3 — Governance]
|
||||||
|
GV[visibility / club_id / created_by]
|
||||||
|
end
|
||||||
|
|
||||||
|
REQ[HTTP Request] --> ACCT[Account-Lifecycle]
|
||||||
|
ACCT --> cap
|
||||||
|
cap --> gov
|
||||||
|
gov --> feat
|
||||||
|
feat --> EXEC[Ausführung + increment]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Frage | System | Subjekt |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| Darf Trainer X KI nutzen? | Capability `exercises.ai.suggest` | `profile_id` + `club_role` |
|
||||||
|
| Wie viele KI-Aufrufe hat Verein Y? | Feature `ai_calls` | **`club_id`** |
|
||||||
|
| Darf ich diese Übung ändern? | Governance | Objekt + Mitgliedschaft |
|
||||||
|
|
||||||
|
**Beide Achsen müssen erfüllt sein** (AND), außer dokumentierte Plattform-Ausnahmen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mitai-Mapping (was übernehmen, was nicht)
|
||||||
|
|
||||||
|
### 3.1 Übernehmen (Pattern)
|
||||||
|
|
||||||
|
| Mitai (Person) | Shinkan (Verein) | Anmerkung |
|
||||||
|
|----------------|------------------|-----------|
|
||||||
|
| `features` (TEXT-PK, Registry) | `features` (`app='shinkan'`) | Gemeinsames Muster, ggf. später Jinkendo-weit |
|
||||||
|
| `tiers` | `club_plans` | Produktdefinition |
|
||||||
|
| `tier_limits` | `club_plan_limits` | Matrix Plan × Feature |
|
||||||
|
| `user_feature_restrictions` | `club_feature_overrides` | Admin-Override pro Verein |
|
||||||
|
| `user_feature_usage` | `club_feature_usage` | Verbrauch pro Verein |
|
||||||
|
| `access_grants` | `club_access_grants` | Trial, Promo, manuelle Freischaltung |
|
||||||
|
| `check_feature_access()` | `check_club_feature_access()` | Subjekt `club_id` |
|
||||||
|
| `increment_feature_usage()` | `increment_club_feature_usage()` | Nur bei INSERT / KI-Call |
|
||||||
|
| 4-Phasen-Rollout | identisch | Log → UI → Hard-Block |
|
||||||
|
| `GET /api/features/usage` | `GET /api/clubs/{id}/entitlements` | siehe Capability-Doc §7 |
|
||||||
|
|
||||||
|
### 3.2 Nicht übernehmen
|
||||||
|
|
||||||
|
| Mitai | Shinkan-Grund |
|
||||||
|
|-------|---------------|
|
||||||
|
| `profiles.tier` als Haupt-Abo | Verein zahlt, nicht Einzeltrainer |
|
||||||
|
| `subscriptions` (Shinkan `001`, INT-Features) | Ungenutzt, Schema-Drift |
|
||||||
|
| `get_effective_tier(profile_id)` für Shinkan-Limits | Ersetzen durch `get_effective_club_plan(club_id)` |
|
||||||
|
| Profil-zentrierte Enforcement-Hooks allein | Primär `club_id`; Profil nur für Attribution |
|
||||||
|
|
||||||
|
### 3.3 Parallelität Jinkendo-Familie (später)
|
||||||
|
|
||||||
|
`CENTRAL_SUBSCRIPTION_SYSTEM.md` (Mitai): zentrales Personen-Abo über Apps.
|
||||||
|
|
||||||
|
**Zielbild ohne Refactoring:**
|
||||||
|
|
||||||
|
```
|
||||||
|
features.enforcement_subject ∈ { 'club', 'profile', 'portal' }
|
||||||
|
|
||||||
|
effektives_limit(feature) = merge(
|
||||||
|
club_plan_limit(club_id, feature), # Shinkan-Hauptquelle
|
||||||
|
profile_grant_limit(profile_id, feature) # optional Jinkendo-Bonus
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge-Regel (Vorschlag): **Maximum** der erlaubten Kontingente, boolean = OR. Details vor Stripe festlegen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ist-Zustand Shinkan (Drift — zuerst bereinigen)
|
||||||
|
|
||||||
|
| Artefakt | Problem |
|
||||||
|
|----------|---------|
|
||||||
|
| `backend/migrations/001_auth_membership.sql` | `features.id SERIAL`, `tier_limits.tier VARCHAR` |
|
||||||
|
| `backend/auth.py` `check_feature_access()` | Erwartet Mitai-v9c-Schema (`features.id TEXT`, `tier_id`, `limit_type`, …) |
|
||||||
|
| Kein Router | Ruft `check_feature_access` auf |
|
||||||
|
| `profiles.tier` | Existiert, ohne Shinkan-Enforcement |
|
||||||
|
|
||||||
|
**Pflicht vor Phase 3 (Enforcement):** Migration `0XX_club_features_v1.sql` — v9c-kompatibles Feature-Schema + Vereins-Tabellen; alte `001`-Feature-Zeilen migrieren oder deprecaten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ziel-Schema (v1)
|
||||||
|
|
||||||
|
### 5.1 Feature-Registry (app-weit, Mitai-kompatibel)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Konzept — Implementierung als nummerierte Migration
|
||||||
|
CREATE TABLE features (
|
||||||
|
id TEXT PRIMARY KEY, -- z.B. 'ai_calls'
|
||||||
|
app TEXT NOT NULL DEFAULT 'shinkan',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT NOT NULL, -- 'content'|'planning'|'ai'|'org'|'integration'|'platform'
|
||||||
|
limit_type TEXT NOT NULL DEFAULT 'count', -- 'count' | 'boolean'
|
||||||
|
reset_period TEXT NOT NULL DEFAULT 'never', -- 'never' | 'daily' | 'monthly'
|
||||||
|
default_limit INTEGER, -- NULL=∞, 0=aus
|
||||||
|
enforcement_subject TEXT NOT NULL DEFAULT 'club', -- 'club'|'profile'|'portal'
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Vereins-Produkte & Abo
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE club_plans (
|
||||||
|
id TEXT PRIMARY KEY, -- 'free', 'verein_starter', 'verein_pro'
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
price_monthly_cents INTEGER,
|
||||||
|
price_yearly_cents INTEGER,
|
||||||
|
stripe_price_id_monthly TEXT,
|
||||||
|
stripe_price_id_yearly TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_subscriptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES club_plans(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- active|trial|past_due|cancelled
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ends_at TIMESTAMPTZ,
|
||||||
|
trial_ends_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id) -- ein aktiver Plan pro Verein (v1)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_plan_limits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
limit_value INTEGER, -- NULL=∞, 0=deaktiviert
|
||||||
|
UNIQUE (plan_id, feature_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Overrides, Grants, Verbrauch
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE club_feature_overrides (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
limit_value INTEGER NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_access_grants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT REFERENCES club_plans(id),
|
||||||
|
feature_id TEXT REFERENCES features(id), -- optional Einzel-Feature
|
||||||
|
grant_limit INTEGER,
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ends_at TIMESTAMPTZ NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_feature_usage (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reset_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (club_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Optional: Attribution / Fairness / Audit
|
||||||
|
CREATE TABLE club_feature_usage_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL,
|
||||||
|
feature_id TEXT NOT NULL,
|
||||||
|
profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL, -- 'ai_suggest', 'exercise_create', ...
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Capabilities (Rollen — Kurzreferenz)
|
||||||
|
|
||||||
|
Siehe `CAPABILITY_CATALOG.v1.md` für IDs. Tabellen:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE capabilities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
min_account_state TEXT NOT NULL DEFAULT 'active_member',
|
||||||
|
linked_feature_id TEXT REFERENCES features(id), -- optional Kontingent
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE club_role_capability_grants (
|
||||||
|
role_code TEXT NOT NULL, -- club_admin, trainer, ...
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (role_code, capability_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE portal_role_capability_grants (
|
||||||
|
portal_role TEXT NOT NULL, -- admin, superadmin
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (portal_role, capability_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Shinkan Feature-Katalog (Seed v1)
|
||||||
|
|
||||||
|
Übernahme aus `001_auth_membership.sql` + Ist-Endpoints, angereichert:
|
||||||
|
|
||||||
|
| feature_id | category | limit_type | reset_period | enforcement_subject | Default Free | Beschreibung |
|
||||||
|
|------------|----------|------------|--------------|---------------------|--------------|--------------|
|
||||||
|
| `exercises` | content | count | never | club | 100 | Anzahl Übungen im Verein (Bestand) |
|
||||||
|
| `exercise_media` | content | count | monthly | club | 20 | Medien-Uploads / Monat |
|
||||||
|
| `training_units` | planning | count | monthly | club | 40 | Geplante/durchgeführte Einheiten |
|
||||||
|
| `training_programs` | planning | count | never | club | 5 | Module + Rahmenprogramme (kombiniert v1) |
|
||||||
|
| `training_groups` | org | count | never | club | 10 | Trainingsgruppen |
|
||||||
|
| `active_members` | org | count | never | club | 25 | Aktive Mitglieder |
|
||||||
|
| `ai_calls` | ai | count | monthly | club | 0 | KI-Aufrufe (Suggest, Regenerate, Planung) |
|
||||||
|
| `ai_pipeline` | ai | boolean | never | club | 0 | Erweiterte KI-Pipelines (Batch, später) |
|
||||||
|
| `wiki_import` | integration | boolean | never | portal | 0 | MediaWiki-Import (Superadmin) |
|
||||||
|
| `data_export` | integration | boolean | never | club | 0 | Export-Funktionen (wenn eingeführt) |
|
||||||
|
|
||||||
|
**Hinweis:** Free-Defaults sind Produktentscheidung — Tabelle dient Implementierung.
|
||||||
|
|
||||||
|
### 6.1 Beispiel-Pläne (Seed)
|
||||||
|
|
||||||
|
| plan_id | ai_calls/Monat | exercises | active_members |
|
||||||
|
|---------|----------------|-----------|----------------|
|
||||||
|
| `free` | 0 | 100 | 25 |
|
||||||
|
| `verein_starter` | 30 | 500 | 80 |
|
||||||
|
| `verein_pro` | 200 | NULL (∞) | NULL |
|
||||||
|
| `pilot` | 100 | NULL | NULL |
|
||||||
|
|
||||||
|
Jeder Verein erhält bei Anlage durch Superadmin initial `club_subscriptions.plan_id = 'free'` (oder `pilot`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Auflösungslogik
|
||||||
|
|
||||||
|
### 7.1 Effektiver Vereinsplan
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_effective_club_plan(cur, club_id: int) -> str:
|
||||||
|
"""
|
||||||
|
1. Aktiver club_access_grants mit plan_id (höchste Priorität, Zeitfenster)
|
||||||
|
2. club_subscriptions.status == 'active' → plan_id
|
||||||
|
3. Fallback 'free'
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Feature-Limit (analog Mitai `check_feature_access`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def check_club_feature_access(
|
||||||
|
cur,
|
||||||
|
club_id: int,
|
||||||
|
feature_id: str,
|
||||||
|
*,
|
||||||
|
profile_id: int | None = None, # nur für Logging / optionale Profil-Boni später
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Priorität:
|
||||||
|
1. club_feature_overrides (club_id, feature_id)
|
||||||
|
2. club_plan_limits für get_effective_club_plan(club_id)
|
||||||
|
3. features.default_limit
|
||||||
|
|
||||||
|
Auswertung:
|
||||||
|
- limit_type boolean: limit_value == 1
|
||||||
|
- limit_type count: used < limit (club_feature_usage, reset beachten)
|
||||||
|
|
||||||
|
Returns: { allowed, limit, used, remaining, reason, reset_at }
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Vollständige Request-Kette
|
||||||
|
|
||||||
|
```
|
||||||
|
1. require_auth
|
||||||
|
2. assert_account_state(min_state) # unverified / verified_pending_club / active_member
|
||||||
|
3. get_tenant_context
|
||||||
|
4. assert_capability(tenant, cap_id) # Rollen-Achse
|
||||||
|
5. assert_content_governance(...) # nur bei Objekt-Endpoints
|
||||||
|
6. check_club_feature_access(club_id, feature_id)
|
||||||
|
7. … Business-Logik …
|
||||||
|
8. consume_club_feature_with_usage(…) + merge_feature_usage_into_response(payload, usage)
|
||||||
|
# Standard: zählen, JSON-Log phase=consume, feature_usage in Response
|
||||||
|
9. optional: club_feature_usage_events (profile_id, action)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response-Standard (alle Consume-Endpoints):** JSON-Feld `feature_usage` — Map `feature_id → { allowed, used, limit, remaining, reason, … }` wie `GET /me/entitlements`. Frontend: `request()` synchronisiert Entitlements automatisch (`featureUsageSync.js`); UI-Komponenten brauchen keinen Einzelcode.
|
||||||
|
|
||||||
|
### 7.4 Wer zählt als Verbrauch?
|
||||||
|
|
||||||
|
| Aktion | increment | Subjekt |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
| `POST /exercises` (neu) | `exercises` | `club_id` des Objekts oder `effective_club_id` |
|
||||||
|
| Medien-Upload | `exercise_media` | Verein des Mediums |
|
||||||
|
| KI Suggest/Regenerate | `ai_calls` | `effective_club_id` |
|
||||||
|
| Mitglied hinzufügen | `active_members` | Ziel-`club_id` |
|
||||||
|
| Trainingsgruppe anlegen | `training_groups` | `club_id` |
|
||||||
|
|
||||||
|
**Mitai-Regel:** Counter **nicht** bei UPDATE/DELETE erhöhen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API-Oberfläche
|
||||||
|
|
||||||
|
### 8.1 Nutzer / Vereinsadmin
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/clubs/{club_id}/entitlements
|
||||||
|
```
|
||||||
|
|
||||||
|
Kombiniert Capabilities + Feature-Kontingente (siehe `CAPABILITY_CATALOG.v1.md` §7.1).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/me/entitlements?club_id=12
|
||||||
|
```
|
||||||
|
|
||||||
|
Bequemer Alias für aktiven Verein.
|
||||||
|
|
||||||
|
### 8.2 Superadmin / Plattform
|
||||||
|
|
||||||
|
| Endpoint | Zweck |
|
||||||
|
|----------|-------|
|
||||||
|
| `GET/PUT /api/admin/club-plans` | Plan-CRUD |
|
||||||
|
| `GET/PUT /api/admin/club-plan-limits` | Matrix |
|
||||||
|
| `GET/PUT /api/admin/clubs/{id}/subscription` | Verein-Abo |
|
||||||
|
| `GET/PUT /api/admin/clubs/{id}/feature-overrides` | Sonderkontingente |
|
||||||
|
| `POST /api/admin/clubs/{id}/access-grants` | Trial/Promo |
|
||||||
|
|
||||||
|
Vorbild UI: Mitai `AdminTierLimitsPage.jsx`, `AdminUserRestrictionsPage.jsx` → Vereins-Kontext.
|
||||||
|
|
||||||
|
### 8.3 Geplant: Vereinsgründung
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/club-creation-requests # Nutzer (verified_pending_club)
|
||||||
|
GET /api/admin/club-creation-requests
|
||||||
|
POST /api/admin/club-creation-requests/{id}/approve # legt club + subscription an
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Vier-Phasen-Rollout (aus Mitai)
|
||||||
|
|
||||||
|
| Phase | Shinkan-Aktivität | Nutzer sichtbar? |
|
||||||
|
|-------|-------------------|------------------|
|
||||||
|
| **0** | Schema-Migration, Seed `features` + `club_plans`, Drift `001` bereinigen | Nein |
|
||||||
|
| **1** | Account-Gates + Capability-Grants (ohne Limits) | Onboarding-Hinweise |
|
||||||
|
| **2** | `check_club_feature_access` — **nur JSON-Log** (`feature_logger` analog Mitai) | Nein |
|
||||||
|
| **3** | `GET …/entitlements` + UsageBadge im UI | Ja (Kontingent-Anzeige) |
|
||||||
|
| **4** | HTTP 403 bei Limit + `increment` | Ja (Hard-Block) |
|
||||||
|
|
||||||
|
**Reihenfolge innerhalb Phase 4:** zuerst `ai_calls`, dann `exercise_media`, dann Bestands-Limits (`exercises`, `active_members`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. CI / Test-Isolation (Betrieb)
|
||||||
|
|
||||||
|
Unabhängig vom Membership-System — **Pflicht** wegen Prod-Vorfälle (`access_layer_it_*@test.local`):
|
||||||
|
|
||||||
|
| Regel | Umsetzung |
|
||||||
|
|-------|-----------|
|
||||||
|
| Integrationstests nie gegen Prod-DB | Eigene Test-DB oder Job-Postgres in Gitea |
|
||||||
|
| `ENVIRONMENT=production` + `ALLOW_INTEGRATION_TESTS` | Default `0`, Tests abbrechen |
|
||||||
|
| Test-Accounts | E-Mail `@test.local` oder `profiles.is_test_account` |
|
||||||
|
| Cleanup | Fixture-`finally` + Nightly-Job löscht Leichen |
|
||||||
|
|
||||||
|
`.gitea/workflows/test.yml`: pytest-backend gegen Deploy-DB **ersetzen** durch isolierte DB (eigenes Epic, parallel zu Membership).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Implementierungs-Roadmap (gesamt)
|
||||||
|
|
||||||
|
| Schritt | Deliverable | Membership-relevant |
|
||||||
|
|---------|-------------|-------------------|
|
||||||
|
| M0 | CI-Isolation + Prod-Cleanup-Runbook | Nein |
|
||||||
|
| M1 | Migration Feature-Schema v9c + `club_plans`/`club_subscriptions` (leer nutzbar) | **Ja** |
|
||||||
|
| M2 | `check_club_feature_access` + Seed Pläne | **Ja** |
|
||||||
|
| M3 | Account-Lifecycle + Capability-Grants | Capabilities |
|
||||||
|
| M4 | `GET /me/entitlements` | **Ja** |
|
||||||
|
| M5 | Enforcement `ai_calls` (Phase 4) | **Ja** |
|
||||||
|
| M6 | Admin Plan-Matrix UI | **Ja** |
|
||||||
|
| M7 | `club_creation_requests` | Prozess |
|
||||||
|
| M8 | Stripe / Rechnung | Später |
|
||||||
|
|
||||||
|
**Nach Produktentscheidungen 2026-06-06** (Details `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §4):
|
||||||
|
|
||||||
|
| Phase | Paket | Priorität |
|
||||||
|
|-------|--------|-----------|
|
||||||
|
| A | Onboarding-Gates vollständig (`verified_pending_club`) | **Als Nächstes** |
|
||||||
|
| B | M7 Vereinsgründung beantragen | hoch |
|
||||||
|
| C | M5 Hard-Block `ai_calls` | danach |
|
||||||
|
| D | M6 Superadmin-UI | danach |
|
||||||
|
| E | Systemrolle `co_trainer` + Frontend-Entitlements | v1 Rollen |
|
||||||
|
| F | Trainer-Member-Budgets (v2) | später |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Offene Produktentscheidungen
|
||||||
|
|
||||||
|
Vor M6 festlegen:
|
||||||
|
|
||||||
|
1. **Zählen `active_members`:** alle Mitglieder oder nur Rollen mit Planungsrecht?
|
||||||
|
2. **Soft-Limit vs. Hard-Stop:** Warnung bei 80 % oder sofort 403?
|
||||||
|
3. **Pilotverein:** eigener Plan `pilot` mit hohen Limits?
|
||||||
|
4. **KI-Fairness:** nur Vereinslimit oder zusätzlich Max pro Trainer/Monat?
|
||||||
|
5. **Offizielle Inhalte:** für `verified_pending_club` sichtbar oder gesperrt? → **entschieden: gesperrt** (`MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.1)
|
||||||
|
6. **Portal `admin` vs. `superadmin`:** Wer darf Vereine anlegen? (Ziel: nur `superadmin` für Freigabe)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Referenzen
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| `c:/dev/mitai-jinkendo/backend/migrations/v9c_subscription_system.sql` | Mitai-Schema-Vorlage |
|
||||||
|
| `c:/dev/mitai-jinkendo/.claude/docs/architecture/FEATURE_ENFORCEMENT.md` | 4-Phasen-Modell |
|
||||||
|
| `c:/dev/mitai-jinkendo/.claude/docs/technical/MEMBERSHIP_SYSTEM.md` | Mitai-Hauptdoku |
|
||||||
|
| `c:/dev/mitai-jinkendo/.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` | Jinkendo-Familie später |
|
||||||
|
| `CAPABILITY_CATALOG.v1.md` | Rollen & Capabilities |
|
||||||
|
| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6 | Ursprüngliches Vereinsabo-Zielbild |
|
||||||
|
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Stufe E/F |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Changelog**
|
||||||
|
|
||||||
|
- 2026-06-06: v1 — Mitai-Mapping, Ziel-Schema, Feature-Seed, Auflösungslogik, Rollout.
|
||||||
9
.claude/docs/technical/DESIGN_PRINCIPLES_RELOCATED.md
Normal file
9
.claude/docs/technical/DESIGN_PRINCIPLES_RELOCATED.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Designprinzipien — verschoben
|
||||||
|
|
||||||
|
Die **Jinkendo-Produktfamilie Designprinzipien** (Index + Shinkan-Serie) liegen nicht mehr hier.
|
||||||
|
|
||||||
|
**Neuer Standort (Shinkan-Serie):** [docs/jinkendo-family/design-principles/DESIGN_PRINCIPLES_INDEX.md](../../../docs/jinkendo-family/design-principles/DESIGN_PRINCIPLES_INDEX.md)
|
||||||
|
|
||||||
|
**Mitai-Serie:** [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md)
|
||||||
|
|
||||||
|
**Abgleich:** [docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md](../../../docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md)
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
# Exercises API Specification
|
# Exercises API Specification
|
||||||
|
|
||||||
**Version:** 1.5
|
**Version:** 1.6
|
||||||
**Datum:** 2026-05-08
|
**Datum:** 2026-05-20
|
||||||
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
|
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
|
||||||
**Autor:** Claude Code
|
**Autor:** Claude Code
|
||||||
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
|
**Änderungen v1.6:** Freigabelevel-UI-Hinweis; `exercise_skills` ohne `is_primary` in Requests (Legacy-Feld wird ignoriert/forciert false); Permissions-Bereich an Ist-Code angeglichen; Intensität kanonisch `niedrig|mittel|hoch`
|
||||||
**Änderungen v1.5:** Medien-/Inline-Workflow aktualisiert (Modal-Picker, Drag&Drop UX im Frontend), Klarstellung zu `context` (legacy/optional), Hinweise zu Platzhaltern in Rich-Text-Feldern.
|
**Änderungen v1.5:** Medien-/Inline-Workflow aktualisiert (Modal-Picker, Drag&Drop UX im Frontend), Klarstellung zu `context` (legacy/optional), Hinweise zu Platzhaltern in Rich-Text-Feldern.
|
||||||
|
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
|
||||||
**Änderungen v1.3:** `GET /exercises` erweiterte Query-Parameter (`include_variants`, Multi-Filter, `ai_search`-Platzhalter); Dokumentation angepasst
|
**Änderungen v1.3:** `GET /exercises` erweiterte Query-Parameter (`include_variants`, Multi-Filter, `ai_search`-Platzhalter); Dokumentation angepasst
|
||||||
**Änderungen v1.2:** KI-Assistenz Endpoints, Skill-Level-System (benannte Stufen), intensity als low/medium/high
|
**Änderungen v1.2:** KI-Assistenz Endpoints, Skill-Level-System (benannte Stufen), intensity als low/medium/high
|
||||||
**Änderungen v1.1:** Exercise Blocks Endpoints, Permissions dokumentiert, age_groups korrigiert
|
**Änderungen v1.1:** Exercise Blocks Endpoints, Permissions dokumentiert, age_groups korrigiert
|
||||||
|
|
@ -185,11 +186,11 @@ Lightweight-Liste; bei `include_variants=true` zusätzlich z. B.:
|
||||||
"skill_id": 10,
|
"skill_id": 10,
|
||||||
"skill_name": "Distanzgefühl",
|
"skill_name": "Distanzgefühl",
|
||||||
"skill_category": "Kumite",
|
"skill_category": "Kumite",
|
||||||
"is_primary": true,
|
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau",
|
"target_level": "aufbau",
|
||||||
"ai_suggested": false
|
"ai_suggested": false,
|
||||||
|
"is_primary": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -307,7 +308,6 @@ Lightweight-Liste; bei `include_variants=true` zusätzlich z. B.:
|
||||||
"skills": [
|
"skills": [
|
||||||
{
|
{
|
||||||
"skill_id": 10,
|
"skill_id": 10,
|
||||||
"is_primary": true,
|
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau"
|
"target_level": "aufbau"
|
||||||
|
|
@ -578,7 +578,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau",
|
"target_level": "aufbau",
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"is_primary": true,
|
|
||||||
"confidence": 0.92
|
"confidence": 0.92
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -588,7 +587,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "einsteiger",
|
"required_level": "einsteiger",
|
||||||
"target_level": "grundlagen",
|
"target_level": "grundlagen",
|
||||||
"intensity": "mittel",
|
"intensity": "mittel",
|
||||||
"is_primary": false,
|
|
||||||
"confidence": 0.74
|
"confidence": 0.74
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -621,6 +619,38 @@ Trainer muss im Frontend aktiv übernehmen.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
|
**UI-Hinweis:** Das Feld `visibility` heißt in der Oberfläche **Freigabelevel** (`exerciseGovernanceLabels.js`).
|
||||||
|
|
||||||
|
### Lesen (`GET /exercises`, `GET /exercises/{id}`)
|
||||||
|
|
||||||
|
| `visibility` | Wer darf lesen? |
|
||||||
|
|--------------|-----------------|
|
||||||
|
| `official` | Plattform-weit |
|
||||||
|
| `private` | Ersteller (`created_by`); Plattform-Admin |
|
||||||
|
| `club` | Aktive Mitglieder des Objekt-`club_id`; Plattform-Admin ohne Mitgliedschaft (Audit-Zugang) |
|
||||||
|
|
||||||
|
Implementierung: `library_content_visible_to_profile` / `exercise_visible_to_profile` in `club_tenancy.py`.
|
||||||
|
|
||||||
|
### Bearbeiten (`PUT`, Varianten-CRUD, Medien an Übung)
|
||||||
|
|
||||||
|
| Bedingung | Wer darf bearbeiten? |
|
||||||
|
|-----------|----------------------|
|
||||||
|
| Ersteller | Immer (eigene Übung) |
|
||||||
|
| Plattform-Admin | Immer |
|
||||||
|
| `visibility=club` | Zusätzlich **`can_plan_in_club`** im Objekt-Verein: `club_admin`, `trainer`, `content_editor`, `division_lead` |
|
||||||
|
|
||||||
|
Implementierung: `_assert_can_edit_exercise` in `exercises.py`. **Varianten** haben kein eigenes Owner-Feld — gleiche Prüfung wie Eltern-Übung.
|
||||||
|
|
||||||
|
### Löschen (`DELETE /exercises/{id}`)
|
||||||
|
|
||||||
|
| `visibility` | Wer darf löschen? |
|
||||||
|
|--------------|-------------------|
|
||||||
|
| `official` | Nur Plattform-Admin |
|
||||||
|
| `club` | Nur **`club_admin`** im Objekt-Verein |
|
||||||
|
| `private` | Ersteller; oder Vereins-Admin, der mit dem Ersteller einen gemeinsamen Verein teilt |
|
||||||
|
|
||||||
|
Implementierung: `_assert_can_delete_exercise` in `exercises.py`.
|
||||||
|
|
||||||
### Sichtbarkeits-Workflow
|
### Sichtbarkeits-Workflow
|
||||||
|
|
||||||
| Von → Nach | Wer darf das? |
|
| Von → Nach | Wer darf das? |
|
||||||
|
|
@ -638,11 +668,12 @@ Trainer muss im Frontend aktiv übernehmen.
|
||||||
| `club → official` | Club-Admin, Super-Admin |
|
| `club → official` | Club-Admin, Super-Admin |
|
||||||
| `official → club` | Super-Admin |
|
| `official → club` | Super-Admin |
|
||||||
|
|
||||||
### Owner-Checks
|
### Owner-Checks (veraltet — siehe Tabellen oben)
|
||||||
|
|
||||||
- **Bearbeiten** (PUT): Nur Ersteller oder Club-Admin
|
Die folgenden Kurzregeln sind durch die Ist-Implementierung ersetzt; nur zur historischen Einordnung:
|
||||||
- **Löschen** (DELETE): Nur Ersteller oder Super-Admin
|
|
||||||
- **Lesen** (`private`): Nur Ersteller
|
- ~~Bearbeiten (PUT): Nur Ersteller oder Club-Admin~~ → siehe **Bearbeiten**-Tabelle (`can_plan_in_club`)
|
||||||
|
- ~~Löschen (DELETE): Nur Ersteller oder Super-Admin~~ → siehe **Löschen**-Tabelle
|
||||||
|
|
||||||
**403 Fehler-Beispiel:**
|
**403 Fehler-Beispiel:**
|
||||||
```json
|
```json
|
||||||
|
|
@ -904,7 +935,8 @@ Trainer muss im Frontend aktiv übernehmen.
|
||||||
### Exercise Skills
|
### Exercise Skills
|
||||||
- `required_level`: enum – `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
|
- `required_level`: enum – `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
|
||||||
- `target_level`: enum – gleiche Werte (optional/nullable)
|
- `target_level`: enum – gleiche Werte (optional/nullable)
|
||||||
- `intensity`: enum – `niedrig | mittel | hoch` (optional/nullable)
|
- `intensity`: enum – **`niedrig | mittel | hoch`** (optional/nullable; Default beim Speichern **`mittel`**)
|
||||||
|
- `is_primary`: **Legacy** — Spalte existiert in DB, wird bei POST/PUT **nicht ausgewertet** (immer `false` gespeichert); UI liefert/speichert kein Primär-Flag mehr; Scoring ignoriert das Feld
|
||||||
- `target_level` sollte >= `required_level` sein (Warnung, kein Fehler)
|
- `target_level` sollte >= `required_level` sein (Warnung, kein Fehler)
|
||||||
|
|
||||||
### Exercise Block Item
|
### Exercise Block Item
|
||||||
|
|
|
||||||
|
|
@ -99,20 +99,21 @@ Exercise Block ──── (N) Block Items ──── (1) Exercise
|
||||||
|
|
||||||
### 1.3 M:N Beziehungen (Primary/Secondary Pattern)
|
### 1.3 M:N Beziehungen (Primary/Secondary Pattern)
|
||||||
|
|
||||||
**Regel:** Alle Katalog-Zuordnungen nutzen M:N mit `is_primary` Flag.
|
**Regel:** Katalog-Zuordnungen (Fokus, Stil, Zielgruppe, …) nutzen M:N mit optionalem `is_primary`-Flag.
|
||||||
|
|
||||||
**Betroffene Relationen:**
|
**Betroffene Relationen (mit `is_primary`):**
|
||||||
- `exercise_focus_areas` (Übung ↔ Fokusbereiche)
|
- `exercise_focus_areas` (Übung ↔ Fokusbereiche)
|
||||||
- `exercise_styles` (Übung ↔ Trainingsstile)
|
- `exercise_styles` / `exercise_style_directions` (Übung ↔ Stilrichtungen)
|
||||||
|
- `exercise_training_types` (Übung ↔ Trainingsstile)
|
||||||
- `exercise_target_groups` (Übung ↔ Zielgruppen)
|
- `exercise_target_groups` (Übung ↔ Zielgruppen)
|
||||||
- `exercise_training_characters` (Übung ↔ Trainingscharaktere)
|
|
||||||
- `exercise_skills` (Übung ↔ Fähigkeiten)
|
|
||||||
|
|
||||||
**Primary/Secondary Semantik:**
|
**Ausnahme — `exercise_skills`:** Kein Primär-Flag in UI/API mehr; stattdessen **`intensity`** (`niedrig` \| `mittel` \| `hoch`, Default `mittel`). Spalte `is_primary` bleibt Legacy (Backend speichert immer `false`).
|
||||||
|
|
||||||
|
**Primary/Secondary Semantik (Katalog-Dimensionen):**
|
||||||
- **Primary:** Hauptzuordnung, entscheidend für Filter/Suche
|
- **Primary:** Hauptzuordnung, entscheidend für Filter/Suche
|
||||||
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
|
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
|
||||||
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension
|
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension (wo UI das noch anbietet)
|
||||||
- **UI:** Primary wird visuell hervorgehoben (z.B. fett, farbig)
|
- **UI:** Primary wird visuell hervorgehoben (z. B. fett, farbig) — Fähigkeiten: Intensitäts-Segmente statt Primary
|
||||||
|
|
||||||
**Legacy-Felder (DEPRECATED):**
|
**Legacy-Felder (DEPRECATED):**
|
||||||
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
|
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
# Frontend Routing & Navigation Specification
|
# Frontend Routing & Navigation Specification
|
||||||
|
|
||||||
**Version:** 1.2
|
**Version:** 1.3
|
||||||
**Datum:** 2026-04-30
|
**Datum:** 2026-05-20
|
||||||
**Status:** DRAFT - Awaiting Review
|
**Status:** DRAFT - Awaiting Review
|
||||||
**Autor:** Claude Code
|
**Autor:** Claude Code
|
||||||
|
**Änderungen v1.3:** Übungsformular Tab-Navigation unter `/exercises/:id/edit` (Stammdaten … Medien & Mehr); Freigabelevel als UI-Begriff
|
||||||
**Änderungen v1.2:** Übersicht **Übungen**: Tabs Liste \| Progressionsgraphen auf `/exercises`; Progressions-Editor ohne neue Routen (Panel + Formularblock unter `/exercises/:id/edit`)
|
**Änderungen v1.2:** Übersicht **Übungen**: Tabs Liste \| Progressionsgraphen auf `/exercises`; Progressions-Editor ohne neue Routen (Panel + Formularblock unter `/exercises/:id/edit`)
|
||||||
**Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
|
**Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@
|
||||||
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
||||||
/exercises/new → ExerciseFormPage (Create)
|
/exercises/new → ExerciseFormPage (Create)
|
||||||
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
|
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
|
||||||
/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline + Block Progressionsgraph)
|
/exercises/{id}/edit → ExerciseFormPage (Edit: Registerkarten + Varianten inline + Progressionsgraph)
|
||||||
|
|
||||||
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
|
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
|
||||||
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
||||||
|
|
@ -35,6 +36,25 @@
|
||||||
- Pagination: `/exercises?limit=50&offset=100`
|
- Pagination: `/exercises?limit=50&offset=100`
|
||||||
- Sortierung: `/exercises?sort=created_at&order=desc`
|
- Sortierung: `/exercises?sort=created_at&order=desc`
|
||||||
|
|
||||||
|
### 1.2 Übungsformular – Registerkarten (`/exercises/new`, `/exercises/:id/edit`)
|
||||||
|
|
||||||
|
**Implementierung:** `ExerciseFormPageRoot.jsx` + `ExerciseFormLayout.jsx` (`ExerciseFormTabBar`, `ExerciseFormPanel`).
|
||||||
|
|
||||||
|
| Tab-ID | Label | Verfügbarkeit |
|
||||||
|
|--------|-------|---------------|
|
||||||
|
| `stammdaten` | Stammdaten | immer |
|
||||||
|
| `anleitung` | Anleitung | immer |
|
||||||
|
| `einordnung` | Einordnung | immer |
|
||||||
|
| `kombination` | Kombination | nur `exercise_kind=combination` |
|
||||||
|
| `varianten` | Varianten | Edit-Modus; nicht bei Kombination; disabled bei Neuanlage |
|
||||||
|
| `medien` | Medien & Mehr | Edit-Modus; disabled bei Neuanlage |
|
||||||
|
|
||||||
|
**UX-Regeln:**
|
||||||
|
- Nur ein Panel sichtbar (`activeFormTab`); Navigation über `PageSectionNav`.
|
||||||
|
- **Freigabelevel** (Feld `visibility`) in Stammdaten — Konstante `EXERCISE_VISIBILITY_FIELD_LABEL`.
|
||||||
|
- Varianten-Änderungen werden mit **Speichern** in der Aktionsleiste persistiert (`persistPendingVariantChanges`); Button „Variante anlegen“ optional sofort.
|
||||||
|
- Kein URL-Hash pro Tab (Tab-State nur lokal).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Navigation-Patterns
|
## 2. Navigation-Patterns
|
||||||
|
|
@ -673,7 +693,7 @@ function App() {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version:** 1.2
|
**Version:** 1.3
|
||||||
**Letzte Änderung:** 2026-04-30
|
**Letzte Änderung:** 2026-05-20
|
||||||
**Status:** REVIEWED - Pending Implementation
|
**Status:** REVIEWED - Pending Implementation
|
||||||
**Review-Änderungen:** Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)
|
**Review-Änderungen:** Formular-Registerkarten; Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,16 @@
|
||||||
**Änderungen v1.1:** Prompts sind nicht hardcoded – sie werden aus der DB geladen (AI_PROMPT_SYSTEM_SPEC.md)
|
**Änderungen v1.1:** Prompts sind nicht hardcoded – sie werden aus der DB geladen (AI_PROMPT_SYSTEM_SPEC.md)
|
||||||
**Verwandte Specs:** AI_PROMPT_SYSTEM_SPEC.md (Prompt-DB + Platzhalter), SKILLS_MATRIX_SPEC.md (Fähigkeitsmatrix)
|
**Verwandte Specs:** AI_PROMPT_SYSTEM_SPEC.md (Prompt-DB + Platzhalter), SKILLS_MATRIX_SPEC.md (Fähigkeitsmatrix)
|
||||||
|
|
||||||
|
**Übergeordnete Produkt-Vision** (breiter Scope: Zielausbau, bereichsweise vs. Gesamtüberarbeitung, Varianten, Planungs-/Nachbereitungskontext, Admin-Masse):
|
||||||
|
`functional/AI_EXERCISE_ASSISTANT_VISION.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Übersicht
|
## 1. Übersicht
|
||||||
|
|
||||||
Zwei KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen:
|
KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen (Mindestpaket dieser Spec):
|
||||||
|
|
||||||
|
**Hinweis:** Die beiden folgenden Zeilen entsprechen **P0** der Phasierung in **`AI_EXERCISE_ASSISTANT_VISION.md`**; spätere Funkteile sind dort beschrieben.
|
||||||
|
|
||||||
| Funktion | Ziel |
|
| Funktion | Ziel |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
|
|
@ -155,7 +160,38 @@ KI gibt Vorschläge
|
||||||
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
|
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
|
||||||
Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||||
|
|
||||||
**Request Body:**
|
**Required Fields:** mindestens `goal` ODER `execution`
|
||||||
|
|
||||||
|
**Optional – Skill-Katalogpriorisierung (Stand 068):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"focus_areas_context": [
|
||||||
|
{ "focus_area_id": 3, "is_primary": true },
|
||||||
|
{ "focus_area_id": 1, "is_primary": false }
|
||||||
|
],
|
||||||
|
"focus_area_hint": "Karate, Kumite…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `focus_areas_context`: IDs aus Stammdatum **Fokusbereiche**; Primär soll zuerst stehen (`is_primary`). Ohne Feld oder leere Liste gilt das DB-Profil **`is_default`** (`ai_skill_retrieval_profiles`).
|
||||||
|
- `focus_area_hint`: bleibt lesbarer Text für den Prompt (bestehende Prompts).
|
||||||
|
|
||||||
|
|
||||||
|
**Minimal-Beispiel (Mit Fokus für Retrieval):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Maai - Distanzübung",
|
||||||
|
"goal": "…",
|
||||||
|
"execution": "…",
|
||||||
|
"focus_areas_context": [ { "focus_area_id": 1, "is_primary": true } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
**Minimal-Beispiel ( ohne Fokus — nur Texts):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "Maai - Distanzübung",
|
"title": "Maai - Distanzübung",
|
||||||
|
|
@ -164,8 +200,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Required Fields:** mindestens `goal` ODER `execution` (je länger, desto besser)
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -182,7 +216,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "grundlagen",
|
"required_level": "grundlagen",
|
||||||
"target_level": "aufbau",
|
"target_level": "aufbau",
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
"is_primary": true,
|
|
||||||
"confidence": 0.92
|
"confidence": 0.92
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -192,7 +225,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||||
"required_level": "einsteiger",
|
"required_level": "einsteiger",
|
||||||
"target_level": "grundlagen",
|
"target_level": "grundlagen",
|
||||||
"intensity": "mittel",
|
"intensity": "mittel",
|
||||||
"is_primary": false,
|
|
||||||
"confidence": 0.74
|
"confidence": 0.74
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
243
.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md
Normal file
243
.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Membership, RBAC & Kontingente — Produktentscheidungen
|
||||||
|
|
||||||
|
**Status:** Verbindlich (Zielbild & Roadmap-Priorisierung)
|
||||||
|
**Stand:** 2026-06-06
|
||||||
|
**Bezüge:** `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
|
||||||
|
|
||||||
|
Dieses Dokument hält **getroffene Produktentscheidungen** fest (Session 2026-06-06) und ergänzt die v1-Konzept-Specs um Umsetzungsrichtung. Technischer Implementierungsstand: Abschnitt 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Getroffene Entscheidungen
|
||||||
|
|
||||||
|
### 1.1 Onboarding: `verified_pending_club`
|
||||||
|
|
||||||
|
Nutzer **ohne aktive Vereinsmitgliedschaft** (E-Mail verifiziert) dürfen **nur**:
|
||||||
|
|
||||||
|
| Erlaubt | Nicht erlaubt (Zielbild) |
|
||||||
|
|---------|---------------------------|
|
||||||
|
| Konto / Einstellungen | Übungen, Planung, KI, Medien |
|
||||||
|
| Vereinsverzeichnis lesen | Vereinsinterne Inhalte (`club`), private Fremdinhalte |
|
||||||
|
| **Beitrittsantrag** an bestehenden Verein | Vollzugriff auf Bibliothek / offizielle Inhalte (Lesen) — **bewusst gesperrt** bis Mitgliedschaft |
|
||||||
|
| **Vereinsgründung beantragen** (Prozess M7, Superadmin-Freigabe) | |
|
||||||
|
|
||||||
|
**Kein** „Bibliothek durchstöbern“ für Bewerber — reduziert Datenexposition und vereinfacht UX („erst Verein, dann Arbeit“).
|
||||||
|
|
||||||
|
Technischer Zustand: `account_state = verified_pending_club` (siehe `CAPABILITY_CATALOG.v1.md` §3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Rollenmodell: Risikoarm statt Big-Bang
|
||||||
|
|
||||||
|
**Zielbild (langfristig):**
|
||||||
|
|
||||||
|
- **Fest:** nur `superadmin` (Plattform) als nicht konfigurierbare Systemrolle.
|
||||||
|
- **Dynamisch konfigurierbar:** alle Vereinsrollen und deren Capability-Bundles (später `club_custom_roles`).
|
||||||
|
- Optional: `admin` (Plattform) als abgeschwächter Portal-Admin bleibt vorerst bestehen (Ist-Code).
|
||||||
|
|
||||||
|
**Entscheidung v1 (risikoarm):**
|
||||||
|
|
||||||
|
| Maßnahme | Jetzt | Später |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| Alte Helfer (`can_plan_in_club`, `if (club_admin)` in JSX) | **Behalten** — weiter produktiv | Schrittweise durch `entitlements` ersetzen |
|
||||||
|
| Neue Endpoints / Features | Nur über **Capability-IDs** + Audit | — |
|
||||||
|
| Neue Vereinsrollen | Als **Systemrollen** ergänzen (z. B. `co_trainer`) | Custom Roles UI |
|
||||||
|
| `club_custom_roles` | **Nicht** in v1 | v2 Epic |
|
||||||
|
|
||||||
|
**Begründung:** Backend und Frontend haben hunderte Verdrahtungen auf `trainer` / `club_admin` / Plattform-Rollen. Parallelbetrieb Capability-System + Legacy-Helfer ist sicherer als einmaliges Aufbrechen.
|
||||||
|
|
||||||
|
**Co-Trainer (geplant als Systemrolle):** weniger Capabilities als `trainer` (z. B. kein `planning.*`, kein `exercises.create`) — Umsetzung nach Onboarding-Gates + Entitlements-Rollout, nicht vorher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Vereins-Kontingente (Membership-Pakete)
|
||||||
|
|
||||||
|
**Jetzt:** Schema und Anzeige vorbereiten; **keine** detaillierte Paket-Logik (z. B. „3 Trainer + 10 Co-Trainer“) implementieren.
|
||||||
|
|
||||||
|
| Vorbereitet (DB/Module) | Bewusst zurückgestellt |
|
||||||
|
|-------------------------|-------------------------|
|
||||||
|
| `features`, `club_plans`, `club_subscriptions` | Eigene Feature-IDs `trainer_seats` / `co_trainer_seats` |
|
||||||
|
| Bestands-Limits (`exercises`, `training_groups`, `ai_calls`, …) | Zählregel „nur planungsberechtigte Mitglieder“ vs. alle Mitglieder |
|
||||||
|
| `GET /me/entitlements` Feature-Teil | Stripe / Rechnung (M8) |
|
||||||
|
|
||||||
|
**Prinzip:** Neue Kontingent-Typen = neue `features`-Zeile + Plan-Limits + optional Capability-`linked_feature_id` — ohne Schema-Bruch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 Trainer-Budget innerhalb Vereins-Kontingent (v2)
|
||||||
|
|
||||||
|
**Anforderung:** Vereins-KI-Kontingent liegt beim Verein; **Vereinsadmin** kann pro Trainer ein **Sub-Budget** vergeben (Fairness, „Kontingent-Fresser“).
|
||||||
|
|
||||||
|
**Entscheidung:**
|
||||||
|
|
||||||
|
- v1: nur **Vereins-Ebene** (`club_plan_limits`, `club_feature_usage`).
|
||||||
|
- v2: neue Tabellen (Skizze):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Skizze — noch nicht migriert
|
||||||
|
club_member_feature_budgets (club_id, profile_id, feature_id, limit_value, …)
|
||||||
|
club_member_feature_usage (club_id, profile_id, feature_id, usage_count, reset_at, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prüf-Kette v2:** Capability → Mitglieds-Budget (falls gesetzt, `profile_id` aus Session) → Vereins-Kontingent.
|
||||||
|
|
||||||
|
**Fairness-Modell (offen, Tendenz):** harte Sub-Budgets (Modell A) — Trainer darf sein Budget nicht überschreiten, auch wenn Verein noch Rest hat.
|
||||||
|
|
||||||
|
**Roadmap:** Phase 5b / Meilenstein **M9** in `docs/working/RBAC_ENFORCEMENT_ROADMAP.md` — Vereinsadmin-UI zur Verteilung, Entitlements mit persönlichem + Vereins-Rest, Auswertung je Person.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 Enforcement-Phasen (unverändert, bestätigt)
|
||||||
|
|
||||||
|
| Phase | Verhalten | Nutzer sichtbar |
|
||||||
|
|-------|-----------|-----------------|
|
||||||
|
| 2 (M2/M3) | JSON-Log, kein Block | Nein (außer Logs) |
|
||||||
|
| 3 (M4) | `GET /me/entitlements` + Badge | Kontingent-Anzeige |
|
||||||
|
| 4 (M5+) | HTTP 403 + `increment` | Hard-Block |
|
||||||
|
|
||||||
|
Env-Schalter: `ACCOUNT_GATE_ENFORCE` (Default `1`, Endpoint-Helfer), `ACCOUNT_GATE_API_ENFORCE` (Default `1`, API-Middleware Phase A), `CAPABILITY_ENFORCE` / `CLUB_FEATURE_ENFORCE` (Default `0`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Implementierungsstand (Ist, Codebase)
|
||||||
|
|
||||||
|
**DB-Schema:** `20260606083` · App **0.8.199** (`backend/version.py`)
|
||||||
|
**Roadmap (detailliert):** `docs/working/RBAC_ENFORCEMENT_ROADMAP.md`
|
||||||
|
|
||||||
|
### M1 — Feature-Schema v9c ✅
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| Migration `078_club_features_and_plans.sql` | ✅ |
|
||||||
|
| Legacy `001` archiviert | ✅ |
|
||||||
|
| `club_plans`, `club_subscriptions`, Usage-Tabellen | ✅ |
|
||||||
|
| Seed Features + Pläne (`free`, …) | ✅ |
|
||||||
|
| `club_features.py`: `check_club_feature_access`, `get_effective_club_plan` | ✅ |
|
||||||
|
| Backfill Vereine → Plan `free` | ✅ |
|
||||||
|
|
||||||
|
### M2 — Feature-Probe (Log only) ✅
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| `club_feature_logger.py` → `club-feature-usage.log` | ✅ |
|
||||||
|
| `probe_club_feature_access()` | ✅ |
|
||||||
|
| Hooks: KI-Endpoints, `POST /exercises`, Medien-Upload, Planungs-KI | ✅ |
|
||||||
|
| Consume-Standard + `feature_usage` in Response (`ai_calls`) | ✅ |
|
||||||
|
| `CLUB_FEATURE_ENFORCE=0` (Default) | ✅ |
|
||||||
|
|
||||||
|
### M3 — Account-Lifecycle + Capability-Grants ⚠️ teilweise
|
||||||
|
|
||||||
|
| Deliverable | Status | Lücke |
|
||||||
|
|-------------|--------|-------|
|
||||||
|
| Migration `079_capabilities.sql` + Seed | ✅ | — |
|
||||||
|
| `account_lifecycle.py`, `resolve_account_state` | ✅ | — |
|
||||||
|
| `capabilities.py`, `check_capability`, `probe_capability` | ✅ | — |
|
||||||
|
| `TenantContext.account_state` | ✅ | — |
|
||||||
|
| `GET /profiles/me` → `account_state`, `club_roles` | ✅ | — |
|
||||||
|
| Account-Gates auf **Schreib-/KI-Endpoints** | ✅ | Lesepfade für Bewerber noch offen |
|
||||||
|
| `CAPABILITY_ENFORCE=0` (nur Log) | ✅ | — |
|
||||||
|
| Onboarding UX: nur Bewerbung/Gründung | ✅ | Phase A: API-Middleware + `/onboarding` + reduzierte Nav |
|
||||||
|
| `club_creation_requests` (M7) | ✅ Basis | Capabilities + Admin-Freigabe |
|
||||||
|
| Quota-Bypass via Capability-Grants (083) | ✅ | kein paralleles Exemption-Schema |
|
||||||
|
| Custom Roles / Co-Trainer | ❌ | bewusst v2 |
|
||||||
|
| Legacy-Helfer entfernt | ❌ | bewusst parallel |
|
||||||
|
|
||||||
|
### M4 — Anzeige ✅ teilweise
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| `GET /api/me/entitlements` | ✅ |
|
||||||
|
| `EntitlementsContext`, `hasCapability()` | ✅ (UI nutzt noch kaum) |
|
||||||
|
| `FeatureUsageBadge` | ✅ nur KI im Übungsformular |
|
||||||
|
| `featureUsageSync` in `request()` | ✅ |
|
||||||
|
|
||||||
|
### M5 — Hard-Block + vollständiger Verbrauch ⚠️
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| `consume_club_feature_with_usage` Standard | ✅ `ai_calls` |
|
||||||
|
| `CLUB_FEATURE_ENFORCE=1` produktiv | ❌ Default 0 |
|
||||||
|
| Consume `exercises`, `exercise_media`, … | ❌ |
|
||||||
|
|
||||||
|
### M6 — Admin UI Rollen & Rechte ⚠️
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| `/admin/rights` Capability-Matrix (Portal + Verein) | ✅ |
|
||||||
|
| Klartext zuerst, Enforcement-Badge | ✅ 2026-06-07 |
|
||||||
|
| Kontingent-Bypass + Vereinspläne (Seed) | ✅ |
|
||||||
|
| Neue Pläne / Rollen anlegen (CRUD) | ❌ |
|
||||||
|
|
||||||
|
### Bewusst zurückgestellt
|
||||||
|
|
||||||
|
| ID | Inhalt |
|
||||||
|
|----|--------|
|
||||||
|
| M0 | CI-Isolation / Test-DB |
|
||||||
|
| M8 | Stripe |
|
||||||
|
| v2 | Trainer-Budgets, Custom Roles |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architektur-Zielbild (kompakt)
|
||||||
|
|
||||||
|
```
|
||||||
|
Request
|
||||||
|
→ require_auth
|
||||||
|
→ account_state (Gate)
|
||||||
|
→ TenantContext
|
||||||
|
→ assert_capability (Rolle / Funktion)
|
||||||
|
→ check_club_feature_access (Vereins-Kontingent)
|
||||||
|
→ [v2] member_feature_budget (Trainer-Budget)
|
||||||
|
→ Governance (Objekt)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Drei Achsen:** Account-Lifecycle · Capabilities · Features (Kontingente). Governance bleibt vierte Prüfung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Empfohlene Roadmap (nach Entscheidungen)
|
||||||
|
|
||||||
|
| Phase | Paket | Warum zuerst |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| **A** | **Onboarding-Gates vollständig** | ✅ umgesetzt (API + Frontend `/onboarding`) |
|
||||||
|
| **B** | **M7 Vereinsgründung beantragen** | **Als Nächstes** — zweiter Pfad für `verified_pending_club` |
|
||||||
|
| **C** | **M5 Hard-Block `ai_calls`** | Free-Plan `0` wird real; Badge (M4) liefert Erklärung |
|
||||||
|
| **D** | **M6 voll** | Pläne-CRUD, Rollen-CRUD | ⚠️ Matrix da |
|
||||||
|
| **E** | Entitlements im Frontend (`hasCapability`) | Entscheidung 1.2 risikoarm |
|
||||||
|
| **F** | **M9 Kontingent-Verteilung** — Vereinsadmin vergibt Sub-Budgets pro Person (`profile_id`); Prüfung + Consume personenbezogen; UI Vereinsorga | Entscheidung 1.4, Roadmap Phase 5b |
|
||||||
|
| **G** | `co_trainer` + Custom Roles (v2) | Entscheidung 1.2 |
|
||||||
|
|
||||||
|
M0 parallel, nicht blockierend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Offene Punkte (vor M6 / v2)
|
||||||
|
|
||||||
|
1. Fairness Modell A/B/C für Trainer-Budget (Tendenz: A).
|
||||||
|
2. Ob `admin` (Portal) langfristig neben `superadmin` bleibt.
|
||||||
|
3. Ob offizielle Inhalte für Bewerber **nie** lesbar bleiben (aktuell: ja).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Referenzen
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| `CAPABILITY_CATALOG.v1.md` | Capability-IDs, Account-States |
|
||||||
|
| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Feature-Registry, Kontingente |
|
||||||
|
| `backend/club_features.py` | Vereins-Features |
|
||||||
|
| `backend/capabilities.py` | Capability-Auflösung |
|
||||||
|
| `backend/account_lifecycle.py` | Account-Gates |
|
||||||
|
|
||||||
|
## 7. Superadmin im Verein (FAQ)
|
||||||
|
|
||||||
|
Siehe **`docs/working/RBAC_ENFORCEMENT_ROADMAP.md` §4**: Plattform-Admin (`admin`, `superadmin`) erhält **Capability-Bypass** für Vereins-Funktionen ohne `club_admin`-Mitgliedschaft. Mandant über aktiven Verein wählen; Kontingente via Bypass. Einzelne Legacy-Pfade (z. B. Löschen `visibility=club`) sind noch nicht vereinheitlicht — Ziel Phase 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Changelog**
|
||||||
|
|
||||||
|
- 2026-06-06: Initial — Entscheidungen Onboarding, Rollen-Risiko, Kontingente, Trainer-Budget v2; Ist-Stand M1–M3; Roadmap A–F.
|
||||||
|
- 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation.
|
||||||
|
- 2026-06-07: M4–M6 Ist-Stand, Roadmap-Verweis, Superadmin-FAQ; Admin-Matrix UX + Enforcement-Audit.
|
||||||
|
- 2026-06-08: Roadmap Phase 5b / M9 — Vereinsadmin-Kontingentverteilung pro Person; Enforce Dev verifiziert (0.8.202).
|
||||||
|
|
@ -227,7 +227,9 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie
|
||||||
## 8. Verwandtes Dokument
|
## 8. Verwandtes Dokument
|
||||||
|
|
||||||
- **`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`** – verbindliche Umsetzungsstufen A–F, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt.
|
- **`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`** – verbindliche Umsetzungsstufen A–F, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt.
|
||||||
|
- **`CAPABILITY_CATALOG.v1.md`** – Rollen, Capability-IDs, Account-Lifecycle, Endpoint-Mapping.
|
||||||
|
- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** – Vereinsabo, Feature-Registry (Mitai-v9c-Pattern), Kontingente.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2026-05-05
|
**Letzte Aktualisierung:** 2026-06-06
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
|
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
|
||||||
| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
|
| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
|
||||||
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
|
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
|
||||||
|
| profiles | `POST /api/profiles/{id}/totp-reset` | Plattform | `get_tenant_context` | nur `superadmin`, nicht das eigene Konto | Schaltet TOTP ab, löscht Wiederherstellungscodes, beendet Sitzungen |
|
||||||
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
|
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
|
||||||
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
|
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
|
||||||
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
|
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
|
||||||
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
||||||
| exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
|
| exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
|
||||||
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
|
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
|
||||||
|
| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; OpenRouter — suggest optional `focus_areas_context` für Retrieval-Profile |
|
||||||
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
|
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
|
||||||
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
|
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
|
||||||
| dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) |
|
| dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) |
|
||||||
|
|
@ -32,18 +34,28 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
|
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
|
||||||
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
|
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
|
||||||
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
|
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
|
||||||
|
| matrix_editor | `/api/admin/matrix-editor/*` (Export/Import Editor-Bundle) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale Fähigkeitsmatrix ohne Mandantenkontext |
|
||||||
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
||||||
|
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
||||||
|
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
|
||||||
|
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
|
||||||
|
| admin_user_content | `/api/admin/user-content/*` (Meta, Nutzer-Summary, Items, PATCH, DELETE) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; Moderation nutzerangelegter Inhalte inkl. privat; kein TenantContext |
|
||||||
|
|
||||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||||
|
|
||||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
||||||
|
|
||||||
Letzte Änderung: 2026-05-13 — `GET /api/dashboard/kpis` (Kurzüberblick-Aggregat).
|
Letzte Änderung: 2026-06-06 — Superadmin `/api/admin/user-content/*` (Nutzer-Inhalte Moderation).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Changelog (Fortführung)
|
### Changelog (Fortführung)
|
||||||
|
|
||||||
|
- **2026-05-23:** Superadmin-API `exercise_enrichment_admin` (Batch-Übungs-Anreicherung KI) dokumentiert.
|
||||||
|
- **2026-05-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert.
|
||||||
|
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
||||||
|
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||||
|
|
||||||
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
|
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
|
||||||
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||||
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||||
|
|
|
||||||
67
.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
Normal file
67
.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Umsetzungsplan – KI bei Übungen (stufenweise, Driftschutz)
|
||||||
|
|
||||||
|
**Version:** 0.2
|
||||||
|
**Datum:** 2026-05-29
|
||||||
|
**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · **`working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`** · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Drift vermeiden – verbindliche Regeln
|
||||||
|
|
||||||
|
1. **Spec vor Code:** Request/Response-Felder und Statuscodes an `KI_FEATURES_SPEC.md` ausrichten; Abweichungen zuerst Spec oder dieses Dokument anpassen.
|
||||||
|
2. **Prompts in der DB:** Keine produktionskritischen Prompt-Langtexte nur im Code; Defaults per **Migration** in `ai_prompts`, Anpassung durch Admins über vorgesehene Oberfläche (später) oder SQL.
|
||||||
|
3. **Skill-Retrieval-Profile:** Gewichte/Quotes in **`ai_skill_retrieval_profiles.config`** — Spezifikation `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; kein zweites gleichzeitiges Truth-Repo im Sourcecode außer defensiver Fallback `_FALLBACK_RETRIEVAL_CONFIG` in `exercise_ai.py`.
|
||||||
|
4. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen.
|
||||||
|
5. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec).
|
||||||
|
6. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`.
|
||||||
|
7. **Schema:** Neue DB-Objekte nur nummerierte Migration **`backend/migrations/`** (aktuell bis **068**) und `DB_SCHEMA_VERSION` anheben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stufen (Releases)
|
||||||
|
|
||||||
|
| Stufe | Inhalt | Exit-Kriterium |
|
||||||
|
|-------|--------|------------------|
|
||||||
|
| **S0** | Dieses Dokument + Verweise konsistent | Review abgehakt |
|
||||||
|
| **S1** | Migration `ai_prompts` + Defaults `exercise_summary`, `exercise_skill_suggestions`; `exercises.summary_ai_generated` | Migrierte DB, App startet |
|
||||||
|
| **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key |
|
||||||
|
| **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key |
|
||||||
|
| **S4** | Frontend: KI-Vorschlag, **Änderungsdialog** (Vorschau, Kurzfassung wählbar, Fähigkeiten pro Zeile an-/abwählbar), dann Übernahme ins Formular | Manuelle UX-Prüfung |
|
||||||
|
| **S4b** | **Skill-Retrieval:** Migration **`ai_skill_retrieval_profiles`**, `focus_areas_context` am **`POST …/ai/suggest`**, `exercise_ai` kontextbezogener Katalog (Gewichte, Caps, Keyword-Patches) | Migration 068 angelegt; Smoke mit Gewaltschutz / ohne Fokus |
|
||||||
|
| **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config |
|
||||||
|
| **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics |
|
||||||
|
|
||||||
|
**Aktueller Implementierungsstand:** **S4 + S4b** im Code (`exercise_ai` + Formular übermittelt `focus_areas_context`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementierungs-Checkliste (Technik)
|
||||||
|
|
||||||
|
- [ ] `OPENROUTER_API_KEY` / `OPENROUTER_MODEL` in `.env.example` dokumentiert (bereits teils vorhanden – prüfen).
|
||||||
|
- [ ] Fehlerbilder: `400` zu wenig Inhalt, `503` KI nicht konfiguriert, `502` Upstream-Fehler mit kurzer Message.
|
||||||
|
- [ ] Logging: **keine** vollständigen Prompts mit personenbezogenen Daten in Prod-Logs (optional DEBUG).
|
||||||
|
- [ ] Optional: Rate-Limit KI-Endpunkte (`slowapi`) – nach Bedarf.
|
||||||
|
- [ ] `MODULE_VERSIONS["exercises"]` / Changelog bei API-Erweiterung setzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Changelog dieses Plans
|
||||||
|
|
||||||
|
- **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad.
|
||||||
|
- **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen.
|
||||||
|
- **2026-05-29:** **S4b:** Migration **068**, `ai_skill_retrieval_profiles`; suggest `focus_areas_context`; Frontend sendet gesetzte Fokusbereiche; Spec `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Umsetzungsstand (Zwischencheckpoint)
|
||||||
|
|
||||||
|
**Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert).
|
||||||
|
|
||||||
|
**Erledigt (2026-05-29):** Migration **`068`** / Profil **`ai_skill_retrieval_profiles`** (Standard + Profil Gewaltschutz wenn `focus_areas.name` vorhanden); **`exercise_ai`** — Score/Kategorie-Zapfen/Text-Overlap/Keyword-Zuschläge; **API:** `ExerciseAiSuggestBody.focus_areas_context`; **Regenerate** nutzt DB-Fokuszeilen.
|
||||||
|
|
||||||
|
**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen.
|
||||||
|
|
||||||
|
**Offen nächste Schritte Pflege/Umsetzung:** weitere Retrieval-Profile (z. B. Karate-/Fitness-Schwerpunkt) per SQL später Admin-UI; optionales Feld **`skills.ai_context`** Kurzbeschreibung für KI; automatische KI beim Speichern (**S5**); Prompt-/Profil-Admin-UI ohne SQL; Rate-Limits.
|
||||||
|
|
||||||
|
**Bewusst noch nicht (`summary_ai_generated`):** zurücksetzen bei manueller Kurzfassung im UI; Admin-Pflege `ai_skill_retrieval_profiles`.
|
||||||
|
|
||||||
124
.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md
Normal file
124
.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Mehrstufige KI für Trainingsplanung – Architektur-Vorschau (Anti-Refactoring)
|
||||||
|
|
||||||
|
**Version:** 0.1
|
||||||
|
**Datum:** 2026-05-22
|
||||||
|
**Status:** Planungs-/Architektur-Arbeitspapier (keine Implementierungspflicht)
|
||||||
|
**Ziel:** Für die **spätere** Planungs-KI bereits **Schnittstellen und Schichten** vorzeichnen, damit die **kleinere, starre** Übungs-KI nicht zur impliziten Vorlage für einen viel größeren Kopf wird — **ohne** jetzt eine Mitai-artige Workflow-Engine zu bauen.
|
||||||
|
|
||||||
|
**Update 2026-06-07:** Progressionsgraph startet **Phase F** (`planning_progression_roadmap.py`) — Roadmap-first, Workflow-lite. Siehe **`PLANNING_PROGRESSION_ROADMAP_SPEC.md`** und **`docs/architecture/PLANNING_KI_ROADMAP.md`**. Gruppenanalyse bleibt in der **Trainingsplanungs-Pipeline** (§3 S0–S4), nicht im Graphen.
|
||||||
|
|
||||||
|
**Bezüge:** `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/SKILL_SCORING_SPEC.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-003) · Schwesterprojekt Mitai: `c:/dev/mitai-jinkendo` (Referenz: `prompt_executor`, `placeholder_resolver`, `workflow_*` — **nicht** Pflicht-Port).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zwei getrennte Produktlinien (bewusst entkoppelt)
|
||||||
|
|
||||||
|
| Linie | Rolle | Orchestrator |
|
||||||
|
|--------|--------|----------------|
|
||||||
|
| **Übungs-KI** | wenige Eingaben → Kurzfassung / Skills; **starrer** Ablauf (1–2 Calls), kleines Kontextfenster | z. B. `exercise_ai.py` (heute) |
|
||||||
|
| **Planungs-KI** | Gruppe, Zeit, Ziele, Historie, Katalogausschnitt, Phasen/Streams → **strukturierte Planelemente** | **eigenes** Modul + **mehrstufig** (siehe §3) |
|
||||||
|
|
||||||
|
**Regel:** Shared Library nur auf **niedriger Ebene** (`openrouter_chat`-Art: HTTP, Timeouts, Modellname, Fehler-Mapping) und **gemeinsame Prompt-Tabelle** `ai_prompts`. **Keine** Vermischung der Geschäftslogik „Übung erstellen“ mit „Einheit füllen“, um später keine Abhängigkeiten reißen zu müssen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Konzeptioneller „Planungs-Graph“ (Daten, nicht zwingend Graph-DB)
|
||||||
|
|
||||||
|
Für die Planungs-KI ist ein **Graph als Denkmodell** hilfreich — technisch reicht meist **PostgreSQL + bestehende FKs** (+ optional `exercise_progression_graphs`):
|
||||||
|
|
||||||
|
**Knoten-Typen (Auszug):** `training_groups`, `training_units`, `training_unit_sections` / Items, `exercises`, `skills`, `training_framework_programs` / Slots / Goals, ggf. Nachbearbeitungs-/Debrief-Metadaten.
|
||||||
|
|
||||||
|
**Kanten-Typen (Auszug):**
|
||||||
|
|
||||||
|
- **Zeitliche Folge:** Einheiten einer Gruppe nach `planned_date` / Reihenfolge
|
||||||
|
- **Inhalt:** Section-Item → `exercise_id` (± Variante)
|
||||||
|
- **Ziele:** Slot-/Framework-Ziele, Kopf-Notizen, Trainer-Zieltexte
|
||||||
|
- **Progression:** Kanten aus `exercise_progression_graphs` (optional erweitern um „empfohlene Folge im Gruppenkontext“, bleibt Spekulationsfeld)
|
||||||
|
- **Skills:** bereits über `exercise_skills`; aggregiert über `skill_scoring`-Pfad
|
||||||
|
|
||||||
|
**Wichtig:** Für KI **nicht** einen Riesen-Graphen serialisieren, sondern **Projektionen** („letzte *N* Einheiten“, „Nachbarn im Progressionsgraph zu zuletzt verwendeten Übungen“, „Skill-Gap Heuristik“).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mehrstufiger Prozess (Pflichtidee für Planungs-KI)
|
||||||
|
|
||||||
|
Statt einem Prompt „mach den ganzen Plan“ mehrere **Schritte mit kleinen, validierbaren Outputs**:
|
||||||
|
|
||||||
|
| Stufe | Beispiel-Aufgabe | Deterministisch möglich? | Typischer LLM-Einsatz |
|
||||||
|
|-------|-------------------|--------------------------|------------------------|
|
||||||
|
| **S0** | Governance + Filter + Historie + Slot-Ziele zusammenstellen | Ja (SQL/API) | Nein |
|
||||||
|
| **S1** | Kandidaten-Übungen auf Top‑K schrumpfen (Skills, Volltext, Score, Wiederholungsstrafe) | Teilweise | Optional Ranking |
|
||||||
|
| **S2** | Reihenfolge je Section / Phase unter Constraints (Aufwärmen, Graphen-Nachbarn) | Teilweise | Ja (auf kleiner Liste) |
|
||||||
|
| **S3** | Zeiten auf Section/Item vorschlagen oder Plausibilisieren | Teilweise | Ja |
|
||||||
|
| **S4** | Trainer-sprachliche Kurzbegründung / Alternativen | Nein | Ja |
|
||||||
|
|
||||||
|
**Zwischen jeder Stufe:** starkes **Schema / Validierung** (z. B. nur erlaubte `exercise_id`s, nur erlaubte Slot-Struktur zu Phasen/Streams). So bleibt das System auch bei Modell-Fehlern stabil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Schnittstellen-Vorsorge im Code (ohne Big-Bang)
|
||||||
|
|
||||||
|
Minimal-Ausbaustufe später, die Refactoring vermeidet:
|
||||||
|
|
||||||
|
1. **`PlanningContextPack` (internes DTO)** — reines Python-`dict`/`dataclass` oder Pydantic: aggregierte, **tokenbewusst gekürzte** Ansicht (Gruppe, nächste Einheit-Ziele, Historie-IDs, Top‑K-Kandidaten, Constraints).
|
||||||
|
2. **`planning_ai_steps` als rein **funktionale** Pipeline** — jede Stufe `(context) → context` oder `(context) → partial_suggestion`; keine globale „Prompt-String-Bastelei“ überall im Router.
|
||||||
|
3. **Prompt-Slugs pro Stufe** in `ai_prompts` (analog Übung), z. B. `planning_rank_section_items`, `planning_explain_sequence`, mit **eigenem** Platzhalter-Katalog (nicht `{{skills_catalog}}` aus Übungen recyclen).
|
||||||
|
4. **Router** `training_planning.py` (oder neuer `planning_ai.py`): nur **dünne** HTTP-Schicht, ruft Orchestrator.
|
||||||
|
|
||||||
|
Optional **später**, wenn nötig: zweite Tabelle `ai_prompt_chains` oder externe Workflow-Definition — **erst** wenn 3–4 feste Stufen nicht mehr reichen. Mitai-Workflow-Engine dann **bewusste** Option, kein Default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Kontextfenster und „Kaskade“
|
||||||
|
|
||||||
|
**Kerngedanke:** Je Stufe nur **neue** Information hinzufügen, die vorherige Stufen **ersetzen** oder **verdichten**, nicht duplizieren.
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
- Stufe A (LLM oder Heuristik): „Priorisierte Skill-Ziele für diese Session“ (kurz)
|
||||||
|
- Stufe B: Top‑40 Übungen mit **einer** Zeile pro Übung
|
||||||
|
- Stufe C: Reihenfolge für 8 IDs + 2-Satz-Begründung
|
||||||
|
|
||||||
|
So bleibt dieselbe fachliche Tiefe erreichbar ohne Kontext-Explosion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Schnittstellen zu bereits vorhandenen Bausteinen
|
||||||
|
|
||||||
|
- **`skill_profiles` / `skill-discovery`:** liefern **deterministische** Ziel-/Profil-Signale für S0/S1 (`SKILL_SCORING_SPEC.md`).
|
||||||
|
- **`training_planning_prefs`:** weiche Constraints (Tone, Dauer, Split-Vorlieben).
|
||||||
|
- **`exercise_progression_graphs`:** lokale Nachbarschaft um „zuletzt verwendet“.
|
||||||
|
- **Mitai-Referenz:** Platzhalter-Katalog + Preview-API als **Inspiration** für Admin-UX; Workflow-Graph nur wenn Shinkan **wirklich** viele verzweigte Pipelines braucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Was wir **nicht** jetzt tun müssen
|
||||||
|
|
||||||
|
- Keine zweite Graph-Datenbank nur für KI.
|
||||||
|
- Keine Workflow-UI-Kopie aus Mitai.
|
||||||
|
- Keine Vereinheitlichung der Übungs-KI mit Planungs-KI über einen „Mega-Orchestrator“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Kurz-Checkliste „Refactoring vermeiden“ vor erster Planungs-KI-Zeile Code
|
||||||
|
|
||||||
|
- [ ] Eigenes Modulbaum-„Root“ für Planung (nicht `exercise_ai` erweitern).
|
||||||
|
- [ ] Prompt-Slugs mit **Planungs-**Präfix und **eigenem** Platzhalter-Set dokumentieren.
|
||||||
|
- [ ] Outputs pro Stufe **JSON-Schema** oder Pydantic validieren.
|
||||||
|
- [ ] Kandidatenlisten **immer** serverseitig auf erlaubte IDs begrenzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Progressionsgraph vs. Trainingsplanung (2026-06-07)
|
||||||
|
|
||||||
|
| Pipeline | Kontext | Orchestrator |
|
||||||
|
|----------|---------|--------------|
|
||||||
|
| **Progressionsgraph (F)** | Zieltext, N Steps, Semantic Brief | `planning_progression_roadmap.py` |
|
||||||
|
| **Trainingsplanung (G, später)** | Gruppe, Historie, Rahmen, Zeit | `planning_ai_steps` + ggf. Mitai Workflow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Changelog
|
||||||
|
|
||||||
|
- **2026-06-07:** Verweis Phase F Roadmap-first; Abgrenzung Graphen/Planung.
|
||||||
|
- **2026-05-22:** Erstfassung als Vorschau-Dokument für mehrstufige Planungs-KI.
|
||||||
121
.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
Normal file
121
.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# KI Skill-Retrieval-Profile (`ai_skill_retrieval_profiles`)
|
||||||
|
|
||||||
|
**Version:** 0.1
|
||||||
|
**Datum:** 2026-05-29
|
||||||
|
**Status:** Umsetzung gestartet (Migration **068**)
|
||||||
|
**Ziel:** Für `POST /api/exercises/ai/suggest` (Skill-Katalogauszug) **Gewichte und Quoten** steuerbar machen:
|
||||||
|
|
||||||
|
- gebunden an **Übungs-Fokusbereich** (`focus_areas.id`),
|
||||||
|
- ein **Standardprofil** ohne Fokus,
|
||||||
|
- **optional zusammengeführte** Profile bei mehreren Fokusbereichen,
|
||||||
|
- **optional Keyword-Übersteuerungen** aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung).
|
||||||
|
|
||||||
|
**Technische Basis:** Skills mit `skills.main_category_id` → `skill_main_categories.slug` (`karate` | `allgemeine`) und `skills.category_id` → `skill_categories.slug` (`kondition`, `selbstverteidigung`, …).
|
||||||
|
|
||||||
|
**Bezüge:** `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md` · `backend/exercise_ai.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Datenmodell
|
||||||
|
|
||||||
|
### Tabelle `ai_skill_retrieval_profiles`
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|--------|-----|--------------|
|
||||||
|
| `id` | serial | Primärschlüssel |
|
||||||
|
| `focus_area_id` | int NULL FK → `focus_areas(id)` ON DELETE SET NULL | **`NULL`** nur für Standardeintrag möglich (siehe `is_default`) |
|
||||||
|
| `is_default` | boolean | Genau **eine** Zeile mit `true` |
|
||||||
|
| `name` | varchar | Kurzer Name (Admin später) |
|
||||||
|
| `description` | text | Hinweise für Pflege |
|
||||||
|
| `active` | boolean | Nur aktive werden geladen |
|
||||||
|
| `config` | jsonb | Siehe §2 |
|
||||||
|
|
||||||
|
**Constraints / Indizes**
|
||||||
|
|
||||||
|
- Eindeutig: `(focus_area_id)` WHERE `focus_area_id IS NOT NULL`
|
||||||
|
- Eindeutig: `(is_default)` WHERE `is_default = true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. JSON-Konfiguration `config.version = 1`
|
||||||
|
|
||||||
|
Alle Schlüssel **optional**; fehlende Werte fallen auf **einprogrammierten Fallback** in `exercise_ai.py` zurück (entspricht bisher grob „neutral“).
|
||||||
|
|
||||||
|
### 2.1 Gewichtungen (Ranking)
|
||||||
|
|
||||||
|
| Schlüssel | Typ | Bedeutung |
|
||||||
|
|-----------|-----|------------|
|
||||||
|
| `main_slug_weights` | `object[str, float]` | Multiplikator pro Hauptkategorie-Slug (`karate`, `allgemeine`) |
|
||||||
|
| `category_slug_weights` | `object[str, float]` | Multiplikator pro `skill_categories.slug` |
|
||||||
|
|
||||||
|
Basis-Score (vereinfacht):
|
||||||
|
`(importance oder 3) × main_w × cat_w × text_overlap_bonus × importance_multiplier`
|
||||||
|
|
||||||
|
### 2.2 Kapazitätsbegrenzung (Liste)
|
||||||
|
|
||||||
|
`_MAX_SKILLS_CATALOG_LINES` (aktuell **240**) Zeilen Gesamt:
|
||||||
|
|
||||||
|
| Schlüssel | Typ | Bedeutung |
|
||||||
|
|-----------|-----|------------|
|
||||||
|
| `category_max_share` | `object[str, float]` | Max. Anteil dieser **Unterkategorie** am Endergebnis (0–1), z. B. `{ "kondition": 0.25 }` |
|
||||||
|
| `main_min_share` | `object[str, float]` | Mindest-Zielanteil Hauptkategorie beim **Auswahl-Greedy** (weich; Rest nach Score aufgefüllt) |
|
||||||
|
|
||||||
|
### 2.3 Text / Token-Sparen
|
||||||
|
|
||||||
|
| Schlüssel | Typ | Standard | Bedeutung |
|
||||||
|
|-----------|-----|----------|------------|
|
||||||
|
| `description_plain_max_len` | int | 160 | Gekürzte Beschreibung pro Zeile |
|
||||||
|
| `karate_relevance_max_len` | int | **0** oder 80 | **`0`** = Feld `karate_relevance`/`relevance_level` in der Promptzeile **weglassen** |
|
||||||
|
|
||||||
|
### 2.4 Keyword-Overrides (optional)
|
||||||
|
|
||||||
|
Liste `keyword_overrides`: jedes Element:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"keywords_any": ["befreiung", "haltegriff"],
|
||||||
|
"case_insensitive": true,
|
||||||
|
"patch": {
|
||||||
|
"category_slug_weights": { "selbstverteidigung": 2.5 },
|
||||||
|
"category_max_share": { "koordination": 0.1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Textsuche in verkettetem Korpus **Titel, Ziel, Durchführung, Focus-Hint** (bereits plaintext). Reihenfolge: erst Basis-Profile zusammenmergen, dann **alle treffenden Overrides**‑`patch`‑Objekte **flach zusammenführen** (Gewichte multiplikativ übereinander, Caps den strengsten Wert nehmen – aktuelle Implementierung im Code dokumentiert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mehrere Fokusbereiche auf der Übung
|
||||||
|
|
||||||
|
Request-Body: `focus_areas_context: [{ "focus_area_id": n, "is_primary": bool }, …]`
|
||||||
|
|
||||||
|
**Aktuelle Merge-Strategie (v1):** Profile laden → **gleichgewichtete Mittelwert-Bildung** der numerischen Gewichte / Caps (implementiert für `main_slug_weights`, `category_slug_weights`, `category_max_share`, `main_min_share`, `*_max_len`). Anschließend **Keyword-Overrides** anwenden.
|
||||||
|
|
||||||
|
**Primär-Fokus:** Im Frontend soll die **primäre** Zeile aus `focus_areas_multi` **zuerst** in der Liste stehen; die Merge-Strategie kann später zu „Primär dominate“ erweitert werden.
|
||||||
|
|
||||||
|
Ohne Kontext oder ohne Treffer auf aktive Profile: **nur Standardprofil** (`is_default`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Seed-Daten (Migration)
|
||||||
|
|
||||||
|
- **`is_default=true`:** ausgewogene Standard-Gewichte, moderate Caps auf `kondition`/`koordination`, Karate-Relevanz gekürzt.
|
||||||
|
- **`Gewaltschutz`:** `focus_area_id` per `(SELECT id FROM focus_areas WHERE name = 'Gewaltschutz' LIMIT 1)` — höhere Gewichte für `kognition`, `psychische_faehigkeiten`, `soziale_faehigkeiten`, `selbstverteidigung`; gedrosseltes `kondition`/`koordination`; `karate_relevance_max_len`: 0; Keyword-Patches wie oben können nachgeschärft werden.
|
||||||
|
|
||||||
|
Weitere Profile (Karate-Schwerpunkt etc.) später per Admin-SQL oder UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API
|
||||||
|
|
||||||
|
`ExerciseAiSuggestBody` erweitert um **`focus_areas_context`** (Liste). Feld **`focus_area_hint`** bleibt für den **Prompt-Kontext** (bestehende Prompts).
|
||||||
|
|
||||||
|
`POST …/ai/regenerate` nutzt gespeicherte `exercise_focus_areas` zur gleichen Retrieval-Logik wie Suggest.
|
||||||
|
|
||||||
|
**Pflege der Profile:** Superadmin ohne Mandantenwahl — **`GET|POST /api/admin/ai-skill-retrieval-profiles`**, **`GET|PUT|DELETE /api/admin/ai-skill-retrieval-profiles/{id}`** (`routers/ai_skill_retrieval_admin.py`); Web-UI Superadmin unter **`/admin/ai-skill-retrieval`**.
|
||||||
|
|
||||||
|
## 6. Changelog
|
||||||
|
|
||||||
|
- **2026-05-29:** Superadmin-Pflege-Endpoints + UI‑Route dokumentiert (`/admin/ai-skill-retrieval`).
|
||||||
|
- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration.
|
||||||
68
.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md
Normal file
68
.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Superadmin: Übungs-Anreicherung per KI
|
||||||
|
|
||||||
|
Stand: 2026-05-23 · App 0.8.178
|
||||||
|
|
||||||
|
## Zweck
|
||||||
|
|
||||||
|
Plattform-weites Werkzeug für Superadmins, um Übungen (typisch `draft`, ohne Skills) **batchweise** per KI mit Fähigkeiten anzureichern und kontrolliert auf `in_review` zu setzen.
|
||||||
|
|
||||||
|
Verbessert indirekt die Planungs-KI (`POST /api/planning/exercise-suggest`), die gegen Skill-Profile rankt — unvollständige `exercise_skills` führen dort zu Volltext-dominiertem Ranking.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
- Route: `/admin/exercise-enrichment` (nur Superadmin)
|
||||||
|
- Admin-Menü: „Übungs-Anreicherung“
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Prefix: `/api/admin/exercise-enrichment`
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| GET | `/candidates` | Paginierte Kandidaten (Filter: status, visibility, focus_area, without_skills, with_ai_suggested_skills, include_club, search) |
|
||||||
|
| POST | `/preview` | Dry-Run — `{ exercise_ids[], modes: { skills, summary }, merge_mode }` |
|
||||||
|
| POST | `/apply` | `{ items: [{ exercise_id, merged_skills }], merge_mode, set_status }` |
|
||||||
|
|
||||||
|
Auth: `require_auth` + `is_superadmin` — **kein** `TenantContext` (EXEMPT, siehe ACCESS_LAYER_ENDPOINT_AUDIT.md).
|
||||||
|
|
||||||
|
## KI
|
||||||
|
|
||||||
|
Wiederverwendet `run_exercise_form_ai_suggestion` → Prompts `exercise_skill_suggestions` (MVP Pflicht), optional `exercise_summary`. Skill-Katalog via `build_contextual_skills_catalog_block` / `ai_skill_retrieval_profiles`.
|
||||||
|
|
||||||
|
## Merge-Modi (Skills)
|
||||||
|
|
||||||
|
- `additive` (Default): manuelle Skills bleiben; KI ergänzt neue; bestehende `ai_suggested`-Links werden aktualisiert
|
||||||
|
- `replace_ai_only`: nur `ai_suggested=true` entfernen, dann KI-Set anwenden
|
||||||
|
- `replace_all`: alle Skills ersetzen (explizit)
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
- Kandidaten: **Status** primär (Default `draft`); Sichtbarkeit Default **`private`**, wählbar bis „Alle“
|
||||||
|
- Skill-Merge Default: **`replace_all`** (alle Skills KI-neu, `ai_suggested=true` — unterscheidbar von manuell)
|
||||||
|
- Nach Apply: `set_status=in_review` (nie automatisch `approved`)
|
||||||
|
- Batch: keine Gesamtgrenze (bis 10.000 IDs); **Analyze** + explizite Nutzerbestätigung
|
||||||
|
- **Preview:** max. **3 Übungen/HTTP-Request** (parallel LLM), Frontend chunked — vermeidet Gateway-504 (~60s Fritz!Box)
|
||||||
|
- **Apply:** HTTP-Chunks à 25 (nur DB, kein LLM)
|
||||||
|
|
||||||
|
## Inhalte (modular)
|
||||||
|
|
||||||
|
| Modus | Prompt | Apply-Felder |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| Skills | `exercise_skill_suggestions` | `exercise_skills` inkl. Intensität, required/target_level, `ai_suggested` |
|
||||||
|
| Summary | `exercise_summary` | `summary`, `summary_ai_generated=true` |
|
||||||
|
| Anleitung | `exercise_instruction_rewrite` | `goal`, `execution`, `preparation`, `trainer_notes` |
|
||||||
|
|
||||||
|
## API (ergänzt)
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| GET | `/candidate-ids` | Alle IDs zum Filter (Select-all) |
|
||||||
|
| POST | `/analyze` | `{ exercise_ids[], modes }` → Kosten-Schätzung vor Start |
|
||||||
|
|
||||||
|
## Keine Migration
|
||||||
|
|
||||||
|
Bestehende Spalte `exercise_skills.ai_suggested` reicht; kein Enrichment-Log in MVP.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`backend/tests/test_exercise_enrichment_admin.py` — 403, Merge-Logik, Status draft→in_review.
|
||||||
529
.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
Normal file
529
.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
Normal file
|
|
@ -0,0 +1,529 @@
|
||||||
|
# Planungs-KI: Übungssuche & Kontext für Neu-Anlage
|
||||||
|
|
||||||
|
**Version:** 0.2
|
||||||
|
**Datum:** 2026-05-23
|
||||||
|
**Status:** P0–P2 ✅ · Phase A/B/B2 ✅ · **Phase C1–C3 ✅** · **Phase E ✅** (Semantik + Pfad-QA)
|
||||||
|
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ziel
|
||||||
|
|
||||||
|
Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können mit natürlichen Anfragen wie:
|
||||||
|
|
||||||
|
- „Vertiefung zu Übung XY“
|
||||||
|
- „Nächste sinnvolle Übung im Progressionsgraph Z“
|
||||||
|
- „Baut auf der bisherigen Planung auf — Reaktionsschnelligkeit mit Partnern“
|
||||||
|
- **Preset:** „Schlage mir die nächste Übung vor“
|
||||||
|
|
||||||
|
**Suche** (Bibliothek) und **Neu mit KI-Assistent** (Anlage) nutzen dasselbe **`PlanningExerciseContextPack`** — unterschiedliches Ergebnis (Treffer vs. Entwurf).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architektur (Mehrstufig)
|
||||||
|
|
||||||
|
| Stufe | Name | Technik | P0 |
|
||||||
|
|-------|------|---------|-----|
|
||||||
|
| **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ |
|
||||||
|
| **S1a** | Intent strukturieren | LLM `planning_exercise_search_intent` (Szenario-Pipeline) | ✅ P1 |
|
||||||
|
| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan + **Profil** | ✅ |
|
||||||
|
| **S1b+** | Profil-Vorselektion | `ExerciseMatchProfile` × `PlanningTargetProfile` | ✅ `profile_v1` |
|
||||||
|
| **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` |
|
||||||
|
| **S2** | Neu-Anlage | Bestehende `suggestExerciseAi` + Pack als Zusatzkontext | Später |
|
||||||
|
|
||||||
|
Zwischen jeder Stufe: **nur erlaubte `exercise_id`s** (Governance / Sichtbarkeit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Intent-Typen
|
||||||
|
|
||||||
|
| `intent_hint` | Bedeutung | Retrieval-Gewichtung (P0) |
|
||||||
|
|---------------|-----------|---------------------------|
|
||||||
|
| `suggest_next` | Nächste Übung (Default bei leerer/kurzer Query) | Progression + Skill-Overlap + Plan-Kontinuität |
|
||||||
|
| `progression_next` | Explizit Graph-Folge | Progression hoch |
|
||||||
|
| `deepen_exercise` | Vertiefung zu Anker-Übung | Skill-Overlap hoch, ähnlicher Fokus |
|
||||||
|
| `continue_plan_goal` | Auf bisherigen Plan aufbauen | Plan-Kontinuität, Wiederholungsstrafe |
|
||||||
|
| `free_search` | Freitext / Stichwort | Volltext hoch |
|
||||||
|
|
||||||
|
**S1a (später):** Freitext → JSON `{ intent, skill_hints[], requires_partner, level_hint, … }` validiert per Pydantic.
|
||||||
|
|
||||||
|
**P0:** `intent_hint` vom Client oder Keyword-Heuristik auf `query`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PlanningExerciseContextPack (S0)
|
||||||
|
|
||||||
|
Serverseitig aus Request + DB (tokenbewusst für spätere LLM-Stufen):
|
||||||
|
|
||||||
|
| Feld | Quelle | UI-Chip |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `unit_id`, Titel, `group_id`, Gruppenname | `training_units` + `training_groups` | Gruppe · Einheit |
|
||||||
|
| `section_order_index`, Abschnittstitel | `training_unit_sections` | Abschnitt |
|
||||||
|
| `planned_exercise_ids[]` | Items der Einheit (Reihenfolge) | „N Übungen im Plan“ |
|
||||||
|
| `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker |
|
||||||
|
| `anchor_skill_ids[]` | `exercise_skills` | (intern) |
|
||||||
|
| `progression_graph_id` | Request oder **Auto-Match** vom Anker (sichtbarer Graph mit passenden Ausgangskanten) | Graph |
|
||||||
|
| `progression_graph_name`, `progression_graph_auto_resolved` | Response `context_summary` | Graph (auto) |
|
||||||
|
| `anchor_exercise_variant_id` | Request / Abschnitt-Item / DB | (intern) |
|
||||||
|
| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker (variantenbewusst, Migration **034**) | (intern) |
|
||||||
|
| `progression_successor_variants` | `to_exercise_variant_id` pro Nachfolger | (intern) |
|
||||||
|
| `group_recent_exercise_ids[]` | Letzte Einheiten derselben Gruppe | Wiederholungsstrafe |
|
||||||
|
| `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) |
|
||||||
|
|
||||||
|
**Berechtigung:** `get_tenant_context` + `_assert_training_unit_permission` wie `GET /training-units/{id}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Hybrid-Retrieval (S1b, P0)
|
||||||
|
|
||||||
|
Kandidaten: sichtbare Übungen (`library_content_visibility_sql`), ohne `archived`, max. ~400 (recent).
|
||||||
|
|
||||||
|
**Score** (0–1, gewichtet nach Intent):
|
||||||
|
|
||||||
|
```
|
||||||
|
score = w_ft * fulltext_rank
|
||||||
|
+ w_prog * progression_hit
|
||||||
|
+ w_skill * skill_jaccard(anchor, candidate)
|
||||||
|
+ w_plan * plan_affinity
|
||||||
|
+ w_profile * profile_match(exercise, target)
|
||||||
|
+ w_repeat * (candidate in unit_plan ? -1 : 0)
|
||||||
|
+ w_group_repeat * (candidate in group_recent ? -0.5 : 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`profile_match`** (0–1): siehe §12–§13 — Katalog-Dimensionen + Skill-Gewichte + Skill-Gap.
|
||||||
|
|
||||||
|
**`reasons[]`** (regelbasiert, Deutsch): z. B. „Nachfolger im Progressionsgraph“, „Fähigkeiten passen zur Anker-Übung“, „Fokusbereich passend zum Planungsziel“, „Deckt Skill-Lücke im bisherigen Plan“, „Volltext-Treffer“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API
|
||||||
|
|
||||||
|
### `POST /api/planning/exercise-suggest`
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unit_id": 123,
|
||||||
|
"section_order_index": 0,
|
||||||
|
"phase_order_index": null,
|
||||||
|
"parallel_stream_order_index": null,
|
||||||
|
"anchor_exercise_id": 456,
|
||||||
|
"anchor_exercise_variant_id": 12,
|
||||||
|
"progression_graph_id": 7,
|
||||||
|
"query": "Schlage mir die nächste Übung vor",
|
||||||
|
"intent_hint": "suggest_next",
|
||||||
|
"limit": 20,
|
||||||
|
"exercise_kind_any": ["simple"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"context_summary": {
|
||||||
|
"unit_title": "…",
|
||||||
|
"group_name": "…",
|
||||||
|
"section_title": "Hauptteil",
|
||||||
|
"planned_count": 4,
|
||||||
|
"anchor_title": "Partner-Fangspiel"
|
||||||
|
},
|
||||||
|
"target_profile_summary": {
|
||||||
|
"sources": ["framework_catalog", "current_unit_plan", "anchor_exercise"],
|
||||||
|
"focus_areas": ["Reaktion & Abwehr"],
|
||||||
|
"top_skills": [{ "skill_id": 12, "name": "Reaktionsgeschwindigkeit", "weight": 1.0 }],
|
||||||
|
"has_skill_gap": true
|
||||||
|
},
|
||||||
|
"retrieval_phase": "profile_v1",
|
||||||
|
"intent_resolved": "suggest_next",
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"title": "…",
|
||||||
|
"summary": "…",
|
||||||
|
"score": 0.78,
|
||||||
|
"reasons": ["Nachfolger im Progressionsgraph", "Fokusbereich passend zum Planungsziel"],
|
||||||
|
"focus_area": "…"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modul:** `backend/planning_exercise_suggest.py` · `backend/planning_exercise_profiles.py` · Router `backend/routers/planning_exercise_suggest.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Frontend
|
||||||
|
|
||||||
|
| Ort | Verhalten |
|
||||||
|
|-----|-----------|
|
||||||
|
| `ExercisePickerModal` | Prop `planningContext` → Planungs-API statt reiner `listExercises`; Kontext-Chips; `reasons` unter Treffer |
|
||||||
|
| `TrainingUnitEditPage` | `planningContext` aus Einheit + Picker-Ziel (Anker = letzte Übung im Abschnitt) |
|
||||||
|
| **`ExercisesListPageRoot`** | Schalter **„Neu mit KI-Assistent“**: Planungs-KI-Suche (frei, ohne `unit_id`) + Neuanlage im Modal; **„+ Neu“** ausgeblendet |
|
||||||
|
| Rahmen / Kombi-Formular | analog, sobald `unit_id` / Slot-Blueprint bekannt |
|
||||||
|
| Übungsliste (ohne KI-Schalter) | weiter Volltext |
|
||||||
|
|
||||||
|
**Zweites Suchfeld** im Picker: Query = Volltext + ergänzender Begriff (ODER in P0 als Konkatenation an Backend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Neu-Anlage (Anbindung, Phase P1)
|
||||||
|
|
||||||
|
Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||||
|
|
||||||
|
- `planning_context` im Request-Body → `planning_context_json` in Übungs-Prompts (Migration **085**); Pfad-Builder + Picker ✅ **0.8.208**
|
||||||
|
- Kurzbeschreibung optional leer (freier Vorschlag) oder aus Intent/Skizze
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phasen-Roadmap
|
||||||
|
|
||||||
|
| Phase | Inhalt | Status |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| **P0** | Context-Pack, Hybrid-Score, API, Picker in Planung | ✅ |
|
||||||
|
| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
|
||||||
|
| **P1** | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil | ✅ |
|
||||||
|
| **P2 / B2** | LLM-Rerank bei engem Top-Feld (max. 2 Calls) | ✅ |
|
||||||
|
| **P3** | Skill-Discovery / Framework-Ziele im Pack | 🔲 |
|
||||||
|
| **A** | Voll-Library Hybrid-Ranking | ✅ **0.8.177** |
|
||||||
|
| **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** |
|
||||||
|
| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
||||||
|
| **C2** | Varianten in Trefferliste / Picker | ✅ **0.8.184** |
|
||||||
|
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** |
|
||||||
|
| **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** |
|
||||||
|
| **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** |
|
||||||
|
| **D** | Neu-Anlage: `planning_context` an `suggestExerciseAi` (Migration **085**) | ✅ **0.8.208** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Changelog
|
||||||
|
|
||||||
|
- **2026-05-23:** Phase C1 — Graph auto-match, variantenbewusste Nachfolger (`planning_exercise_progression.py`).
|
||||||
|
- **2026-05-23:** Phase B2 — Rerank bei engem Top-Feld; Phase B — Text-Signale; Phase A — Voll-Library (siehe §17–§19).
|
||||||
|
- **2026-05-22:** Erstfassung; P0 API + Planungs-Picker.
|
||||||
|
- **2026-05-22:** P0 implementiert (`planning_exercise_suggest.py`, Router, Picker); unsaved Formular-Plan noch nicht an API (nur persistierte Einheit).
|
||||||
|
- **2026-05-22:** P0.1 — `planning_exercise_profiles.py`, Profil-Score in Hybrid-Retrieval, `retrieval_phase: profile_v1`, `target_profile_summary`.
|
||||||
|
- **2026-05-22:** P2 — LLM-Rerank optional (`include_llm_rank`); Client `planned_exercise_ids[]`; Prompt Migration 072.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Bekannte Lücken & Backlog
|
||||||
|
|
||||||
|
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
|
||||||
|
- **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen.
|
||||||
|
- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer (**C2** — Dropdown + Graph-Vorschlag).
|
||||||
|
- **Graph-Builder (C3):** Ziel → Pfad vorschlagen → in Graph speichern — ✅ **0.8.185**
|
||||||
|
- **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**).
|
||||||
|
- **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score.
|
||||||
|
- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073).
|
||||||
|
- **Preset + LLM:** ✅ Erwartungs-LLM (074) bei Planungsbezug; Preset ohne Plan = kein Erwartungs-LLM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Szenario-Pipeline & Query-Erwartungsprofil (P1)
|
||||||
|
|
||||||
|
Komplexe Planungsanfragen brauchen **Schritte vor** dem Profil-Match — nicht jede Query ist gleich.
|
||||||
|
|
||||||
|
### 16.1 Szenario-Klassen
|
||||||
|
|
||||||
|
| `scenario_kind` | Typische Anfrage | LLM Intent? |
|
||||||
|
|-----------------|------------------|-------------|
|
||||||
|
| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Erwartungs-LLM (074) wenn Planungsbezug |
|
||||||
|
| `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) |
|
||||||
|
| `deepen` | Vertiefung Anker | Ja |
|
||||||
|
| `continue_plan` | Auf bisherigen Plan aufbauen | Ja |
|
||||||
|
| `additive_constraint` | Plan **+** Zusatz (z. B. Schnellkraft) | Ja |
|
||||||
|
| `free_search` | Offene Stichwortsuche | Ja |
|
||||||
|
|
||||||
|
**Routing:** `planning_exercise_target_pipeline.classify_planning_scenario()` → `should_run_llm_intent_pipeline()`.
|
||||||
|
|
||||||
|
### 16.2 Pipeline (Reihenfolge)
|
||||||
|
|
||||||
|
```
|
||||||
|
S0 Kontext-Pack
|
||||||
|
→ Heuristik-Intent + Szenario
|
||||||
|
→ [optional] LLM planning_exercise_search_intent
|
||||||
|
→ Basis PlanningTargetProfile (Rahmen, Plan, Anker, Gap)
|
||||||
|
→ Merge Query-Overlay (Katalog-IDs aus Hints)
|
||||||
|
→ Hybrid-Retrieval + Profil-Score
|
||||||
|
→ [optional] LLM-Rerank
|
||||||
|
```
|
||||||
|
|
||||||
|
Module: `planning_exercise_target_pipeline.py` · `planning_exercise_intent.py`
|
||||||
|
|
||||||
|
### 16.3 API (Erweiterung)
|
||||||
|
|
||||||
|
| Request | Default | Bedeutung |
|
||||||
|
|---------|---------|-----------|
|
||||||
|
| `include_llm_intent` | `true` | LLM nur wenn Szenario ≠ preset_next und Query nicht leer |
|
||||||
|
|
||||||
|
| Response | Bedeutung |
|
||||||
|
|----------|-----------|
|
||||||
|
| `scenario_kind` | Szenario-Klasse |
|
||||||
|
| `query_intent_summary` | intent, llm_applied, rationale, skill_hints_resolved |
|
||||||
|
| `intent_heuristic` | Heuristik vor LLM |
|
||||||
|
| `retrieval_phase` | z. B. `profile_v1+query_intent+llm_rank` |
|
||||||
|
|
||||||
|
**Prompt 073:** `planning_exercise_search_intent` — Ausgabe JSON mit `skill_hints`, `focus_hints`, `emphasis` (`additive`|`replace`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. LLM-Rerank (P2)
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
| Feld | Typ | Default | Bedeutung |
|
||||||
|
|------|-----|---------|-----------|
|
||||||
|
| `planned_exercise_ids` | `int[]` | — | Optional: Reihenfolge aus Formular (überschreibt DB-Plan) |
|
||||||
|
| `include_llm_rank` | `bool` | `true` (Client) | Backend gated (B2): Rerank nur bei engem Top-Feld, max. 2 LLM-Calls |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|------|------|
|
||||||
|
| `retrieval_phase` | `profile_v1` oder `profile_v1+llm_rank` |
|
||||||
|
| `llm_rank_applied` | `true` wenn LLM erfolgreich sortiert hat |
|
||||||
|
| `hits[].llm_rank` | optional: Position nach LLM (1…n) |
|
||||||
|
|
||||||
|
**Fallback:** Kein API-Key, inaktiver Prompt oder Parse-Fehler → Hybrid-Reihenfolge unverändert, `llm_rank_applied: false`.
|
||||||
|
|
||||||
|
**Prompt:** Migration **072**, Slug `planning_exercise_search_rank` — Kandidaten als JSON mit Titel, summary, goal (Plaintext), skills; Ausgabe `{ ranked_ids, reasons }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. ExerciseMatchProfile & PlanningTargetProfile (Phase 1)
|
||||||
|
|
||||||
|
Ziel: deterministische Vorselektion über **Profil-Dimensionen** statt nur Titel/Jaccard.
|
||||||
|
|
||||||
|
### 12.1 ExerciseMatchProfile (pro Übung)
|
||||||
|
|
||||||
|
| Feld | Quelle |
|
||||||
|
|------|--------|
|
||||||
|
| `focus_area_ids` | `exercise_focus_areas` (Primary = 1.0, sonst 0.85) |
|
||||||
|
| `style_direction_ids` | `exercise_style_directions` |
|
||||||
|
| `training_type_ids` | `exercise_training_types` |
|
||||||
|
| `target_group_ids` | `exercise_target_groups` |
|
||||||
|
| `skill_weights` | `exercise_skills` × Intensitäts-Multiplikator (`skill_scoring._skill_link_multiplier`) |
|
||||||
|
|
||||||
|
Bulk-Lader: `load_exercise_match_profiles_bulk(cur, exercise_ids)`.
|
||||||
|
|
||||||
|
### 12.2 PlanningTargetProfile (Planungsziel)
|
||||||
|
|
||||||
|
Zusammensetzung aus mehreren Quellen (`sources[]`):
|
||||||
|
|
||||||
|
| Quelle | Inhalt |
|
||||||
|
|--------|--------|
|
||||||
|
| `framework_catalog` | Fokus/Stil/Trainingsstil/Zielgruppe aus `training_framework_program_*` |
|
||||||
|
| `framework_slot_skill_profile` | Skill-Profil des Slot-Blueprints (`profile_for_occurrences`) |
|
||||||
|
| `framework_overall_skill_profile` | Fallback: alle Blueprint-Einheiten des Rahmens |
|
||||||
|
| `current_unit_plan` | Skill-Profil der bereits eingeplanten Übungen dieser Einheit |
|
||||||
|
| `anchor_exercise` | Katalog + Skills der Anker-Übung (Intent-abhängig) |
|
||||||
|
| `skill_gap_vs_plan` | `target_skills − plan_skills` (normalisiert, Schwelle > 0.08) |
|
||||||
|
|
||||||
|
Builder: `build_planning_target_profile(cur, unit=…, planned_exercise_ids=…, anchor_exercise_id=…, intent=…)`.
|
||||||
|
|
||||||
|
Rahmen-Anbindung über `unit.framework_slot_id` oder `origin_framework_slot_id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Profil-Score (Formeln)
|
||||||
|
|
||||||
|
**Gewichtete Überlappung** (Katalog + Skills):
|
||||||
|
|
||||||
|
```
|
||||||
|
overlap(a, b) = Σ min(a[k], b[k]) / Σ max(a[k], b[k])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skill-Gap-Abdeckung:**
|
||||||
|
|
||||||
|
```
|
||||||
|
gap_coverage(gap, candidate) = Σ min(gap[k], candidate[k]) / Σ gap[k]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Profil-Score** (intent-gewichtet, Summe Dimensionen = 1.0):
|
||||||
|
|
||||||
|
```
|
||||||
|
profile_score = w_focus * overlap(focus)
|
||||||
|
+ w_style * overlap(style)
|
||||||
|
+ w_tt * overlap(training_type)
|
||||||
|
+ w_tg * overlap(target_group)
|
||||||
|
+ w_skill * overlap(skill_weights)
|
||||||
|
+ w_gap * gap_coverage(skill_gap)
|
||||||
|
```
|
||||||
|
|
||||||
|
Intent-Gewichte (Auszug): `deepen_exercise` → Skill hoch; `continue_plan_goal` → Gap hoch; `free_search` → Gap + Skill moderat.
|
||||||
|
|
||||||
|
Scorer: `score_exercise_against_target(exercise_profile, target_profile, intent=…) → (score, reasons[])`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Hybrid + Profil (P0.1)
|
||||||
|
|
||||||
|
Im Hybrid-Score kommt **`w_profile * profile_score`** hinzu (Intent-abhängig ~0.15–0.35). Jaccard auf Anker-Skills bleibt parallel (schneller Anker-Fokus).
|
||||||
|
|
||||||
|
**Response-Felder:**
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank |
|
||||||
|
| `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) |
|
||||||
|
|
||||||
|
**Phase 2 (P2 / B2):** siehe §15 und §18 — `include_llm_rank: true` vom Client, Backend entscheidet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Phase A — Voll-Library-Ranking (0.8.177)
|
||||||
|
|
||||||
|
- Kein OR-Profil-Pool (~500 Übungen) mehr.
|
||||||
|
- Alle sichtbaren Übungen (bis 8000) werden hybrid gescored (`fetch_all_visible_exercise_rows` + `rank_visible_library_hits`).
|
||||||
|
- API: `full_library_ranked: true`, `retrieval_phase` enthält `+full_library+`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Phase B / B2 — Text-Signale & Rerank-Gates (0.8.181–0.8.182)
|
||||||
|
|
||||||
|
**B — Text-Signale (`planning_exercise_text_signals.py`):**
|
||||||
|
|
||||||
|
- `section_guidance_notes`, Rahmen-Ziele/Notizen → Skill-/Katalog-Gewichte ohne LLM.
|
||||||
|
- `requires_partner` aus Intent filtert Kandidaten.
|
||||||
|
- `retrieval_phase +text_signals`.
|
||||||
|
|
||||||
|
**B2 — Rerank bei unklarem Ranking:**
|
||||||
|
|
||||||
|
- `hybrid_ranking_ambiguous(hits)` (Top-4-/Top-10-Gap).
|
||||||
|
- Rerank auch nach Erwartungs-/Intent-LLM, wenn Scores eng beieinander.
|
||||||
|
- Budget: max. **2** LLM-Calls (Profil + optional Rerank).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Phase C1 — Progressionsgraph im Planungskontext (0.8.183)
|
||||||
|
|
||||||
|
**Modul:** `planning_exercise_progression.py`
|
||||||
|
|
||||||
|
### Auto-Match Graph
|
||||||
|
|
||||||
|
Wenn `progression_graph_id` fehlt und Anker-Übung gesetzt: sichtbarer Graph mit passender `next_exercise`-Kante vom Anker (variantenbewusst). Bevorzugung: variantenspezifische Kanten > Anzahl Kanten.
|
||||||
|
|
||||||
|
### Variantenbewusste Nachfolger (Migration 034)
|
||||||
|
|
||||||
|
Generische Kante (`from_exercise_variant_id IS NULL`) gilt für jeden Anker; variantenspezifische Kante nur bei passender Anker-Variante.
|
||||||
|
|
||||||
|
Treffer: optional `hits[].suggested_variant_id`.
|
||||||
|
|
||||||
|
### Request / Response
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `anchor_exercise_variant_id` | Request — Variante der Anker-Übung |
|
||||||
|
| `progression_graph_name` | Response — Name des (auto-)Graphs |
|
||||||
|
| `progression_graph_auto_resolved` | Response — Auto-Match aktiv |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Phase C2 — Varianten in Treffern (0.8.184) ✅
|
||||||
|
|
||||||
|
- API: `variants[]`, `suggested_variant_name` pro Treffer (Batch aus `exercise_variants`).
|
||||||
|
- **`ExercisePickerModal`:** Dropdown pro Treffer; Graph-`suggested_variant_id` vorausgewählt; Übernahme setzt `exercise_variant_id`.
|
||||||
|
- **`hydrateExercisePlanningRow`:** übernimmt `exercise_variant_id` / `suggested_variant_id` in die Planungszeile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. Phase C3 — Graph-Builder (0.8.185) ✅
|
||||||
|
|
||||||
|
**API:** `POST /api/planning/progression-path-suggest`
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `query` | Ziel / Entwicklungsrichtung (Freitext, min. 3 Zeichen) |
|
||||||
|
| `max_steps` | 2–10, Default 5 |
|
||||||
|
| `progression_graph_id` | optional — Graph-Kontext für Nachfolger ab Schritt 2 |
|
||||||
|
| `include_llm_intent` | LLM nur Schritt 1 (Budget) |
|
||||||
|
|
||||||
|
**Response:** `steps[]` mit `exercise_id`, `variant_id`, `title`, `reasons`, `variants`; `retrieval_phase: …+path_builder`.
|
||||||
|
|
||||||
|
**Algorithmus:** Iterativ Hybrid-Ranking — Schritt 1 aus Zielprofil, Folgeschritte mit Anker = letzte Übung, ohne Duplikate.
|
||||||
|
|
||||||
|
**UI:** `ExerciseProgressionPathBuilder` im Progressionsgraph-Panel — Review, Varianten, `POST …/edges/sequence`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. Phase E — Semantik-Schicht + Pfad-QA (0.8.186) ✅
|
||||||
|
|
||||||
|
### Semantic Brief (`planning_exercise_semantics.py`)
|
||||||
|
|
||||||
|
Parallel zum Katalog-Overlay — **nicht ersetzend**:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `primary_topic` | z. B. `mae geri` |
|
||||||
|
| `must_phrases` / `exclude_phrases` | Phrasen-Match in Titel/Ziel/Varianten |
|
||||||
|
| `development_arc` | einstieg → … → perfektion |
|
||||||
|
| `semantic_strength` | 0–1 — steuert dynamisches Blend im Hybrid-Score |
|
||||||
|
| `retrieval_query` | fokussierte Volltext-Query (nicht ganzer Satz) |
|
||||||
|
|
||||||
|
Optional LLM: Prompt `planning_exercise_query_semantics` (Migration **075**).
|
||||||
|
|
||||||
|
**Hybrid-Score:** neuer Term `w_semantic * semantic_score` — Profil/Volltext werden bei hoher `semantic_strength` relativ abgeschwächt.
|
||||||
|
|
||||||
|
### Pfad-QA (`planning_exercise_path_qa.py`)
|
||||||
|
|
||||||
|
Nach Pfad-Bildung:
|
||||||
|
|
||||||
|
1. **Lücken-Messung** zwischen benachbarten Schritten (Skill-Jaccard + Semantik zum erwarteten Phasen-Segment)
|
||||||
|
2. **Brücken-Übungen** bei großen Lücken (zusätzliche Schritte, markiert `is_bridge`)
|
||||||
|
3. **LLM-QS** (Prompt `planning_exercise_path_qa`): Reihenfolge, Themen-Abdeckung, Empfehlungen
|
||||||
|
|
||||||
|
**API-Erweiterung** `progression-path-suggest`: `include_path_qa`, `include_llm_path_qa` · Response: `semantic_brief_summary`, `path_qa`.
|
||||||
|
|
||||||
|
**Pfad-Schritte:** Semantic Brief + Entwicklungsphase in **allen** Schritten (nicht nur Schritt 1).
|
||||||
|
|
||||||
|
### Phase E2 (0.8.187)
|
||||||
|
|
||||||
|
- **LLM-QS → Neuordnung:** `ordered_step_indices` im Prompt `planning_exercise_path_qa` (Migration **076**)
|
||||||
|
- **KI-Lückenfüller:** `planning_exercise_path_ai_fill.py` — `is_ai_proposal` wenn Bibliothek keine Brücke liefert
|
||||||
|
- Request: `include_path_reorder`, `include_ai_gap_fill`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 23. Phase E3 (0.8.203) ✅
|
||||||
|
|
||||||
|
- Off-Topic aus Pfad entfernen; `gap_fill_offers` mit `goal_for_ai`; voller KI-Call im UI (kein Pre-Vorschlag)
|
||||||
|
- Migration **077** `suggested_new_exercises` im Pfad-QS-Prompt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204–217) ✅
|
||||||
|
|
||||||
|
**Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung.
|
||||||
|
|
||||||
|
**Ist-Stand (vollständig):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||||
|
**Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md`
|
||||||
|
|
||||||
|
| Teil | Modul / API |
|
||||||
|
|------|-------------|
|
||||||
|
| Pipeline | `planning_progression_roadmap.py` (Workflow-lite) |
|
||||||
|
| Match | `planning_exercise_path_builder.py` — `roadmap_first`, `roadmap_override` |
|
||||||
|
| Skills | `planning_skill_expectations.py` — pro Stufe + Pfad |
|
||||||
|
| Gap-KI | `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py` |
|
||||||
|
| Persistenz | `planning_roadmap` JSONB (Migration **088**) |
|
||||||
|
| API | `progression-path-suggest`, `PUT` Graph, `POST …/edges/sequence` |
|
||||||
|
| Prompts | **078/079/087** — Slugs nur in `ai_prompts` |
|
||||||
|
| UI | `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal` |
|
||||||
|
|
||||||
|
**Graph-Bias:** `progression_graph_id` bevorzugt **bestehende Nachfolger** ab Schritt 2 (Gewicht ~4–10 %), baut aber **keinen** Pfad aus vorhandenen Knoten — siehe Ist-Doku §5.
|
||||||
|
|
||||||
|
**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 25. Backlog (offen)
|
||||||
|
|
||||||
|
Siehe priorisierte Liste in **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** §10:
|
||||||
|
|
||||||
|
1. UI-Wizard (Progressionsgraph) — separater Chat
|
||||||
|
2. Graph-Erweiterungsmodus (Start ab Knoten)
|
||||||
|
3. Trainingsplanung Phase G (Gruppenkontext, `planning_skill_expectations`)
|
||||||
|
4. Kontext auf allen Pfad-Schritten in der UI
|
||||||
|
5. Enrichment / Prompt-Feintuning
|
||||||
|
6. Mitai Workflow-Engine (langfristig)
|
||||||
209
.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
Normal file
209
.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
# Planungs-KI — Progressions-Roadmap (Phase F)
|
||||||
|
|
||||||
|
**Version:** 0.1
|
||||||
|
**Datum:** 2026-06-07
|
||||||
|
**Status:** VERBINDLICHE ZIELARCHITEKTUR — **F0–F9 umgesetzt** (0.8.217)
|
||||||
|
**Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse
|
||||||
|
|
||||||
|
**Ist-Stand (Module, API, Graph-Verhalten, Persistenz):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||||
|
|
||||||
|
**Bezüge:**
|
||||||
|
`working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` · `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `technical/AI_PROMPT_TARGET_ARCHITECTURE.md` · `docs/architecture/PLANNING_KI_ROADMAP.md` · `docs/HANDOVER.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Entscheidung (2026-06-07)
|
||||||
|
|
||||||
|
### 1.1 Problem
|
||||||
|
|
||||||
|
Der Pfad-Builder (Phase C3/E) ist **retrieval-first**: Zieltext → N Übungen aus der Bibliothek → QS nachbessern. Das entspricht nicht der menschlichen Planung (Ziel → Roadmap → Stufenspezifikation → Übung).
|
||||||
|
|
||||||
|
### 1.2 Festlegung
|
||||||
|
|
||||||
|
| Thema | Entscheidung |
|
||||||
|
|--------|----------------|
|
||||||
|
| **Progressionsgraph** | **Roadmap-first** — Phasen A→B→C, dann Bibliothek (D), dann Feinausplanung (E) |
|
||||||
|
| **Gruppenanalyse** | **Nicht** in der Graphen-Pipeline — erst bei **Trainingsplanung** (Einheit/Rahmen) |
|
||||||
|
| **Mitai Workflow-Engine** | **Nicht** jetzt portieren — **Workflow-lite** (`PlanningProgressionPipeline`), später workflow-ready |
|
||||||
|
| **Ein Mega-Prompt** | **Verboten** — validierte Artefakte pro Phase |
|
||||||
|
|
||||||
|
### 1.3 Abgrenzung Trainingsplanung
|
||||||
|
|
||||||
|
```
|
||||||
|
Progressionsgraph-Pipeline Trainingsplanungs-Pipeline (später)
|
||||||
|
───────────────────────── ───────────────────────────────────
|
||||||
|
Ziel + N Major Steps Gruppe + Historie + Termin + Rahmen
|
||||||
|
Kein Gruppenkontext Kontext-Pack S0 (AI_PLANNING_KI_MULTISTAGE_FORECAST)
|
||||||
|
Curriculum / Technikpfad Session-Füllung / Reihenfolge / Zeiten
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Menschliches Vorbild → Phasen
|
||||||
|
|
||||||
|
| Mensch | Phase | Output-Artefakt | LLM |
|
||||||
|
|--------|-------|-----------------|-----|
|
||||||
|
| Startpunkt + Zielzustand | **A** Zielanalyse | `goal_analysis` | Optional (klein) |
|
||||||
|
| Zwischenziele, gewichten, auf N reduzieren | **B** Roadmap | `roadmap` (`micro_objectives[]`, `major_steps[N]`) | Ja |
|
||||||
|
| Belastung, Übungstyp, Lernziel je Stufe | **C** Stufenspezifikation | `stage_specs[]` | Teilweise |
|
||||||
|
| Bibliothek / Brücke | **D** Match | `step_matches[]` oder `gaps[]` | Nein (Retrieval) |
|
||||||
|
| Skizze + Feinplan | **E** Übungsentwurf | bestehend `suggestExerciseAi` | On-demand |
|
||||||
|
|
||||||
|
**Phase B** = Kern: 8–12 `micro_objectives` → Konsolidierung → exakt `max_steps` `major_steps`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pipeline-Orchestrator (Workflow-lite)
|
||||||
|
|
||||||
|
Modul: **`backend/planning_progression_roadmap.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
ctx = ProgressionRoadmapContext(goal_query=..., max_steps=N, semantic_brief=...)
|
||||||
|
ctx = phase_a_goal_analysis(ctx) # deterministisch + optional LLM
|
||||||
|
ctx = phase_b_roadmap(ctx) # micro → major
|
||||||
|
ctx = phase_c_stage_specs(ctx) # je major_step
|
||||||
|
# Phase D/E: bestehende path_builder / retrieval / ai_fill — speisen von ctx.major_steps
|
||||||
|
```
|
||||||
|
|
||||||
|
Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-the-loop** (Roadmap-Review vor Übungs-Match).
|
||||||
|
|
||||||
|
**Später:** jede Phase = Workflow-Knoten (Mitai-kompatibel), keine API-Änderung an Artefakten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. JSON-Artefakte (Pydantic)
|
||||||
|
|
||||||
|
### 4.1 `goal_analysis` (Phase A)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"start_assumption": "Grundkenntnisse der Standführung, keine Perfektion",
|
||||||
|
"target_state": "Sicherer, präziser Mae Geri unter Belastung und in Anwendung",
|
||||||
|
"success_criteria": ["saubere Kammerhaltung", "Hüftführung", "Kime am Zielpunkt"],
|
||||||
|
"constraints": { "partner_required": false, "equipment": [] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `roadmap` (Phase B)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"micro_objectives": [
|
||||||
|
{ "id": "m1", "phase": "grundlage", "title": "Stellung und Kammerhaltung", "weight": 0.9, "depends_on": [] },
|
||||||
|
{ "id": "m2", "phase": "vertiefung", "title": "Hüft- und Kniekoordination", "weight": 0.85, "depends_on": ["m1"] }
|
||||||
|
],
|
||||||
|
"major_steps": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"phase": "grundlage",
|
||||||
|
"learning_goal": "Stabile Mae-Geri-Grundstellung",
|
||||||
|
"consolidates": ["m1"],
|
||||||
|
"rationale": "Einstieg ohne Perfektionsdruck"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consolidation_notes": ["Perfektion mit Anwendung zusammengeführt"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 `stage_spec` (Phase C, je Major Step)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"major_step_index": 2,
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["präzision", "koordination"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["reine Kraftübung ohne Technikbezug"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API (schrittweise)
|
||||||
|
|
||||||
|
### 5.1 Erweiterung `POST /api/planning/progression-path-suggest`
|
||||||
|
|
||||||
|
| Feld (neu) | Default | Bedeutung |
|
||||||
|
|------------|---------|-----------|
|
||||||
|
| `roadmap_first` | `false` → später `true` | Roadmap-Pipeline vor Retrieval |
|
||||||
|
| `include_roadmap_preview` | `true` wenn `roadmap_first` | Artefakte A/B/C in Response |
|
||||||
|
|
||||||
|
**Response (neu):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"progression_roadmap": {
|
||||||
|
"goal_analysis": { },
|
||||||
|
"roadmap": { },
|
||||||
|
"stage_specs": [ ],
|
||||||
|
"pipeline_phase": "roadmap_v1"
|
||||||
|
},
|
||||||
|
"steps": [ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Übergangsphase (0.8.204):** `include_roadmap_preview=true` liefert Roadmap **parallel** zum bestehenden retrieval-first Pfad — UI kann Roadmap reviewen, Schritte bleiben vorerst retrieval-basiert.
|
||||||
|
|
||||||
|
**Zielphase (F2):** `roadmap_first=true` — Retrieval pro Major Step aus `stage_specs`, nicht mehr iterativ „beste nächste Übung“.
|
||||||
|
|
||||||
|
### 5.2 Prompt-Slugs — nur in `ai_prompts`, nie im Code
|
||||||
|
|
||||||
|
**Regel:** Prompt-**Texte** leben ausschließlich in der Tabelle `ai_prompts` (Superadmin bearbeitbar, Vorschau, `openrouter_model` pro Zeile). Python referenziert nur **Slugs** (`PROMPT_SLUG_*` in `planning_progression_roadmap.py`). Kein verstecktes Hardcoding von Templates.
|
||||||
|
|
||||||
|
| Slug | Phase | Migration |
|
||||||
|
|------|-------|-----------|
|
||||||
|
| `planning_progression_start_target` | Start/Ziel | **087** |
|
||||||
|
| `planning_progression_goal_analysis` | A | **078** |
|
||||||
|
| `planning_progression_roadmap` | B | **078** |
|
||||||
|
| `planning_progression_stage_spec` | C | **079** |
|
||||||
|
|
||||||
|
**API:** `include_llm_roadmap` (Default `true`) — lädt Prompts via `load_and_render_ai_prompt`. Bei Fehler/kein OpenRouter: **deterministischer Fallback** (kein stilles Versagen).
|
||||||
|
|
||||||
|
**Response:** `prompt_slugs` (genutzte Slugs), `prompt_slug_catalog` (Referenz), `llm_*_applied` Flags.
|
||||||
|
|
||||||
|
**Admin:** Templates unter Kategorie `training` pflegen — siehe `AI_PROMPT_SYSTEM_SPEC.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI-Roadmap
|
||||||
|
|
||||||
|
1. **F1:** Roadmap-Box unter Ziel-Eingabe (Major Steps als Karten, editierbar) — vor Übungsliste
|
||||||
|
2. **F2:** Match-Ergebnis pro Major Step (Bibliothek / Lücke / KI anlegen)
|
||||||
|
3. **F3:** `roadmap_first` als Default im Graph-Builder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Was bewusst nicht in Phase F
|
||||||
|
|
||||||
|
- Gruppen-Historie, Belastungssteuerung der Gruppe
|
||||||
|
- Mitai `workflow_engine` Port
|
||||||
|
- Vollautomatisches Speichern ohne Trainer-Review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementierungsstände
|
||||||
|
|
||||||
|
| ID | Inhalt | Status |
|
||||||
|
|----|--------|--------|
|
||||||
|
| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | ✅ 0.8.204 |
|
||||||
|
| **F1** | `include_roadmap_preview` in API + deterministische A/B | ✅ 0.8.204 |
|
||||||
|
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | ✅ 0.8.205 |
|
||||||
|
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206–209 |
|
||||||
|
| **F4** | UI Roadmap-Review + `roadmap_override` | ✅ 0.8.207 |
|
||||||
|
| **F5** | Start/Ziel strukturiert + Prompt **087** + Zwei-Schritt-UI | ✅ 0.8.210–214 |
|
||||||
|
| **F6** | Gap-Prep + `planning_context` an Übungs-KI | ✅ 0.8.212–214 |
|
||||||
|
| **F7** | `planning_skill_expectations` | ✅ 0.8.215–216 |
|
||||||
|
| **F8** | Editierbare `stage_specs` in UI | ✅ 0.8.216 |
|
||||||
|
| **F9** | `planning_roadmap` JSONB (Migration **088**) | ✅ 0.8.217 |
|
||||||
|
| **G** | Trainingsplanung: eigene Pipeline + Workflow-Engine | 🔲 |
|
||||||
|
|
||||||
|
Details: `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Changelog
|
||||||
|
|
||||||
|
- **2026-05-22:** Ist-Stand F5–F9 dokumentiert; Verweis auf `PLANNING_PROGRESSION_GRAPH_KI.md`.
|
||||||
|
- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite.
|
||||||
101
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
101
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Progressionsgraph — Slot-Editor (Phase B + F15)
|
||||||
|
|
||||||
|
**Stand:** 2026-05-22 · **Status:** Umgesetzt (F14 + F15 lokal nach 0.8.233)
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein Progressionsgraph = **ein linearer Hauptpfad** (Roadmap = strukturgebend). Jeder **Major Step** ist ein **Slot** mit:
|
||||||
|
|
||||||
|
- **primary** — Hauptübung des Slots (Pfadknoten)
|
||||||
|
- **siblings** — 0..n Schwestern (gleiche Stufe, `edge_type: sibling`)
|
||||||
|
|
||||||
|
KI-Entwürfe und Bibliotheksübungen leben **im selben Slot-Modell**, ohne sofortige Übungsanlage.
|
||||||
|
|
||||||
|
## Slot-Zustände (`kind`)
|
||||||
|
|
||||||
|
| kind | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `empty` | Noch keine Übung |
|
||||||
|
| `library` | `exercise_id` (+ optional `variant_id`) |
|
||||||
|
| `proposal` | KI-Entwurf (`ai_suggestion`, kein `exercise_id`) |
|
||||||
|
|
||||||
|
## Kanten
|
||||||
|
|
||||||
|
- `primary(n) → primary(n+1)` — `next_exercise` (nur befüllte Primärkette, lückenlos verbunden)
|
||||||
|
- `primary ↔ sibling` — `sibling` (pro Slot)
|
||||||
|
|
||||||
|
Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgenden befüllten Primär-Slots.
|
||||||
|
|
||||||
|
## Editor-Zustand (`ProgressionGraphDraft`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
goalQuery, startSituation, targetState, roadmapNotes, maxSteps,
|
||||||
|
majorSteps: MajorStep[],
|
||||||
|
slots: Slot[], // index = major_step_index
|
||||||
|
pathSkillExpectations?,
|
||||||
|
lastFindings?, // path_qa-Snapshot
|
||||||
|
findingsStale?: boolean, // Bewertung veraltet (↔ Artefakt findings_stale)
|
||||||
|
dirty: boolean,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
|
||||||
|
|
||||||
|
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, `last_findings`, **`findings_stale`**.
|
||||||
|
|
||||||
|
## Findings-Panel
|
||||||
|
|
||||||
|
Nutzt `path_qa`:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `quality_score` | Gesamt = **min(`roadmap_qa`, `assignment_qa`)** |
|
||||||
|
| `roadmap_qa` | Stufen/Roadmap (LLM `topic_coverage`, …) |
|
||||||
|
| `assignment_qa` | Slot-Befüllung (`empty_slot_count`, …) |
|
||||||
|
| `overall_ok`, `issues`, `recommendations`, `gap_fill_offers`, … | wie bisher |
|
||||||
|
|
||||||
|
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
|
||||||
|
|
||||||
|
**Bewertung veraltet:** Jede Graph-Änderung setzt `findingsStale: true` → Banner im Panel. Nach „Graph bewerten“ → `false`. Persistenz: `planning_roadmap.findings_stale`.
|
||||||
|
|
||||||
|
## Match-Flow („Übungen matchen“)
|
||||||
|
|
||||||
|
1. **Schritt 1:** `evaluate_only` + volle Pfad-QS (wie „Graph bewerten“)
|
||||||
|
2. **Schritt 2:** `unified_slot_review: true` → **`ProgressionOptimizeCompareModal`**
|
||||||
|
3. Pro Slot: aktuell vs. beste Bibliothek vs. optional KI-Vorschlag
|
||||||
|
4. **Vorauswahl:** Bibliothek nur wenn Stufen-Fit ≥ 50 % und besser als Baseline; sonst KI (bei leerem/schwachem Slot)
|
||||||
|
5. **Übernahme:** nur gewählte Slots speichern — **keine** automatische Nach-Bewertung
|
||||||
|
|
||||||
|
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `slot_contents[]` — `{ major_step_index, primary, siblings[] }`
|
||||||
|
- `last_findings` — letzter `path_qa`-Snapshot
|
||||||
|
- **`findings_stale`** — bool, Bewertung bezieht sich nicht mehr auf aktuellen Graph-Stand
|
||||||
|
|
||||||
|
## UI (konsolidiert)
|
||||||
|
|
||||||
|
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
|
||||||
|
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
|
||||||
|
- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
|
||||||
|
- **Slot-Keys:** stabil `slot-{index}` (nicht Lernziel-Text) — sonst Fokusverlust beim Tippen
|
||||||
|
|
||||||
|
## Ersetzt (Legacy, nicht mehr im Panel)
|
||||||
|
|
||||||
|
- `ExerciseProgressionPathBuilder` · `ProgressionChainEditor` — Code bleibt vorerst, nicht eingebunden
|
||||||
|
|
||||||
|
## Implementierungsreihenfolge
|
||||||
|
|
||||||
|
| ID | Inhalt | Status |
|
||||||
|
|----|--------|--------|
|
||||||
|
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | ✅ |
|
||||||
|
| B.1 | Slot-Karten, Bibliothek + Entwurf | ✅ |
|
||||||
|
| B.2 | Findings-Panel + `evaluate_only` | ✅ |
|
||||||
|
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | ✅ |
|
||||||
|
| B.4 | Route + Panel vereinfachen | ✅ |
|
||||||
|
| B.5 | `last_findings` + Phase-C-Vorbereitung | ✅ |
|
||||||
|
| F15 | Unified Slot-Review, getrennte QS, `findings_stale` | ✅ |
|
||||||
|
|
||||||
|
**Ist-Doku:** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` §8.1 · `docs/HANDOVER.md` §2.8 F15
|
||||||
17
.env.example
17
.env.example
|
|
@ -35,6 +35,16 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
||||||
OPENROUTER_API_KEY=your_api_key_here
|
OPENROUTER_API_KEY=your_api_key_here
|
||||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
||||||
|
|
||||||
|
# Vereins-Kontingente hart blockieren (KI-Kosten!). Nur 1, true oder yes aktivieren.
|
||||||
|
# Nach Änderung: docker compose -f docker-compose.dev-env.yml up -d backend
|
||||||
|
CLUB_FEATURE_ENFORCE=1
|
||||||
|
# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
|
||||||
|
# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).
|
||||||
|
|
||||||
|
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
|
||||||
|
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).
|
||||||
|
# SHINKAN_AI_DEBUG=1
|
||||||
|
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=noreply@jinkendo.de
|
SMTP_USER=noreply@jinkendo.de
|
||||||
|
|
@ -43,7 +53,12 @@ SMTP_FROM=noreply@jinkendo.de
|
||||||
SMTP_SSL=
|
SMTP_SSL=
|
||||||
SMTP_STARTTLS=
|
SMTP_STARTTLS=
|
||||||
|
|
||||||
AUTO_ADMIN_FIRST_USER=true
|
# Produktion: false. true nur für eine bewusst leere Erstinstallation.
|
||||||
|
AUTO_ADMIN_FIRST_USER=false
|
||||||
|
# true erzwingt TOTP für Plattform-Admins beim Login. Standard ist freiwillig.
|
||||||
|
REQUIRE_ADMIN_TOTP=false
|
||||||
|
# Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet.
|
||||||
|
# MEDIA_TOKEN_SECRET=
|
||||||
ADMIN_BOOTSTRAP_EMAILS=
|
ADMIN_BOOTSTRAP_EMAILS=
|
||||||
|
|
||||||
APP_URL=https://shinkan.jinkendo.de
|
APP_URL=https://shinkan.jinkendo.de
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ jobs:
|
||||||
docker compose -f docker-compose.dev-env.yml build --no-cache
|
docker compose -f docker-compose.dev-env.yml build --no-cache
|
||||||
docker compose -f docker-compose.dev-env.yml up -d
|
docker compose -f docker-compose.dev-env.yml up -d
|
||||||
sleep 5
|
sleep 5
|
||||||
curl -sf http://localhost:8098/api/version && echo "✓ DEV API healthy"
|
if ! curl -sf http://localhost:8098/api/version; then
|
||||||
|
echo "✗ DEV API nicht erreichbar — Backend-Logs (Migration/Startup):"
|
||||||
|
docker compose -f docker-compose.dev-env.yml logs backend --tail 120 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ DEV API healthy"
|
||||||
curl -sf http://localhost:3098/api/version && echo "✓ DEV über Frontend-Nginx (wie Browser) healthy"
|
curl -sf http://localhost:3098/api/version && echo "✓ DEV über Frontend-Nginx (wie Browser) healthy"
|
||||||
echo "=== Shinkan DEV Deploy complete ==="
|
echo "=== Shinkan DEV Deploy complete ==="
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
name: Test Suite
|
name: Test Suite
|
||||||
|
|
||||||
|
# develop: push/PR → Tests gegen Dev (parallel oder vor Deploy Development).
|
||||||
|
# main: kein push/PR-Trigger — vermeidet doppelten Dev-Lauf beim Merge develop→main;
|
||||||
|
# Prod-Tests nur via workflow_run nach erfolgreichem Deploy Production.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [develop]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, develop]
|
branches: [develop]
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Deploy Development", "Deploy Production"]
|
workflows: ["Deploy Development", "Deploy Production"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
|
@ -17,8 +20,10 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Backend pytest im deployten Container
|
- name: Backend pytest im deployten Container
|
||||||
run: |
|
run: |
|
||||||
|
set -e
|
||||||
EVENT_NAME="${{ github.event_name }}"
|
EVENT_NAME="${{ github.event_name }}"
|
||||||
REF_NAME="${{ github.ref_name }}"
|
REF_NAME="${{ github.ref_name }}"
|
||||||
|
BASE_REF="${{ github.base_ref }}"
|
||||||
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||||
APP_DIR="/home/lars/docker/shinkan"
|
APP_DIR="/home/lars/docker/shinkan"
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
|
@ -28,12 +33,27 @@ jobs:
|
||||||
APP_DIR="/home/lars/docker/shinkan-dev"
|
APP_DIR="/home/lars/docker/shinkan-dev"
|
||||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
COMPOSE_FILE="docker-compose.dev-env.yml"
|
||||||
fi
|
fi
|
||||||
elif [ "$REF_NAME" = "develop" ]; then
|
elif [ "$REF_NAME" = "develop" ] || [ "$BASE_REF" = "develop" ]; then
|
||||||
APP_DIR="/home/lars/docker/shinkan-dev"
|
APP_DIR="/home/lars/docker/shinkan-dev"
|
||||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
COMPOSE_FILE="docker-compose.dev-env.yml"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
|
echo "Warte auf stabilen backend-Container …"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then
|
||||||
|
echo "Backend bereit (Versuch $i)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
echo "Timeout: backend-Container nicht bereit"
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps || true
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs backend --tail 80 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
||||||
pip install -r /app/requirements-dev.txt &&
|
pip install -r /app/requirements-dev.txt &&
|
||||||
cd /app &&
|
cd /app &&
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
||||||
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
|
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
|
||||||
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
|
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
|
||||||
|
> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
|
||||||
|
> | Planungs-KI Progressionsgraph (Ist-Stand) | **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** · Spec **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
|
||||||
|
|
||||||
## Projekt-Übersicht
|
## Projekt-Übersicht
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,16 @@ FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies (tzdata für zoneinfo/ZoneInfo unter Linux)
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
|
tzdata \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements and install dependencies
|
# Copy requirements and install dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
ENV PIP_DEFAULT_TIMEOUT=120
|
||||||
|
RUN pip install --no-cache-dir --retries 5 -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
|
||||||
77
backend/account_lifecycle.py
Normal file
77
backend/account_lifecycle.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""
|
||||||
|
Account-Lifecycle (CAPABILITY_CATALOG.v1.md §3, M3 C0).
|
||||||
|
|
||||||
|
Zustände: unverified → verified_pending_club → active_member; platform_admin separat.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from club_tenancy import is_platform_admin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
_ACCOUNT_STATE_RANK = {
|
||||||
|
"unverified": 1,
|
||||||
|
"verified_pending_club": 2,
|
||||||
|
"active_member": 3,
|
||||||
|
"platform_admin": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_account_state(
|
||||||
|
*,
|
||||||
|
email_verified: bool,
|
||||||
|
global_role: str,
|
||||||
|
has_active_membership: bool,
|
||||||
|
) -> str:
|
||||||
|
"""Ermittelt account_state für ein Profil."""
|
||||||
|
if is_platform_admin(global_role):
|
||||||
|
return "platform_admin"
|
||||||
|
if not email_verified:
|
||||||
|
return "unverified"
|
||||||
|
if not has_active_membership:
|
||||||
|
return "verified_pending_club"
|
||||||
|
return "active_member"
|
||||||
|
|
||||||
|
|
||||||
|
def account_state_satisfies(current: str, required: str) -> bool:
|
||||||
|
"""True wenn current mindestens required ist."""
|
||||||
|
cur = _ACCOUNT_STATE_RANK.get(current, 0)
|
||||||
|
req = _ACCOUNT_STATE_RANK.get(required, 99)
|
||||||
|
if current == "platform_admin":
|
||||||
|
return True
|
||||||
|
return cur >= req
|
||||||
|
|
||||||
|
|
||||||
|
def account_gate_enforcement_enabled() -> bool:
|
||||||
|
"""Account-Gates aktiv (Default an — nur wenige Endpoints in M3)."""
|
||||||
|
return os.getenv("ACCOUNT_GATE_ENFORCE", "1").strip() == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def assert_min_account_state(
|
||||||
|
tenant: "TenantContext",
|
||||||
|
min_state: str,
|
||||||
|
*,
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Prüft Mindest-Account-Status. Wirft 403 wenn ACCOUNT_GATE_ENFORCE=1 (Default).
|
||||||
|
"""
|
||||||
|
current = getattr(tenant, "account_state", "active_member")
|
||||||
|
ok = account_state_satisfies(current, min_state)
|
||||||
|
if ok:
|
||||||
|
return
|
||||||
|
if not account_gate_enforcement_enabled():
|
||||||
|
return
|
||||||
|
detail = (
|
||||||
|
f"Account-Status „{current}“ reicht nicht für diese Aktion "
|
||||||
|
f"(erforderlich: {min_state})."
|
||||||
|
)
|
||||||
|
if endpoint:
|
||||||
|
detail = f"{detail} ({endpoint})"
|
||||||
|
raise HTTPException(status_code=403, detail=detail)
|
||||||
180
backend/account_onboarding_gate.py
Normal file
180
backend/account_onboarding_gate.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""
|
||||||
|
API-Gates für Onboarding (Phase A — MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1).
|
||||||
|
|
||||||
|
Blockiert Domänen-APIs für unverified / verified_pending_club vor dem Router.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from account_lifecycle import resolve_account_state
|
||||||
|
from club_tenancy import memberships_with_roles
|
||||||
|
|
||||||
|
# Öffentlich ohne Session
|
||||||
|
PUBLIC_API_PREFIXES = (
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/register",
|
||||||
|
"/api/auth/forgot-password",
|
||||||
|
"/api/auth/reset-password",
|
||||||
|
"/api/auth/verify/",
|
||||||
|
"/api/legal-documents/",
|
||||||
|
"/api/clubs/public-directory",
|
||||||
|
"/api/version",
|
||||||
|
"/api/health/",
|
||||||
|
"/health",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mit Session, unabhängig vom account_state (Logout, Profil lesen, …)
|
||||||
|
AUTH_INFRA_PREFIXES = (
|
||||||
|
"/api/auth/logout",
|
||||||
|
"/api/auth/me",
|
||||||
|
"/api/auth/status",
|
||||||
|
"/api/auth/pin",
|
||||||
|
"/api/auth/resend-verification",
|
||||||
|
"/api/auth/totp",
|
||||||
|
"/api/auth/media-grant",
|
||||||
|
"/api/profiles/me",
|
||||||
|
"/api/me/entitlements",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zusätzlich für verified_pending_club (Verein bewerben)
|
||||||
|
PENDING_CLUB_PREFIXES = (
|
||||||
|
"/api/me/club-join-requests",
|
||||||
|
"/api/me/club-creation-requests",
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def api_onboarding_gate_enabled() -> bool:
|
||||||
|
"""Produktions-Gate aktiv (ACCOUNT_GATE_API_ENFORCE=0 zum Abschalten)."""
|
||||||
|
return os.getenv("ACCOUNT_GATE_API_ENFORCE", "1").strip() == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _middleware_db_lookup_enabled() -> bool:
|
||||||
|
"""
|
||||||
|
Middleware-Session-Lookup nur mit echter DB (nicht in pytest TestClient ohne Postgres).
|
||||||
|
"""
|
||||||
|
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
|
||||||
|
return False
|
||||||
|
if os.getenv("PYTEST_CURRENT_TEST"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_api_path(path: str) -> str:
|
||||||
|
p = (path or "").split("?", 1)[0].strip()
|
||||||
|
if not p.startswith("/"):
|
||||||
|
p = "/" + p
|
||||||
|
if len(p) > 1 and p.endswith("/"):
|
||||||
|
p = p[:-1]
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_api_path(path: str) -> bool:
|
||||||
|
p = normalize_api_path(path)
|
||||||
|
return any(p == pref or p.startswith(pref) for pref in PUBLIC_API_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_allowed_for_state(path: str, method: str, account_state: str, profile_id: int) -> bool:
|
||||||
|
p = normalize_api_path(path)
|
||||||
|
m = (method or "GET").upper()
|
||||||
|
|
||||||
|
for pref in AUTH_INFRA_PREFIXES:
|
||||||
|
if p == pref or p.startswith(pref + "/"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
match = _PROFILE_MUTATION_RE.match(p)
|
||||||
|
if match and m in ("PUT", "PATCH") and int(match.group(1)) == int(profile_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if account_state == "unverified":
|
||||||
|
return False
|
||||||
|
|
||||||
|
if account_state == "verified_pending_club":
|
||||||
|
for pref in PENDING_CLUB_PREFIXES:
|
||||||
|
if p == pref or p.startswith(pref + "/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_account_state_for_token(cur, session_row: dict) -> str:
|
||||||
|
profile_id = int(session_row["profile_id"])
|
||||||
|
role = (session_row.get("role") or "").lower()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
prof = cur.fetchone()
|
||||||
|
email_verified = bool(prof.get("email_verified")) if prof else False
|
||||||
|
memberships = memberships_with_roles(cur, profile_id, active_only=True)
|
||||||
|
has_active = len(memberships) > 0
|
||||||
|
return resolve_account_state(
|
||||||
|
email_verified=email_verified,
|
||||||
|
global_role=role,
|
||||||
|
has_active_membership=has_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_api_onboarding_gate(
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
method: str,
|
||||||
|
profile_id: int,
|
||||||
|
account_state: str,
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Returns (allowed, reason).
|
||||||
|
active_member / platform_admin → immer erlaubt (Domain).
|
||||||
|
"""
|
||||||
|
if not api_onboarding_gate_enabled():
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if account_state in ("active_member", "platform_admin"):
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if _path_allowed_for_state(path, method, account_state, profile_id):
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
return False, f"account_state_{account_state}"
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_request_gate(token: Optional[str], path: str, method: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Vollständige Prüfung inkl. Session-Lookup.
|
||||||
|
Returns: allowed, reason, account_state (für Logging)
|
||||||
|
"""
|
||||||
|
if not api_onboarding_gate_enabled() or not _middleware_db_lookup_enabled():
|
||||||
|
return True, None, None
|
||||||
|
|
||||||
|
p = normalize_api_path(path)
|
||||||
|
if not p.startswith("/api/"):
|
||||||
|
return True, None, None
|
||||||
|
if is_public_api_path(p):
|
||||||
|
return True, None, None
|
||||||
|
if not token:
|
||||||
|
return True, None, None
|
||||||
|
|
||||||
|
from auth import get_session
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
|
||||||
|
session = get_session(token)
|
||||||
|
if not session:
|
||||||
|
return True, None, None
|
||||||
|
|
||||||
|
profile_id = int(session["profile_id"])
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
account_state = resolve_account_state_for_token(cur, session)
|
||||||
|
|
||||||
|
allowed, reason = check_api_onboarding_gate(
|
||||||
|
path=p,
|
||||||
|
method=method,
|
||||||
|
profile_id=profile_id,
|
||||||
|
account_state=account_state,
|
||||||
|
)
|
||||||
|
return allowed, reason, account_state
|
||||||
108
backend/ai_prompt_context.py
Normal file
108
backend/ai_prompt_context.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""
|
||||||
|
Gemeinsame Pydantic-Modelle fuer Uebungs-KI-Kontext (Formularfelder → Prompt-Platzhalter).
|
||||||
|
|
||||||
|
Keine Imports aus exercise_ai — vermeidet Zirkelimporte mit ai_prompt_job / exercise_ai.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseFormAiFocusRow(BaseModel):
|
||||||
|
"""Fokusbereich fuer Skill-Retrieval (ai_skill_retrieval_profiles)."""
|
||||||
|
|
||||||
|
focus_area_id: int = Field(..., ge=1)
|
||||||
|
is_primary: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseFormAiPromptContext(BaseModel):
|
||||||
|
"""
|
||||||
|
Inhaltliche Eingabe fuer Uebungs-Prompts (Kurzfassung / Skills / Anleitung).
|
||||||
|
|
||||||
|
Wird genutzt von Admin-Prompt-Vorschau und POST /exercises/ai/suggest (via Mapping).
|
||||||
|
"""
|
||||||
|
|
||||||
|
title: Optional[str] = ""
|
||||||
|
goal: Optional[str] = None
|
||||||
|
execution: Optional[str] = None
|
||||||
|
preparation: Optional[str] = None
|
||||||
|
trainer_notes: Optional[str] = None
|
||||||
|
focus_hint: Optional[str] = None
|
||||||
|
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None
|
||||||
|
planning_context: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]:
|
||||||
|
if not self.focus_areas_context:
|
||||||
|
return None
|
||||||
|
return [(int(x.focus_area_id), bool(x.is_primary)) for x in self.focus_areas_context]
|
||||||
|
|
||||||
|
def has_instruction_source_text(self) -> bool:
|
||||||
|
"""Mindestens ein Anleitungsfeld oder Titel fuer instruction_rewrite."""
|
||||||
|
if (self.title or "").strip():
|
||||||
|
return True
|
||||||
|
for val in (self.goal, self.execution, self.preparation, self.trainer_notes):
|
||||||
|
if val and str(val).strip():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_suggest(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
goal: Optional[str] = None,
|
||||||
|
execution: Optional[str] = None,
|
||||||
|
preparation: Optional[str] = None,
|
||||||
|
trainer_notes: Optional[str] = None,
|
||||||
|
focus_area_hint: Optional[str] = None,
|
||||||
|
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
|
||||||
|
planning_context: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> ExerciseFormAiPromptContext:
|
||||||
|
"""Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint)."""
|
||||||
|
hint = (focus_area_hint or "").strip() or None
|
||||||
|
return cls(
|
||||||
|
title=(title or "").strip(),
|
||||||
|
goal=goal,
|
||||||
|
execution=execution,
|
||||||
|
preparation=preparation,
|
||||||
|
trainer_notes=trainer_notes,
|
||||||
|
focus_hint=hint,
|
||||||
|
focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
|
||||||
|
planning_context=dict(planning_context) if planning_context else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_focus_tuples(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
goal: Optional[str] = None,
|
||||||
|
execution: Optional[str] = None,
|
||||||
|
preparation: Optional[str] = None,
|
||||||
|
trainer_notes: Optional[str] = None,
|
||||||
|
focus_hint: Optional[str] = None,
|
||||||
|
focus_tuples: Optional[Sequence[Tuple[int, bool]]] = None,
|
||||||
|
) -> ExerciseFormAiPromptContext:
|
||||||
|
rows = None
|
||||||
|
if focus_tuples:
|
||||||
|
rows = [
|
||||||
|
ExerciseFormAiFocusRow(focus_area_id=int(fid), is_primary=bool(prim))
|
||||||
|
for fid, prim in focus_tuples
|
||||||
|
]
|
||||||
|
return cls(
|
||||||
|
title=(title or "").strip(),
|
||||||
|
goal=goal,
|
||||||
|
execution=execution,
|
||||||
|
preparation=preparation,
|
||||||
|
trainer_notes=trainer_notes,
|
||||||
|
focus_hint=(focus_hint or "").strip() or None,
|
||||||
|
focus_areas_context=rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExerciseFormAiFocusRow",
|
||||||
|
"ExerciseFormAiPromptContext",
|
||||||
|
]
|
||||||
59
backend/ai_prompt_job.py
Normal file
59
backend/ai_prompt_job.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""
|
||||||
|
KI-Prompt Jobs: Resolver + oeffentliche Fassade fuer Uebungs-KI-Aufrufe.
|
||||||
|
|
||||||
|
Importiert exercise_ai fuer Platzhalter-Builder und OpenRouter-Orchestrierung.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
|
||||||
|
from exercise_ai import build_exercise_placeholder_variables
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptContext) -> Dict[str, str]:
|
||||||
|
"""Baut die Mustache-Map fuer exercise_summary / exercise_skill_suggestions."""
|
||||||
|
return build_exercise_placeholder_variables(
|
||||||
|
cur,
|
||||||
|
slug=slug,
|
||||||
|
title=(ctx.title or "").strip(),
|
||||||
|
goal=ctx.goal,
|
||||||
|
execution=ctx.execution,
|
||||||
|
focus_area_hint=ctx.focus_hint,
|
||||||
|
focus_areas_context=ctx.focus_area_tuples(),
|
||||||
|
preparation=ctx.preparation,
|
||||||
|
trainer_notes=ctx.trainer_notes,
|
||||||
|
planning_context=ctx.planning_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_exercise_form_ai_suggestion(
|
||||||
|
cur,
|
||||||
|
ctx: ExerciseFormAiPromptContext,
|
||||||
|
*,
|
||||||
|
want_summary: bool,
|
||||||
|
want_skills: bool,
|
||||||
|
want_instructions: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fuehrt Uebungs-KI aus (OpenRouter) — ein Einstieg fuer Router und kuenftige Jobs.
|
||||||
|
|
||||||
|
``ctx`` = Formularinhalt; ``want_*`` = welche Prompt-Slugs angefragt werden.
|
||||||
|
"""
|
||||||
|
from exercise_ai import run_exercise_ai_suggestion
|
||||||
|
|
||||||
|
return run_exercise_ai_suggestion(
|
||||||
|
cur,
|
||||||
|
form_ctx=ctx,
|
||||||
|
want_summary=want_summary,
|
||||||
|
want_skills=want_skills,
|
||||||
|
want_instructions=want_instructions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExerciseFormAiFocusRow",
|
||||||
|
"ExerciseFormAiPromptContext",
|
||||||
|
"resolve_exercise_form_variables",
|
||||||
|
"run_exercise_form_ai_suggestion",
|
||||||
|
]
|
||||||
317
backend/ai_prompt_planning_preview.py
Normal file
317
backend/ai_prompt_planning_preview.py
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
"""
|
||||||
|
Admin-Vorschau: Platzhalter für Planungs-Prompts (Progressionsgraph, Pfad-QS, Suggest).
|
||||||
|
|
||||||
|
Nutzt repräsentative Beispieldaten + echte Katalog-Auszüge aus der DB.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from planning_exercise_semantics import brief_to_summary_dict, build_semantic_brief
|
||||||
|
from planning_intent_context import build_planning_intent_context
|
||||||
|
from planning_prompt_variables import merge_planning_prompt_variables
|
||||||
|
|
||||||
|
PLANNING_PROMPT_SLUGS = frozenset(
|
||||||
|
{
|
||||||
|
"planning_progression_start_target",
|
||||||
|
"planning_progression_goal_analysis",
|
||||||
|
"planning_progression_roadmap",
|
||||||
|
"planning_progression_stage_spec",
|
||||||
|
"planning_exercise_query_semantics",
|
||||||
|
"planning_exercise_path_qa",
|
||||||
|
"planning_exercise_search_intent",
|
||||||
|
"planning_exercise_search_rank",
|
||||||
|
"planning_exercise_expectation_profile",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningPromptPreviewInput(BaseModel):
|
||||||
|
goal_query: str = Field(
|
||||||
|
default="Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe",
|
||||||
|
max_length=2000,
|
||||||
|
)
|
||||||
|
user_notes: str = Field(default="Fokus Breitensport, ohne Wettkampfdruck.", max_length=2000)
|
||||||
|
max_steps: int = Field(default=5, ge=2, le=10)
|
||||||
|
search_query: Optional[str] = Field(default=None, max_length=2000)
|
||||||
|
planning_catalog_context: Optional[Dict[str, Any]] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_planning_prompt_slug(slug: str) -> bool:
|
||||||
|
return (slug or "").strip().lower() in PLANNING_PROMPT_SLUGS
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_json(obj: Any) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_goal_analysis() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"start_assumption": "Grundstellung und einfache Frontkick-Bewegung bekannt",
|
||||||
|
"target_state": "Kontrollierter Mae Geri in Kumite-Nähe mit Hüftöffnung",
|
||||||
|
"success_criteria": [
|
||||||
|
"Hüfte öffnet vor dem Kick",
|
||||||
|
"Ballen trifft Zielzone",
|
||||||
|
"Rückzug ohne Balanceverlust",
|
||||||
|
],
|
||||||
|
"constraints": {
|
||||||
|
"partner_required": False,
|
||||||
|
"excluded_themes": ["reine Kraft ohne Technikbezug"],
|
||||||
|
"trainer_notes": "Breitensport, kein Wettkampf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_major_steps(max_steps: int) -> List[Dict[str, Any]]:
|
||||||
|
phases = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"]
|
||||||
|
titles = [
|
||||||
|
"Grundstellung und Mae Geri Einstieg",
|
||||||
|
"Hüftöffnung und Ballen-Fokus",
|
||||||
|
"Koordination und Rückzug",
|
||||||
|
"Anwendung in Partnerübung",
|
||||||
|
"Qualität unter leichtem Druck",
|
||||||
|
]
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for i in range(max_steps):
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"phase": phases[min(i, len(phases) - 1)],
|
||||||
|
"title": titles[min(i, len(titles) - 1)],
|
||||||
|
"learning_goal": titles[min(i, len(titles) - 1)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_path_steps() -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"exercise_id": 101,
|
||||||
|
"title": "Mae Geri — Stand und Hüftöffnung",
|
||||||
|
"goal": "Frontkick mit geöffneter Hüfte aus Grundstellung",
|
||||||
|
"is_bridge": False,
|
||||||
|
"is_ai_proposal": False,
|
||||||
|
"reasons": ["Stufen-Gate: Grundlagen"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"exercise_id": 102,
|
||||||
|
"title": "Mae Geri — Ballen und Rückzug",
|
||||||
|
"goal": "Präziser Ballentreffer mit kontrolliertem Rückzug",
|
||||||
|
"is_bridge": False,
|
||||||
|
"is_ai_proposal": False,
|
||||||
|
"reasons": ["Nachfolger im Graph"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_planning_context() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scope": "progression_path",
|
||||||
|
"goal_query": "Mae Geri vom Grundschritt bis zur Kumite-Nähe",
|
||||||
|
"stage_index": 1,
|
||||||
|
"learning_goal": "Hüftöffnung und Ballen-Fokus",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_target_profile() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"primary_focus": "Kihon",
|
||||||
|
"training_type": "Breitensport",
|
||||||
|
"skill_expectations": ["Geri Waza", "Koordination"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_candidates() -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"exercise_id": 101,
|
||||||
|
"title": "Mae Geri — Stand und Hüftöffnung",
|
||||||
|
"summary": "Frontkick mit Hüftöffnung",
|
||||||
|
"skill_names": ["Geri Waza"],
|
||||||
|
"score_hint": 0.82,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exercise_id": 102,
|
||||||
|
"title": "Mae Geri — Ballen und Rückzug",
|
||||||
|
"summary": "Ballentreffer mit Rückzug",
|
||||||
|
"skill_names": ["Geri Waza", "Koordination"],
|
||||||
|
"score_hint": 0.76,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalog_variables(cur) -> Dict[str, str]:
|
||||||
|
from planning_exercise_intent import (
|
||||||
|
_load_compact_catalog,
|
||||||
|
_load_skills_catalog_compact,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||||
|
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||||
|
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||||
|
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||||
|
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_catalog_context(body: PlanningPromptPreviewInput):
|
||||||
|
from planning_catalog_context import catalog_context_from_mapping
|
||||||
|
|
||||||
|
raw = body.planning_catalog_context
|
||||||
|
if raw:
|
||||||
|
return catalog_context_from_mapping(raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_catalog_preview(cur, slug: str, base: Dict[str, str], body: PlanningPromptPreviewInput) -> Dict[str, str]:
|
||||||
|
return merge_planning_prompt_variables(
|
||||||
|
cur,
|
||||||
|
base,
|
||||||
|
catalog=_preview_catalog_context(body),
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_planning_prompt_preview_variables(
|
||||||
|
cur,
|
||||||
|
slug: str,
|
||||||
|
body: PlanningPromptPreviewInput,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Mustache-Variablen für Planungs-Prompt-Vorschau im Admin."""
|
||||||
|
s = (slug or "").strip().lower()
|
||||||
|
if s not in PLANNING_PROMPT_SLUGS:
|
||||||
|
raise ValueError(f"Kein Planungs-Prompt-Slug: {slug!r}")
|
||||||
|
|
||||||
|
goal_query = (body.goal_query or "").strip() or "Mae Geri Progression"
|
||||||
|
search_query = (body.search_query or "").strip() or goal_query
|
||||||
|
max_steps = int(body.max_steps)
|
||||||
|
brief = build_semantic_brief(goal_query)
|
||||||
|
brief_json = _compact_json(brief_to_summary_dict(brief))
|
||||||
|
goal_analysis = _sample_goal_analysis()
|
||||||
|
major_steps = _sample_major_steps(max_steps)
|
||||||
|
intent_ctx = build_planning_intent_context(
|
||||||
|
goal_query=goal_query,
|
||||||
|
goal_analysis=goal_analysis,
|
||||||
|
semantic_brief=brief,
|
||||||
|
extra_context=(body.user_notes or "").strip() or None,
|
||||||
|
)
|
||||||
|
intent_ctx_json = _compact_json(intent_ctx.to_api_dict())
|
||||||
|
ctx = _sample_planning_context()
|
||||||
|
target = _sample_target_profile()
|
||||||
|
catalogs = _load_catalog_variables(cur)
|
||||||
|
|
||||||
|
if s == "planning_progression_start_target":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
"user_notes": (body.user_notes or "").strip(),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_progression_goal_analysis":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_progression_roadmap":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"goal_analysis_json": _compact_json(goal_analysis),
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
"max_steps": str(max_steps),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_progression_stage_spec":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"goal_analysis_json": _compact_json(goal_analysis),
|
||||||
|
"major_steps_json": _compact_json(major_steps),
|
||||||
|
"intent_context_json": intent_ctx_json,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_exercise_query_semantics":
|
||||||
|
return {
|
||||||
|
"search_query": search_query,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == "planning_exercise_path_qa":
|
||||||
|
return _merge_catalog_preview(
|
||||||
|
cur,
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
"goal_query": goal_query,
|
||||||
|
"semantic_brief_json": brief_json,
|
||||||
|
"steps_json": _compact_json(_sample_path_steps()),
|
||||||
|
"gaps_json": _compact_json([]),
|
||||||
|
"bridge_inserts_json": _compact_json([]),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s == "planning_exercise_search_intent":
|
||||||
|
return {
|
||||||
|
"search_query": search_query,
|
||||||
|
"heuristic_intent": "progression_next",
|
||||||
|
"scenario_hint": "preset_next",
|
||||||
|
"planning_context_json": _compact_json(ctx),
|
||||||
|
"target_profile_json": _compact_json(target),
|
||||||
|
**catalogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == "planning_exercise_search_rank":
|
||||||
|
return {
|
||||||
|
"search_query": search_query,
|
||||||
|
"intent": "progression_next",
|
||||||
|
"planning_context_json": _compact_json(ctx),
|
||||||
|
"target_profile_json": _compact_json(target),
|
||||||
|
"candidates_json": _compact_json(_sample_candidates()),
|
||||||
|
"result_limit": "5",
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == "planning_exercise_expectation_profile":
|
||||||
|
return {
|
||||||
|
"heuristic_intent": "suggest_next",
|
||||||
|
"planning_context_json": _compact_json(ctx),
|
||||||
|
"target_profile_json": _compact_json(target),
|
||||||
|
**{k: v for k, v in catalogs.items() if k != "style_directions_catalog_json"},
|
||||||
|
}
|
||||||
|
|
||||||
|
raise ValueError(f"Planungs-Prompt-Slug nicht implementiert: {slug!r}")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PLANNING_PROMPT_SLUGS",
|
||||||
|
"PlanningPromptPreviewInput",
|
||||||
|
"is_planning_prompt_slug",
|
||||||
|
"resolve_planning_prompt_preview_variables",
|
||||||
|
]
|
||||||
125
backend/ai_prompt_runtime.py
Normal file
125
backend/ai_prompt_runtime.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""
|
||||||
|
Gemeinsame KI-Prompt-Laufzeit (Shinkan): DB-Lesezugriff ai_prompts + Kontext-Arten.
|
||||||
|
|
||||||
|
Bleibt ohne Import von exercise_ai (kein Zirkel). Domänen wie exercise_ai nutzen
|
||||||
|
load_ai_prompt_row und die Enum; Platzhalter bauen sie selbst oder über geteilte Builder.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
from prompt_resolver import MustacheRenderResult, render_mustache_template
|
||||||
|
|
||||||
|
_PLANNING_AI_SLUGS = frozenset(
|
||||||
|
{
|
||||||
|
"planning_exercise_search_rank",
|
||||||
|
"planning_exercise_search_intent",
|
||||||
|
"planning_exercise_expectation_profile",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_EXERCISE_AI_SLUGS = frozenset(
|
||||||
|
{
|
||||||
|
"exercise_summary",
|
||||||
|
"exercise_skill_suggestions",
|
||||||
|
"exercise_instruction_rewrite",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AiPromptContextKind(str, Enum):
|
||||||
|
"""
|
||||||
|
Logischer Kontext fuer Platzhalter/Builder — erweiterbar fuer Planung/Rahmen
|
||||||
|
ohne bestehende Slugs zu invalidieren.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLANNING_EXERCISE_SEARCH = "planning_exercise_search"
|
||||||
|
EXERCISE_FORM_AI = "exercise_form_ai"
|
||||||
|
|
||||||
|
|
||||||
|
def context_kind_for_slug(slug: str) -> Optional[AiPromptContextKind]:
|
||||||
|
"""Ordnet einen DB-Slug einer Kontext-Art zu, sofern registriert."""
|
||||||
|
s = (slug or "").strip().lower()
|
||||||
|
if s in _PLANNING_AI_SLUGS:
|
||||||
|
return AiPromptContextKind.PLANNING_EXERCISE_SEARCH
|
||||||
|
if s in _EXERCISE_AI_SLUGS:
|
||||||
|
return AiPromptContextKind.EXERCISE_FORM_AI
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Laedt eine Zeile ai_prompts fuer Laufzeit-Orchestrierung.
|
||||||
|
|
||||||
|
active_only=True: inaktive Prompts werden wie fehlend behandelt (503 im Aufrufer).
|
||||||
|
"""
|
||||||
|
if active_only:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||||
|
FROM ai_prompts
|
||||||
|
WHERE slug = %s AND active = true
|
||||||
|
""",
|
||||||
|
(slug,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||||
|
FROM ai_prompts
|
||||||
|
WHERE slug = %s
|
||||||
|
""",
|
||||||
|
(slug,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
if active_only and not d.get("active", True):
|
||||||
|
return None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class AiPromptUnavailableError(LookupError):
|
||||||
|
"""Kein aktiver Prompt fuer slug (oder Zeile fehlt)."""
|
||||||
|
|
||||||
|
def __init__(self, slug: str) -> None:
|
||||||
|
self.slug = (slug or "").strip()
|
||||||
|
super().__init__(self.slug)
|
||||||
|
|
||||||
|
|
||||||
|
def render_ai_prompt_template_for_row(
|
||||||
|
row: Mapping[str, Any],
|
||||||
|
variables: Mapping[str, str],
|
||||||
|
) -> MustacheRenderResult:
|
||||||
|
"""Ersetzt Platzhalter anhand einer bereits geladenen ai_prompts-Zeile (z. B. Admin-Vorschauch, inkl. inaktiv)."""
|
||||||
|
return render_mustache_template(str(row.get("template") or ""), variables)
|
||||||
|
|
||||||
|
|
||||||
|
def load_and_render_ai_prompt(
|
||||||
|
cur,
|
||||||
|
slug: str,
|
||||||
|
variables: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> Tuple[Dict[str, Any], MustacheRenderResult]:
|
||||||
|
"""
|
||||||
|
Laedt einen aktiven Prompt und wendet Mustache-Variablen an.
|
||||||
|
Wirft AiPromptUnavailableError, wenn die Zeile fehlt oder (bei active_only) inaktiv ist.
|
||||||
|
"""
|
||||||
|
row = load_ai_prompt_row(cur, slug, active_only=active_only)
|
||||||
|
if not row:
|
||||||
|
raise AiPromptUnavailableError(slug)
|
||||||
|
rr = render_ai_prompt_template_for_row(row, variables)
|
||||||
|
return dict(row), rr
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AiPromptContextKind",
|
||||||
|
"AiPromptUnavailableError",
|
||||||
|
"context_kind_for_slug",
|
||||||
|
"load_ai_prompt_row",
|
||||||
|
"load_and_render_ai_prompt",
|
||||||
|
"render_ai_prompt_template_for_row",
|
||||||
|
]
|
||||||
|
|
@ -12,6 +12,11 @@ from fastapi import Header, Query, HTTPException
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
|
from media_grant import profile_id_from_media_grant
|
||||||
|
|
||||||
|
# Fester bcrypt-Hash, damit ein unbekannter Login dieselbe Rechenzeit hat wie ein Treffer.
|
||||||
|
_LOGIN_TIMING_HASH = "$2b$12$GYdnAmeiqaZg/.5J9E4f4.xhz70piViqXIpqKCY/FY609qj65sc9i"
|
||||||
|
MAX_SESSION_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
def hash_pin(pin: str) -> str:
|
def hash_pin(pin: str) -> str:
|
||||||
|
|
@ -33,6 +38,34 @@ def verify_pin(pin: str, stored_hash: str) -> bool:
|
||||||
return stored_hash == hashlib.sha256(pin.encode()).hexdigest()
|
return stored_hash == hashlib.sha256(pin.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_pin_for_login(pin: str, stored_hash: Optional[str]) -> bool:
|
||||||
|
"""Immer ein bcrypt-Vergleich, auch wenn das Konto fehlt (kein Zeit-Orakel)."""
|
||||||
|
if stored_hash and str(stored_hash).startswith("$2"):
|
||||||
|
return verify_pin(pin, stored_hash)
|
||||||
|
verify_pin(pin or "x", _LOGIN_TIMING_HASH)
|
||||||
|
if not stored_hash:
|
||||||
|
return False
|
||||||
|
return verify_pin(pin, stored_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def session_lifetime_days(profile: Optional[dict]) -> int:
|
||||||
|
raw = profile.get("session_days") if profile else None
|
||||||
|
try:
|
||||||
|
days = int(raw) if raw is not None else MAX_SESSION_DAYS
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
days = MAX_SESSION_DAYS
|
||||||
|
if days < 1:
|
||||||
|
days = 1
|
||||||
|
if days > MAX_SESSION_DAYS:
|
||||||
|
days = MAX_SESSION_DAYS
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_profile_sessions(cur, profile_id: int) -> None:
|
||||||
|
"""Alle Sitzungen eines Profils entfernen, inklusive Reset- und 2FA-Zwischentokens."""
|
||||||
|
cur.execute("DELETE FROM sessions WHERE profile_id = %s", (profile_id,))
|
||||||
|
|
||||||
|
|
||||||
def make_token() -> str:
|
def make_token() -> str:
|
||||||
"""Generate a secure random token for sessions."""
|
"""Generate a secure random token for sessions."""
|
||||||
return secrets.token_urlsafe(32)
|
return secrets.token_urlsafe(32)
|
||||||
|
|
@ -51,12 +84,39 @@ def get_session(token: str):
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT s.*, p.role, p.name "
|
"SELECT s.*, p.role, p.name "
|
||||||
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
|
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
|
||||||
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP",
|
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP "
|
||||||
|
"AND COALESCE(s.purpose, 'full') = 'full'",
|
||||||
(token,)
|
(token,)
|
||||||
)
|
)
|
||||||
return cur.fetchone()
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_limited_session(token: str, purpose: str):
|
||||||
|
"""Sitzung mit festem Zweck (totp_enroll, totp_challenge). Kein Fach-API-Zugriff."""
|
||||||
|
if not token or purpose not in ("totp_enroll", "totp_challenge"):
|
||||||
|
return None
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT s.*, p.role, p.name, p.email "
|
||||||
|
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
|
||||||
|
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP "
|
||||||
|
"AND s.purpose = %s",
|
||||||
|
(token, purpose),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_session_from_id(profile_id: int) -> Optional[dict]:
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id AS profile_id, role, name FROM profiles WHERE id = %s",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
def require_auth(x_auth_token: Optional[str] = Header(default=None)):
|
def require_auth(x_auth_token: Optional[str] = Header(default=None)):
|
||||||
"""
|
"""
|
||||||
FastAPI dependency - requires valid authentication.
|
FastAPI dependency - requires valid authentication.
|
||||||
|
|
@ -80,8 +140,8 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss
|
||||||
"""
|
"""
|
||||||
FastAPI dependency - auth via header OR query parameter.
|
FastAPI dependency - auth via header OR query parameter.
|
||||||
|
|
||||||
Used for endpoints accessed by <img> tags and SSE connections that can't send headers.
|
Dateiabruf für <img>/<video>: Header-Sitzung oder kurzlebiges Medien-Token (?ssetoken).
|
||||||
Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters.
|
Ein Sitzungstoken in der Query-Zeichenfolge wird nicht akzeptiert.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@app.get("/api/photos/{id}")
|
@app.get("/api/photos/{id}")
|
||||||
|
|
@ -93,7 +153,13 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException 401 if not authenticated
|
HTTPException 401 if not authenticated
|
||||||
"""
|
"""
|
||||||
session = get_session(x_auth_token or ssetoken)
|
session = get_session(x_auth_token) if x_auth_token else None
|
||||||
|
if session:
|
||||||
|
return session
|
||||||
|
profile_id = profile_id_from_media_grant(ssetoken)
|
||||||
|
if profile_id is None:
|
||||||
|
raise HTTPException(401, "Nicht eingeloggt")
|
||||||
|
session = _profile_session_from_id(profile_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(401, "Nicht eingeloggt")
|
raise HTTPException(401, "Nicht eingeloggt")
|
||||||
return session
|
return session
|
||||||
|
|
@ -170,6 +236,10 @@ def get_effective_tier(profile_id: str, conn=None) -> str:
|
||||||
|
|
||||||
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
||||||
"""
|
"""
|
||||||
|
DEPRECATED für Shinkan: Mitai-v9c-Profil-Limits — Schema 001 ist archiviert (Migration 078).
|
||||||
|
|
||||||
|
Für Vereins-Kontingente: club_features.check_club_feature_access(club_id, feature_id).
|
||||||
|
|
||||||
Check if a profile has access to a feature.
|
Check if a profile has access to a feature.
|
||||||
|
|
||||||
Access hierarchy:
|
Access hierarchy:
|
||||||
|
|
@ -315,6 +385,8 @@ def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
|
||||||
|
|
||||||
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
DEPRECATED für Shinkan — siehe club_features.increment_club_feature_usage.
|
||||||
|
|
||||||
Increment usage counter for a feature.
|
Increment usage counter for a feature.
|
||||||
|
|
||||||
Creates usage record if it doesn't exist, with reset_at based on
|
Creates usage record if it doesn't exist, with reset_at based on
|
||||||
|
|
|
||||||
285
backend/capabilities.py
Normal file
285
backend/capabilities.py
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
"""
|
||||||
|
Capability-Auflösung (CAPABILITY_CATALOG.v1.md, M3 C1).
|
||||||
|
|
||||||
|
Phase 2: probe_capability — JSON-Log, kein Block (CAPABILITY_ENFORCE=0).
|
||||||
|
Phase 3+: CAPABILITY_ENFORCE=1 — HTTP 403 bei fehlender Capability.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from account_lifecycle import account_state_satisfies
|
||||||
|
from club_tenancy import is_platform_admin
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def capability_enforcement_enabled() -> bool:
|
||||||
|
v = os.getenv("CAPABILITY_ENFORCE", "0").strip().lower()
|
||||||
|
return v in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]:
|
||||||
|
if club_id is None:
|
||||||
|
return []
|
||||||
|
for m in tenant.memberships or []:
|
||||||
|
if int(m.get("id") or 0) == int(club_id):
|
||||||
|
roles = m.get("roles") or []
|
||||||
|
if hasattr(roles, "tolist"):
|
||||||
|
roles = roles.tolist()
|
||||||
|
return list(roles)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def check_capability(
|
||||||
|
cur,
|
||||||
|
tenant: "TenantContext",
|
||||||
|
capability_id: str,
|
||||||
|
*,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Prüft eine Capability für Tenant + optionalen Vereinskontext.
|
||||||
|
|
||||||
|
Returns: allowed, reason, account_state, club_roles, linked_feature_id
|
||||||
|
"""
|
||||||
|
account_state = getattr(tenant, "account_state", "active_member")
|
||||||
|
eff_club = club_id if club_id is not None else tenant.effective_club_id
|
||||||
|
club_roles = club_roles_in_club(tenant, eff_club) if eff_club is not None else []
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, min_account_state, linked_feature_id, active, domain
|
||||||
|
FROM capabilities
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(capability_id,),
|
||||||
|
)
|
||||||
|
cap = cur.fetchone()
|
||||||
|
if not cap or not cap.get("active"):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "capability_not_found",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
min_state = cap.get("min_account_state") or "active_member"
|
||||||
|
if not account_state_satisfies(account_state, min_state):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "account_state_insufficient",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
domain = (cap.get("domain") or "").strip().lower()
|
||||||
|
|
||||||
|
# Kontingent-Bypass (konfigurierbar per portal_role / profile grants, ohne Plattform-Admin-Pflicht)
|
||||||
|
if domain == "quota_bypass":
|
||||||
|
role_lc = (tenant.global_role or "").lower()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(role_lc, capability_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "quota_bypass_portal_grant",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(tenant.profile_id, capability_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "quota_bypass_profile_grant",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "quota_bypass_denied",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plattform-Capabilities
|
||||||
|
if domain == "platform" or capability_id.startswith("platform."):
|
||||||
|
role_lc = (tenant.global_role or "").lower()
|
||||||
|
if not is_platform_admin(role_lc):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "portal_role_required",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(role_lc, capability_id),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(tenant.profile_id, capability_id),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "portal_capability_denied",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "portal_granted",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plattform-Admin-Bypass für Mandanten-Funktionen (Audit-Pflicht, s. Katalog §9)
|
||||||
|
if is_platform_admin(tenant.global_role):
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "platform_admin_bypass",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vereins-Capabilities: aktive Mitgliedschaft im Zielverein
|
||||||
|
if min_state == "active_member":
|
||||||
|
if eff_club is None:
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "no_club_context",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
if eff_club not in tenant.club_ids:
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "not_club_member",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT role_code FROM club_role_capability_grants
|
||||||
|
WHERE capability_id = %s
|
||||||
|
""",
|
||||||
|
(capability_id,),
|
||||||
|
)
|
||||||
|
required_roles = [r["role_code"] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
if required_roles:
|
||||||
|
if not any(r in required_roles for r in club_roles):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "club_role_denied",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
elif min_state == "active_member" and eff_club is not None:
|
||||||
|
# Offene Capability für alle aktiven Mitglieder — Mitgliedschaft reicht
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "granted",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_capabilities_map(
|
||||||
|
cur,
|
||||||
|
tenant: "TenantContext",
|
||||||
|
*,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, bool]:
|
||||||
|
"""Alle aktiven Capabilities → bool (für späteres /me/entitlements)."""
|
||||||
|
cur.execute("SELECT id FROM capabilities WHERE active = true ORDER BY id")
|
||||||
|
ids = [r["id"] for r in cur.fetchall()]
|
||||||
|
out: Dict[str, bool] = {}
|
||||||
|
for cid in ids:
|
||||||
|
res = check_capability(cur, tenant, cid, club_id=club_id)
|
||||||
|
out[cid] = bool(res.get("allowed"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def probe_capability(
|
||||||
|
tenant: "TenantContext",
|
||||||
|
capability_id: str,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Phase 2: Capability prüfen + JSON-Log; blockiert nur bei CAPABILITY_ENFORCE=1."""
|
||||||
|
from capability_logger import log_capability_check
|
||||||
|
|
||||||
|
def _run(c):
|
||||||
|
cur = get_cursor(c)
|
||||||
|
result = check_capability(cur, tenant, capability_id, club_id=club_id)
|
||||||
|
log_capability_check(
|
||||||
|
club_id=club_id if club_id is not None else tenant.effective_club_id,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
capability_id=capability_id,
|
||||||
|
action=action,
|
||||||
|
result=result,
|
||||||
|
endpoint=endpoint,
|
||||||
|
phase="enforce" if capability_enforcement_enabled() else "probe",
|
||||||
|
)
|
||||||
|
if capability_enforcement_enabled() and not result.get("allowed"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=(
|
||||||
|
f"Keine Berechtigung für {capability_id} "
|
||||||
|
f"({result.get('reason', 'denied')})."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
return _run(conn)
|
||||||
|
with get_db() as c:
|
||||||
|
return _run(c)
|
||||||
94
backend/capability_enforcement_audit.py
Normal file
94
backend/capability_enforcement_audit.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""
|
||||||
|
Audit: Welche Capabilities sind an Endpoints angebunden?
|
||||||
|
|
||||||
|
Für Admin-Matrix (Rollen & Rechte) und Roadmap — bei neuem probe_capability hier eintragen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
# Endpoints rufen probe_capability auf (Log; Block nur bei CAPABILITY_ENFORCE=1)
|
||||||
|
WIRED_PROBE = frozenset(
|
||||||
|
{
|
||||||
|
"exercises.ai.suggest",
|
||||||
|
"exercises.ai.regenerate",
|
||||||
|
"exercises.create",
|
||||||
|
"exercises.media.upload",
|
||||||
|
"planning.ai.suggest",
|
||||||
|
"planning.ai.progression_path",
|
||||||
|
"club.creation_request.read_own",
|
||||||
|
"club.creation_request.create",
|
||||||
|
"club.creation_request.withdraw",
|
||||||
|
"platform.club_creation.approve",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kontingent-Verbrauch nach Erfolg (consume_club_feature_with_usage)
|
||||||
|
FEATURE_CONSUME_WIRED = frozenset(
|
||||||
|
{
|
||||||
|
"ai_calls",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enforcement_status_for_capability(capability_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Anzeige-Status für Superadmin-Matrix.
|
||||||
|
|
||||||
|
level: probe | legacy | platform | open | none
|
||||||
|
"""
|
||||||
|
cid = (capability_id or "").strip()
|
||||||
|
if cid in WIRED_PROBE:
|
||||||
|
return {
|
||||||
|
"level": "probe",
|
||||||
|
"label": "API vorbereitet (Log)",
|
||||||
|
"detail": "probe_capability am Endpoint; Hard-Block erst mit CAPABILITY_ENFORCE=1",
|
||||||
|
"implemented": True,
|
||||||
|
}
|
||||||
|
if cid.startswith("platform."):
|
||||||
|
if cid == "platform.admin.access":
|
||||||
|
return {
|
||||||
|
"level": "platform",
|
||||||
|
"label": "Plattform (Router-Guard)",
|
||||||
|
"detail": "RequireAdmin / Superadmin-Checks",
|
||||||
|
"implemented": True,
|
||||||
|
}
|
||||||
|
if cid in WIRED_PROBE:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"level": "platform",
|
||||||
|
"label": "Plattform (teilweise)",
|
||||||
|
"detail": "Meist Router-Guard; Capability-Probe nur wo eingetragen",
|
||||||
|
"implemented": cid in WIRED_PROBE,
|
||||||
|
}
|
||||||
|
if cid.startswith("club."):
|
||||||
|
return {
|
||||||
|
"level": "open",
|
||||||
|
"label": "Onboarding",
|
||||||
|
"detail": "Account-State / eigene Flows",
|
||||||
|
"implemented": cid in WIRED_PROBE,
|
||||||
|
}
|
||||||
|
# Vereins-Capabilities ohne Probe: Legacy club_tenancy (can_plan_in_club, has_club_role, …)
|
||||||
|
return {
|
||||||
|
"level": "legacy",
|
||||||
|
"label": "Nur Legacy-Rollen",
|
||||||
|
"detail": "Noch kein probe_capability — prüft can_plan_in_club / club_admin im Code",
|
||||||
|
"implemented": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def feature_consume_status(feature_id: str) -> Dict[str, Any]:
|
||||||
|
fid = (feature_id or "").strip()
|
||||||
|
if fid in FEATURE_CONSUME_WIRED:
|
||||||
|
return {
|
||||||
|
"level": "consume",
|
||||||
|
"label": "Verbrauch aktiv",
|
||||||
|
"detail": "consume_club_feature_with_usage + feature_usage in Response",
|
||||||
|
"implemented": True,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"level": "inventory",
|
||||||
|
"label": "Bestand / Probe",
|
||||||
|
"detail": "Probe oder Live-Zählung; kein Consume nach Aktion",
|
||||||
|
"implemented": False,
|
||||||
|
}
|
||||||
64
backend/capability_logger.py
Normal file
64
backend/capability_logger.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""
|
||||||
|
JSON-Log für Capability-Checks (M3 Phase 2 — analog club_feature_logger).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _log_dir() -> Path:
|
||||||
|
custom = (os.getenv("CAPABILITY_LOG_DIR") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return Path(custom)
|
||||||
|
return Path("/app/logs")
|
||||||
|
|
||||||
|
|
||||||
|
capability_logger = logging.getLogger("shinkan.capability_usage")
|
||||||
|
capability_logger.setLevel(logging.INFO)
|
||||||
|
capability_logger.propagate = False
|
||||||
|
|
||||||
|
if not capability_logger.handlers:
|
||||||
|
log_dir = _log_dir()
|
||||||
|
try:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = log_dir / "capability-usage.log"
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
capability_logger.addHandler(file_handler)
|
||||||
|
except OSError:
|
||||||
|
stream_handler = logging.StreamHandler()
|
||||||
|
stream_handler.setFormatter(logging.Formatter("[capability-usage] %(message)s"))
|
||||||
|
capability_logger.addHandler(stream_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def log_capability_check(
|
||||||
|
*,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int],
|
||||||
|
capability_id: str,
|
||||||
|
action: str,
|
||||||
|
result: Dict[str, Any],
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
phase: str = "probe",
|
||||||
|
) -> None:
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"club_id": club_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"capability": capability_id,
|
||||||
|
"action": action,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"phase": phase,
|
||||||
|
"allowed": result.get("allowed", True),
|
||||||
|
"reason": result.get("reason", "unknown"),
|
||||||
|
"account_state": result.get("account_state"),
|
||||||
|
"club_roles": result.get("club_roles"),
|
||||||
|
"enforcement": os.getenv("CAPABILITY_ENFORCE", "0") == "1",
|
||||||
|
}
|
||||||
|
capability_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||||
432
backend/catalog_prompt_slots.py
Normal file
432
backend/catalog_prompt_slots.py
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
"""
|
||||||
|
Katalog-Prompt-Slots — Slot-Typ-Vokabular + Werte pro Stammdaten-Zeile (H2).
|
||||||
|
|
||||||
|
Prompts in ai_prompts referenzieren Platzhalter wie {{focus_area_hints_on_progression}}.
|
||||||
|
Inhalte liegen in catalog_prompt_slots (Admin-editierbar), nicht im Code pro Eintrag.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from planning_catalog_context import (
|
||||||
|
ProgressionPlanningCatalogContext,
|
||||||
|
PlanningCatalogContextItem,
|
||||||
|
catalog_context_has_items,
|
||||||
|
)
|
||||||
|
from catalog_slot_fallbacks import merge_stored_slots_with_fallbacks
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dimensionen (Prioritätsreihenfolge)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CatalogKindConfig:
|
||||||
|
kind: str
|
||||||
|
table: str
|
||||||
|
context_attr: str
|
||||||
|
label_de: str
|
||||||
|
|
||||||
|
|
||||||
|
CATALOG_KINDS: Tuple[CatalogKindConfig, ...] = (
|
||||||
|
CatalogKindConfig("focus_area", "focus_areas", "focus_areas", "Primärfokus"),
|
||||||
|
CatalogKindConfig("training_type", "training_types", "training_types", "Trainingsstil"),
|
||||||
|
CatalogKindConfig("target_group", "target_groups", "target_groups", "Zielgruppe"),
|
||||||
|
CatalogKindConfig("style_direction", "style_directions", "style_directions", "Stilrichtung"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_KIND_BY_NAME = {c.kind: c for c in CATALOG_KINDS}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slot-Typen (Vokabular — erweiterbar via catalog_prompt_slot_types)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SLOT_KEYS: Tuple[str, ...] = (
|
||||||
|
"description",
|
||||||
|
"hints_on_progression",
|
||||||
|
"hints_on_exercise",
|
||||||
|
"hints_on_path_qa",
|
||||||
|
"anti_patterns",
|
||||||
|
"rematch_guard",
|
||||||
|
)
|
||||||
|
|
||||||
|
LLM_SLOT_KEYS: Tuple[str, ...] = tuple(k for k in SLOT_KEYS if k != "rematch_guard")
|
||||||
|
|
||||||
|
GUIDANCE_BLOCK_SLOTS: Tuple[str, ...] = (
|
||||||
|
"description",
|
||||||
|
"hints_on_progression",
|
||||||
|
"hints_on_path_qa",
|
||||||
|
"anti_patterns",
|
||||||
|
)
|
||||||
|
|
||||||
|
GUIDANCE_PROFILE_BY_SLUG: Dict[str, Tuple[str, ...]] = {
|
||||||
|
"planning_exercise_path_qa": ("description", "hints_on_path_qa", "anti_patterns"),
|
||||||
|
"planning_progression_roadmap": ("description", "hints_on_progression", "anti_patterns"),
|
||||||
|
"planning_progression_stage_spec": ("hints_on_progression", "anti_patterns", "description"),
|
||||||
|
"planning_progression_goal_analysis": ("description", "hints_on_progression"),
|
||||||
|
"planning_progression_start_target": ("description",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def placeholder_key(catalog_kind: str, slot_key: str) -> str:
|
||||||
|
return f"{catalog_kind}_{slot_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def all_placeholder_keys() -> List[str]:
|
||||||
|
keys: List[str] = []
|
||||||
|
for cfg in CATALOG_KINDS:
|
||||||
|
for slot in SLOT_KEYS:
|
||||||
|
keys.append(placeholder_key(cfg.kind, slot))
|
||||||
|
keys.extend(["catalog_guidance_block", "catalog_context_json", "has_catalog_guidance"])
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def empty_catalog_variables() -> Dict[str, str]:
|
||||||
|
out = {k: "" for k in all_placeholder_keys()}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Katalog-Kontext → aktiver Eintrag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pick_active_catalog_item(
|
||||||
|
items: Sequence[PlanningCatalogContextItem],
|
||||||
|
) -> Optional[PlanningCatalogContextItem]:
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
primaries = [i for i in items if i.is_primary]
|
||||||
|
if primaries:
|
||||||
|
return primaries[0]
|
||||||
|
if len(items) == 1:
|
||||||
|
return items[0]
|
||||||
|
return max(items, key=lambda i: (float(i.weight), -int(i.id)))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalog_row(cur, table: str, item_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, name, description
|
||||||
|
FROM {table}
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(int(item_id),),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": int(row["id"]),
|
||||||
|
"name": str(row.get("name") or "").strip(),
|
||||||
|
"description": str(row.get("description") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_slots_for_entry(cur, catalog_kind: str, catalog_id: int) -> Dict[str, str]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT slot_key, content
|
||||||
|
FROM catalog_prompt_slots
|
||||||
|
WHERE catalog_kind = %s AND catalog_id = %s
|
||||||
|
""",
|
||||||
|
(catalog_kind, int(catalog_id)),
|
||||||
|
)
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for row in cur.fetchall():
|
||||||
|
key = str(row.get("slot_key") or "").strip()
|
||||||
|
if key:
|
||||||
|
out[key] = str(row.get("content") or "").strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_types_table_ready(cur) -> bool:
|
||||||
|
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.catalog_prompt_slot_types",))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
val = row.get("t") if isinstance(row, dict) else row[0]
|
||||||
|
return val is not None and str(val).strip() != ""
|
||||||
|
|
||||||
|
|
||||||
|
def list_slot_type_definitions(cur) -> List[Dict[str, Any]]:
|
||||||
|
if not _slot_types_table_ready(cur):
|
||||||
|
return _fallback_slot_type_rows()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT slot_key, display_name, description, applicable_kinds, sort_order, for_llm, for_code
|
||||||
|
FROM catalog_prompt_slot_types
|
||||||
|
ORDER BY sort_order ASC NULLS LAST, slot_key ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
d = dict(row)
|
||||||
|
kinds = d.get("applicable_kinds")
|
||||||
|
if isinstance(kinds, str):
|
||||||
|
kinds = [k.strip() for k in kinds.strip("{}").split(",") if k.strip()]
|
||||||
|
d["applicable_kinds"] = list(kinds or [])
|
||||||
|
rows.append(d)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_slot_type_rows() -> List[Dict[str, Any]]:
|
||||||
|
labels = {
|
||||||
|
"description": "Allgemeine Beschreibung",
|
||||||
|
"hints_on_progression": "Hinweise Progressionsgraph",
|
||||||
|
"hints_on_exercise": "Hinweise Übungsanlage",
|
||||||
|
"hints_on_path_qa": "Hinweise Pfad-QS",
|
||||||
|
"anti_patterns": "Anti-Patterns (Fehlbewertung vermeiden)",
|
||||||
|
"rematch_guard": "Rematch-Guard (Code)",
|
||||||
|
}
|
||||||
|
kinds = [c.kind for c in CATALOG_KINDS]
|
||||||
|
rows = []
|
||||||
|
for i, key in enumerate(SLOT_KEYS):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"slot_key": key,
|
||||||
|
"display_name": labels.get(key, key),
|
||||||
|
"description": "",
|
||||||
|
"applicable_kinds": kinds,
|
||||||
|
"sort_order": (i + 1) * 10,
|
||||||
|
"for_llm": key != "rematch_guard",
|
||||||
|
"for_code": key == "rematch_guard",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_entry_slot_values(
|
||||||
|
stored: Mapping[str, str],
|
||||||
|
row: Mapping[str, Any],
|
||||||
|
catalog_kind: str,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""DB → Namens-Fallback → Stammdaten-Beschreibung (nur description)."""
|
||||||
|
return merge_stored_slots_with_fallbacks(
|
||||||
|
stored,
|
||||||
|
catalog_kind=catalog_kind,
|
||||||
|
name=str(row.get("name") or ""),
|
||||||
|
stammdaten_description=str(row.get("description") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_catalog_entry_slots(cur, catalog_kind: str, catalog_id: int) -> Dict[str, Any]:
|
||||||
|
cfg = _KIND_BY_NAME.get((catalog_kind or "").strip())
|
||||||
|
if not cfg:
|
||||||
|
raise ValueError(f"Unbekannter catalog_kind: {catalog_kind!r}")
|
||||||
|
row = _load_catalog_row(cur, cfg.table, catalog_id)
|
||||||
|
if not row:
|
||||||
|
raise LookupError("Katalog-Eintrag nicht gefunden")
|
||||||
|
stored = _load_slots_for_entry(cur, cfg.kind, catalog_id)
|
||||||
|
merged = _resolve_entry_slot_values(stored, row, cfg.kind)
|
||||||
|
return {
|
||||||
|
"catalog_kind": cfg.kind,
|
||||||
|
"catalog_id": int(catalog_id),
|
||||||
|
"name": row["name"],
|
||||||
|
"slots": merged,
|
||||||
|
"stored_slots": {k: stored.get(k, "") for k in SLOT_KEYS},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_catalog_entry_slots(
|
||||||
|
cur,
|
||||||
|
catalog_kind: str,
|
||||||
|
catalog_id: int,
|
||||||
|
slots: Mapping[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
cfg = _KIND_BY_NAME.get((catalog_kind or "").strip())
|
||||||
|
if not cfg:
|
||||||
|
raise ValueError(f"Unbekannter catalog_kind: {catalog_kind!r}")
|
||||||
|
row = _load_catalog_row(cur, cfg.table, catalog_id)
|
||||||
|
if not row:
|
||||||
|
raise LookupError("Katalog-Eintrag nicht gefunden")
|
||||||
|
for slot_key, raw in (slots or {}).items():
|
||||||
|
sk = str(slot_key or "").strip()
|
||||||
|
if sk not in SLOT_KEYS:
|
||||||
|
continue
|
||||||
|
content = str(raw or "").strip()
|
||||||
|
if not content:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM catalog_prompt_slots
|
||||||
|
WHERE catalog_kind = %s AND catalog_id = %s AND slot_key = %s
|
||||||
|
""",
|
||||||
|
(cfg.kind, int(catalog_id), sk),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, NOW())
|
||||||
|
ON CONFLICT (catalog_kind, catalog_id, slot_key)
|
||||||
|
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(cfg.kind, int(catalog_id), sk, content),
|
||||||
|
)
|
||||||
|
return get_catalog_entry_slots(cur, cfg.kind, catalog_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_dimension_section(
|
||||||
|
label_de: str,
|
||||||
|
name: str,
|
||||||
|
slot_values: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
slot_keys: Sequence[str],
|
||||||
|
) -> Optional[str]:
|
||||||
|
parts: List[str] = [f"### {label_de} — {name}"]
|
||||||
|
labels = {
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"hints_on_progression": "Progressions-Hinweise",
|
||||||
|
"hints_on_path_qa": "QS-Hinweise",
|
||||||
|
"hints_on_exercise": "Übungsanlage",
|
||||||
|
"anti_patterns": "Vermeiden",
|
||||||
|
}
|
||||||
|
added = False
|
||||||
|
for sk in slot_keys:
|
||||||
|
text = str(slot_values.get(sk) or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
added = True
|
||||||
|
if sk == "description":
|
||||||
|
parts.append(text)
|
||||||
|
else:
|
||||||
|
parts.append(f"{labels.get(sk, sk)}: {text}")
|
||||||
|
if not added:
|
||||||
|
return None
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_guidance_block(
|
||||||
|
sections: List[str],
|
||||||
|
) -> str:
|
||||||
|
if not sections:
|
||||||
|
return ""
|
||||||
|
return "## Katalog-Kontext (Didaktik & Bewertung)\n\n" + "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_catalog_prompt_variables(
|
||||||
|
cur,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||||
|
*,
|
||||||
|
slug: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Liefert Mustache-Strings + Metadaten.
|
||||||
|
|
||||||
|
Returns dict mit allen {{kind_slot}} Keys, catalog_guidance_block, catalog_context_json,
|
||||||
|
has_catalog_guidance (bool), active_slots (list).
|
||||||
|
"""
|
||||||
|
variables = empty_catalog_variables()
|
||||||
|
meta: Dict[str, Any] = {
|
||||||
|
"active_slots": [],
|
||||||
|
"audit": {},
|
||||||
|
}
|
||||||
|
if cur is None or not catalog_context_has_items(catalog):
|
||||||
|
variables["catalog_context_json"] = ""
|
||||||
|
return {**variables, **meta}
|
||||||
|
|
||||||
|
profile = GUIDANCE_PROFILE_BY_SLUG.get((slug or "").strip().lower(), GUIDANCE_BLOCK_SLOTS)
|
||||||
|
sections: List[str] = []
|
||||||
|
audit: Dict[str, Any] = {}
|
||||||
|
has_any = False
|
||||||
|
active_slots: List[str] = []
|
||||||
|
|
||||||
|
for cfg in CATALOG_KINDS:
|
||||||
|
items = getattr(catalog, cfg.context_attr, None) or []
|
||||||
|
active = pick_active_catalog_item(items)
|
||||||
|
if not active:
|
||||||
|
continue
|
||||||
|
row = _load_catalog_row(cur, cfg.table, active.id)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
stored = _load_slots_for_entry(cur, cfg.kind, row["id"]) if _slot_types_table_ready(cur) else {}
|
||||||
|
slot_values = _resolve_entry_slot_values(stored, row, cfg.kind)
|
||||||
|
for sk in SLOT_KEYS:
|
||||||
|
pk = placeholder_key(cfg.kind, sk)
|
||||||
|
text = slot_values.get(sk, "")
|
||||||
|
variables[pk] = text
|
||||||
|
if text.strip() and sk in LLM_SLOT_KEYS:
|
||||||
|
has_any = True
|
||||||
|
active_slots.append(pk)
|
||||||
|
|
||||||
|
audit[cfg.context_attr] = {
|
||||||
|
"catalog_kind": cfg.kind,
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"is_primary": bool(active.is_primary),
|
||||||
|
"weight": float(active.weight),
|
||||||
|
"filled_slots": [k for k in LLM_SLOT_KEYS if slot_values.get(k, "").strip()],
|
||||||
|
"stored_slots": [k for k in SLOT_KEYS if (stored.get(k) or "").strip()],
|
||||||
|
}
|
||||||
|
|
||||||
|
section = _render_dimension_section(cfg.label_de, row["name"], slot_values, slot_keys=profile)
|
||||||
|
if section:
|
||||||
|
sections.append(section)
|
||||||
|
|
||||||
|
variables["catalog_guidance_block"] = _compose_guidance_block(sections)
|
||||||
|
ctx_json = json.dumps(audit, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
variables["catalog_context_json"] = f"Katalog-Audit: {ctx_json}" if audit else ""
|
||||||
|
variables["has_catalog_guidance"] = "true" if has_any else ""
|
||||||
|
return {
|
||||||
|
**variables,
|
||||||
|
"active_slots": active_slots,
|
||||||
|
"audit": audit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_rematch_guard_for_catalog(
|
||||||
|
cur,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Erste passende rematch_guard entlang der Dimensions-Priorität."""
|
||||||
|
if cur is None or not catalog_context_has_items(catalog):
|
||||||
|
return None
|
||||||
|
for cfg in CATALOG_KINDS:
|
||||||
|
items = getattr(catalog, cfg.context_attr, None) or []
|
||||||
|
active = pick_active_catalog_item(items)
|
||||||
|
if not active:
|
||||||
|
continue
|
||||||
|
stored = _load_slots_for_entry(cur, cfg.kind, active.id)
|
||||||
|
row = _load_catalog_row(cur, cfg.table, active.id)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
slot_values = _resolve_entry_slot_values(stored, row, cfg.kind)
|
||||||
|
guard = (slot_values.get("rematch_guard") or "").strip()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Abwärtskompatibilität H1-API
|
||||||
|
def build_catalog_guidance_for_prompt(
|
||||||
|
cur,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||||
|
*,
|
||||||
|
slug: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
resolved = resolve_catalog_prompt_variables(cur, catalog, slug=slug)
|
||||||
|
return {
|
||||||
|
"catalog_guidance_block": resolved.get("catalog_guidance_block", ""),
|
||||||
|
"catalog_context_json": resolved.get("catalog_context_json", ""),
|
||||||
|
"has_catalog_guidance": resolved.get("has_catalog_guidance") == "true",
|
||||||
|
"snippet_keys": list(resolved.get("active_slots") or []),
|
||||||
|
"variables": {k: str(resolved.get(k) or "") for k in all_placeholder_keys()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CATALOG_KINDS",
|
||||||
|
"GUIDANCE_PROFILE_BY_SLUG",
|
||||||
|
"SLOT_KEYS",
|
||||||
|
"build_catalog_guidance_for_prompt",
|
||||||
|
"empty_catalog_variables",
|
||||||
|
"get_catalog_entry_slots",
|
||||||
|
"get_rematch_guard_for_catalog",
|
||||||
|
"list_slot_type_definitions",
|
||||||
|
"pick_active_catalog_item",
|
||||||
|
"placeholder_key",
|
||||||
|
"all_placeholder_keys",
|
||||||
|
"resolve_catalog_prompt_variables",
|
||||||
|
"upsert_catalog_entry_slots",
|
||||||
|
]
|
||||||
284
backend/catalog_slot_fallbacks.py
Normal file
284
backend/catalog_slot_fallbacks.py
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
"""
|
||||||
|
Namensbasierte Fallback-Slots — bis Admin/DB befüllt sind (H1-Registry-Inhalt).
|
||||||
|
|
||||||
|
DB-Werte in catalog_prompt_slots haben immer Vorrang. Fallbacks füllen nur leere Slot-Keys.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from typing import Dict, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
_UMLAUT_MAP = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss", "Ä": "ae", "Ö": "oe", "Ü": "ue"})
|
||||||
|
|
||||||
|
SlotPack = Dict[str, str]
|
||||||
|
|
||||||
|
# (catalog_kind, name_pattern_lower) — erste passende Regel gewinnt; * = Default pro Kind
|
||||||
|
_FALLBACK_RULES: Tuple[Tuple[str, str, SlotPack], ...] = (
|
||||||
|
# --- focus_area ---
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"gewaltschutz",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — "
|
||||||
|
"nicht auf Wettkampf-Perfektion oder Technik-Show."
|
||||||
|
),
|
||||||
|
"hints_on_progression": (
|
||||||
|
"Phasen: Wahrnehmung → Grenzen → Deeskalation → sichere Übungsformen; "
|
||||||
|
"keine Kumite-Perfektionsstufen erzwingen."
|
||||||
|
),
|
||||||
|
"hints_on_exercise": (
|
||||||
|
"Übungen mit Rollen, Kommunikation, Ausweichen; keine rein technischen Kick-Fokus-Inseln ohne Bezug."
|
||||||
|
),
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; "
|
||||||
|
"„Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"selbstverteidigung",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Praktische Selbstverteidigung: realistische Szenarien, Sicherheit und "
|
||||||
|
"anwendungsnahe Progression — nicht Show-Technik oder Wettkampf-Kata."
|
||||||
|
),
|
||||||
|
"hints_on_progression": (
|
||||||
|
"Von Wahrnehmung und Distanz zu einfachen Abwehrmustern und kontrollierter Anwendung."
|
||||||
|
),
|
||||||
|
"hints_on_exercise": "Partnerübungen mit klaren Sicherheitsregeln; Szenario-Bezug wichtiger als Stil-Show.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Lücken bei Szenario- oder Sicherheitsstufen sind relevant; "
|
||||||
|
"fehlende Kick-Varianten oder Wettkampftiefe sind kein Mangel."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine Wettkampf- oder Kata-Perfektion als QS-Maßstab.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"fitness",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Fitness- und Konditionsorientierung mit sicherer Belastungssteuerung; "
|
||||||
|
"Technikbezug nur wo fachlich sinnvoll."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Progression von niedriger zu moderater Belastung; klare Pausen und Technikhygiene.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Keine Wettkampf-Spezialisierung als Pflicht-Kriterium; "
|
||||||
|
"Belastungssteigerung ohne Technikbezug abwerten."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine Kumite-Perfektion oder Wettkampf-Kombinationen als QS-Maßstab verlangen.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"karate",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Technik-Curriculum im Karate-Kontext: aufeinander aufbauende Kihon-Progression "
|
||||||
|
"mit klaren Qualitätsankern (Stand, Hüfte, Kime)."
|
||||||
|
),
|
||||||
|
"hints_on_progression": (
|
||||||
|
"Typische Phasen: Einstieg → Grundlagen → Koordination/Kraft → Anwendung → optional Vertiefung; "
|
||||||
|
"Grundlagen vor Perfektion."
|
||||||
|
),
|
||||||
|
"hints_on_exercise": (
|
||||||
|
"Kihon und Partnerübungen mit Technikbezug; reine Kraft-/Ausdauer-Inseln nur mit klarer Begründung."
|
||||||
|
),
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Kohärente Progression Grundlagen → Anwendung → Vertiefung; "
|
||||||
|
"Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine pauschale Perfektions-Stufe verlangen, wenn Kontext Breitensport ist.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"focus_area",
|
||||||
|
"*",
|
||||||
|
{
|
||||||
|
"description": "Technik- oder Themen-Curriculum mit didaktisch aufeinander aufbauenden Stufen.",
|
||||||
|
"hints_on_progression": "Grundlagen vor Anwendung; moderate Sprünge zwischen Stufen vermeiden.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Kohärente Progression zum Anfrage-Thema; "
|
||||||
|
"Lücken sind fehlende Zwischenstufen im Lernpfad, nicht fehlende Nebenthemen."
|
||||||
|
),
|
||||||
|
"hints_on_exercise": "Übungen mit klarem Bezug zum Pfad-Thema und zur Stufe.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# --- training_type ---
|
||||||
|
(
|
||||||
|
"training_type",
|
||||||
|
"breitensport",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Moderater Schwierigkeitsanstieg; Perfektionsphasen optional.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; "
|
||||||
|
"„Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke."
|
||||||
|
),
|
||||||
|
"rematch_guard": "Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"training_type",
|
||||||
|
"leistungssport",
|
||||||
|
{
|
||||||
|
"description": "Leistungsorientiertes Training mit höherer Anspruchskurve und Spezialisierung.",
|
||||||
|
"hints_on_progression": "Belastungs- und Kombinationsprogressionen sind erwünscht.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Höhere Anspruchskurven sind passend; Lücken in Spezialisierung können echte Hinweise sein."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"training_type",
|
||||||
|
"wettkampf",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Wettkampforientiertes Training mit höherer Anspruchskurve und belastungsnahen Phasen."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Anwendungs- und Druckphasen zeitig einplanen.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Spezialisierung, Kombination und Belastung unter Druck sind relevant; "
|
||||||
|
"Lücken in Anwendungs- oder Perfektionsphasen können echte Hinweise sein."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"training_type",
|
||||||
|
"*",
|
||||||
|
{
|
||||||
|
"hints_on_path_qa": "Didaktische Kohärenz wichtiger als maximale Spezialisierung — Kontext beachten.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# --- target_group ---
|
||||||
|
(
|
||||||
|
"target_group",
|
||||||
|
"kinder",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Kinder: kurze Einheiten, spielerische Einstiege, Sicherheit und altersgerechte Komplexität."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Spielerische Einstiege; kurze Abschnitte; Sicherheit vor Perfektion.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Didaktik ohne Überforderung; klare Regeln und Sicherheit vor Perfektion; "
|
||||||
|
"Lücken bei Spiel-/Rollenelementen wichtiger als Wettkampftiefe."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine Erwachsenen-Wettkampf-Perfektion als QS-Maßstab.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target_group",
|
||||||
|
"leistungssportler",
|
||||||
|
{
|
||||||
|
"description": "Leistungsgruppe: höhere Anspruchskurven und Spezialisierung sind fachlich passend.",
|
||||||
|
"hints_on_progression": "Anspruchskurve und Spezialisierung dürfen steiler sein.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; "
|
||||||
|
"Lücken in Spezialisierung können echte Hinweise sein."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target_group",
|
||||||
|
"breitensportler",
|
||||||
|
{
|
||||||
|
"description": "Breitensport: Partizipation und Verständlichkeit vor maximaler Spezialisierung.",
|
||||||
|
"hints_on_path_qa": (
|
||||||
|
"Moderate Progression; Perfektions-Lücken sind selten echte Mängel."
|
||||||
|
),
|
||||||
|
"anti_patterns": "Keine Leistungssport-Perfektion als Pflicht-Kriterium.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target_group",
|
||||||
|
"*",
|
||||||
|
{
|
||||||
|
"hints_on_path_qa": "Zielgruppe im Tempo und in der Komplexität berücksichtigen.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# --- style_direction ---
|
||||||
|
(
|
||||||
|
"style_direction",
|
||||||
|
"shotokan",
|
||||||
|
{
|
||||||
|
"description": (
|
||||||
|
"Shotokan-Linie: klare Kihon-Struktur, Hüft- und Standarbeit als wiederkehrende Qualitätsanker."
|
||||||
|
),
|
||||||
|
"hints_on_progression": "Nuancen in Stellung und Hüfttechnik; kein neuer Planungstyp.",
|
||||||
|
"hints_on_path_qa": "Konsistenz von Stand, Hüfte und Kime entlang des Pfads bewerten.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"style_direction",
|
||||||
|
"*",
|
||||||
|
{
|
||||||
|
"hints_on_progression": (
|
||||||
|
"Stil-spezifische Nuancen (Stand, Hüfte, Rhythmus) einbeziehen — ohne Stilwechsel zu erzwingen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_catalog_name_key(name: str) -> str:
|
||||||
|
s = unicodedata.normalize("NFKD", (name or "").translate(_UMLAUT_MAP))
|
||||||
|
s = s.encode("ascii", "ignore").decode("ascii").lower()
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "_", s).strip("_")
|
||||||
|
return s or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_fallback_slots_for_entry(catalog_kind: str, name: str) -> SlotPack:
|
||||||
|
kind = (catalog_kind or "").strip().lower()
|
||||||
|
norm = normalize_catalog_name_key(name)
|
||||||
|
default: SlotPack = {}
|
||||||
|
for rule_kind, pattern, pack in _FALLBACK_RULES:
|
||||||
|
if rule_kind != kind:
|
||||||
|
continue
|
||||||
|
if pattern == "*":
|
||||||
|
default = dict(pack)
|
||||||
|
continue
|
||||||
|
if pattern in norm or norm.startswith(pattern) or pattern in (name or "").lower():
|
||||||
|
return dict(pack)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def merge_stored_slots_with_fallbacks(
|
||||||
|
stored: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
catalog_kind: str,
|
||||||
|
name: str,
|
||||||
|
stammdaten_description: str = "",
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""DB + Stammdaten-Beschreibung + Namens-Fallback."""
|
||||||
|
fallbacks = get_fallback_slots_for_entry(catalog_kind, name)
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for key in (
|
||||||
|
"description",
|
||||||
|
"hints_on_progression",
|
||||||
|
"hints_on_exercise",
|
||||||
|
"hints_on_path_qa",
|
||||||
|
"anti_patterns",
|
||||||
|
"rematch_guard",
|
||||||
|
):
|
||||||
|
if key == "description":
|
||||||
|
out[key] = (
|
||||||
|
(stored.get(key) or "").strip()
|
||||||
|
or (fallbacks.get(key) or "").strip()
|
||||||
|
or (stammdaten_description or "").strip()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out[key] = (stored.get(key) or "").strip() or (fallbacks.get(key) or "").strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_fallback_slots_for_entry",
|
||||||
|
"merge_stored_slots_with_fallbacks",
|
||||||
|
"normalize_catalog_name_key",
|
||||||
|
]
|
||||||
74
backend/club_feature_logger.py
Normal file
74
backend/club_feature_logger.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""
|
||||||
|
JSON-Log für Vereins-Feature-Zugriffe (Phase 2: nur Monitoring, kein Block).
|
||||||
|
|
||||||
|
Spez: CLUB_MEMBERSHIP_AND_FEATURES.v1.md §9 Phase 2 — analog Mitai feature_logger.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _log_dir() -> Path:
|
||||||
|
custom = (os.getenv("CLUB_FEATURE_LOG_DIR") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return Path(custom)
|
||||||
|
return Path("/app/logs")
|
||||||
|
|
||||||
|
|
||||||
|
feature_usage_logger = logging.getLogger("shinkan.club_feature_usage")
|
||||||
|
feature_usage_logger.setLevel(logging.INFO)
|
||||||
|
feature_usage_logger.propagate = False
|
||||||
|
|
||||||
|
if not feature_usage_logger.handlers:
|
||||||
|
log_dir = _log_dir()
|
||||||
|
try:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = log_dir / "club-feature-usage.log"
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
feature_usage_logger.addHandler(file_handler)
|
||||||
|
except OSError:
|
||||||
|
# Dev ohne /app/logs: Fallback stderr
|
||||||
|
stream_handler = logging.StreamHandler()
|
||||||
|
stream_handler.setFormatter(logging.Formatter("[club-feature-usage] %(message)s"))
|
||||||
|
feature_usage_logger.addHandler(stream_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def log_club_feature_usage(
|
||||||
|
*,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int],
|
||||||
|
feature_id: str,
|
||||||
|
action: str,
|
||||||
|
access: Dict[str, Any],
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
phase: str = "probe",
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Strukturiertes JSON-Log eines Feature-Checks.
|
||||||
|
|
||||||
|
phase: probe (Phase 2, non-blocking) | enforce (Phase 4, nach Block-Entscheid)
|
||||||
|
"""
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"club_id": club_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"feature": feature_id,
|
||||||
|
"action": action,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"phase": phase,
|
||||||
|
"plan_id": access.get("plan_id"),
|
||||||
|
"used": access.get("used", 0),
|
||||||
|
"limit": access.get("limit"),
|
||||||
|
"remaining": access.get("remaining"),
|
||||||
|
"allowed": access.get("allowed", True),
|
||||||
|
"reason": access.get("reason", "unknown"),
|
||||||
|
"enforcement": os.getenv("CLUB_FEATURE_ENFORCE", "0") == "1",
|
||||||
|
}
|
||||||
|
feature_usage_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||||
713
backend/club_features.py
Normal file
713
backend/club_features.py
Normal file
|
|
@ -0,0 +1,713 @@
|
||||||
|
"""
|
||||||
|
Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id).
|
||||||
|
|
||||||
|
Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
|
||||||
|
Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block.
|
||||||
|
Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment.
|
||||||
|
|
||||||
|
Verbrauch-Standard für Router:
|
||||||
|
probe_club_feature_access → Business-Logik → consume_club_feature_with_usage → merge_feature_usage_into_response
|
||||||
|
|
||||||
|
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
# Bestands-Features: Verbrauch = Live-Zählung in DB (nicht club_feature_usage)
|
||||||
|
_INVENTORY_FEATURES = frozenset(
|
||||||
|
{"exercises", "training_groups", "active_members", "training_programs"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_next_reset(reset_period: str, *, now: Optional[datetime] = None) -> Optional[datetime]:
|
||||||
|
"""Nächster Reset-Zeitpunkt; None bei 'never'."""
|
||||||
|
ref = now or datetime.now(timezone.utc)
|
||||||
|
if reset_period == "never":
|
||||||
|
return None
|
||||||
|
if reset_period == "daily":
|
||||||
|
tomorrow = ref.date() + timedelta(days=1)
|
||||||
|
return datetime.combine(tomorrow, datetime.min.time(), tzinfo=timezone.utc)
|
||||||
|
if reset_period == "monthly":
|
||||||
|
if ref.month == 12:
|
||||||
|
return datetime(ref.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||||
|
return datetime(ref.year, ref.month + 1, 1, tzinfo=timezone.utc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_limit(raw: Any) -> Optional[int]:
|
||||||
|
"""NULL = unbegrenzt; -1 (Legacy 001) wird als unbegrenzt behandelt."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
v = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if v < 0:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_club_plan(cur, club_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Effektiver Plan für einen Verein.
|
||||||
|
|
||||||
|
1. Aktiver club_access_grants mit plan_id (Zeitfenster, neueste ends_at)
|
||||||
|
2. club_subscriptions.status = 'active' → plan_id
|
||||||
|
3. Fallback 'free'
|
||||||
|
"""
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT plan_id
|
||||||
|
FROM club_access_grants
|
||||||
|
WHERE club_id = %s
|
||||||
|
AND plan_id IS NOT NULL
|
||||||
|
AND starts_at <= NOW()
|
||||||
|
AND ends_at > NOW()
|
||||||
|
ORDER BY ends_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
grant = cur.fetchone()
|
||||||
|
if grant and grant.get("plan_id"):
|
||||||
|
return str(grant["plan_id"])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT plan_id
|
||||||
|
FROM club_subscriptions
|
||||||
|
WHERE club_id = %s AND status = 'active'
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
sub = cur.fetchone()
|
||||||
|
if sub and sub.get("plan_id"):
|
||||||
|
return str(sub["plan_id"])
|
||||||
|
|
||||||
|
return "free"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) -> Optional[int]:
|
||||||
|
"""Limit-Wert: Override > Plan > Feature-Default."""
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT limit_value
|
||||||
|
FROM club_feature_overrides
|
||||||
|
WHERE club_id = %s AND feature_id = %s
|
||||||
|
""",
|
||||||
|
(club_id, feature_id),
|
||||||
|
)
|
||||||
|
override = cur.fetchone()
|
||||||
|
if override is not None:
|
||||||
|
return _normalize_limit(override.get("limit_value"))
|
||||||
|
|
||||||
|
plan_id = get_effective_club_plan(cur, club_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT limit_value
|
||||||
|
FROM club_plan_limits
|
||||||
|
WHERE plan_id = %s AND feature_id = %s
|
||||||
|
""",
|
||||||
|
(plan_id, feature_id),
|
||||||
|
)
|
||||||
|
plan_lim = cur.fetchone()
|
||||||
|
if plan_lim is not None:
|
||||||
|
return _normalize_limit(plan_lim.get("limit_value"))
|
||||||
|
|
||||||
|
return _normalize_limit(feature_row.get("default_limit"))
|
||||||
|
|
||||||
|
|
||||||
|
def _live_inventory_count(cur, club_id: int, feature_id: str) -> Optional[int]:
|
||||||
|
"""Aktueller Bestand für reset_period=never Features."""
|
||||||
|
if feature_id == "exercises":
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS c
|
||||||
|
FROM exercises
|
||||||
|
WHERE club_id = %s AND status != 'archived'
|
||||||
|
""",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
elif feature_id == "training_groups":
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*)::int AS c FROM training_groups WHERE club_id = %s",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
elif feature_id == "active_members":
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS c
|
||||||
|
FROM club_members
|
||||||
|
WHERE club_id = %s AND status = 'active'
|
||||||
|
""",
|
||||||
|
(club_id,),
|
||||||
|
)
|
||||||
|
elif feature_id == "training_programs":
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS c FROM (
|
||||||
|
SELECT id FROM training_framework_programs WHERE club_id = %s
|
||||||
|
UNION ALL
|
||||||
|
SELECT id FROM training_modules WHERE club_id = %s
|
||||||
|
) t
|
||||||
|
""",
|
||||||
|
(club_id, club_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row["c"] or 0) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_club_id_for_probe(
|
||||||
|
tenant: "TenantContext",
|
||||||
|
*,
|
||||||
|
object_club_id: Optional[int] = None,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Verein für Feature-Probe: explizites Objekt > effective_club_id."""
|
||||||
|
if object_club_id is not None:
|
||||||
|
return int(object_club_id)
|
||||||
|
eff = getattr(tenant, "effective_club_id", None)
|
||||||
|
return int(eff) if eff is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_reset_usage(cur, conn, club_id: int, feature_id: str, feature_row: dict, usage_row: Optional[dict]) -> int:
|
||||||
|
"""Setzt Zähler zurück wenn reset_at überschritten; gibt aktuellen used zurück."""
|
||||||
|
used = int(usage_row.get("usage_count") or 0) if usage_row else 0
|
||||||
|
reset_at = usage_row.get("reset_at") if usage_row else None
|
||||||
|
period = (feature_row.get("reset_period") or "never").strip().lower()
|
||||||
|
|
||||||
|
if not usage_row or not reset_at or period == "never":
|
||||||
|
return used
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
ra = reset_at
|
||||||
|
if hasattr(ra, "tzinfo") and ra.tzinfo is None:
|
||||||
|
ra = ra.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if ra and now > ra:
|
||||||
|
next_reset = _calculate_next_reset(period, now=now)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE club_feature_usage
|
||||||
|
SET usage_count = 0, reset_at = %s, updated_at = NOW()
|
||||||
|
WHERE club_id = %s AND feature_id = %s
|
||||||
|
""",
|
||||||
|
(next_reset, club_id, feature_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
def check_club_feature_access(
|
||||||
|
club_id: int,
|
||||||
|
feature_id: str,
|
||||||
|
*,
|
||||||
|
conn=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Prüft Vereins-Kontingent für ein Feature.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
allowed, limit, used, remaining, reason, plan_id, reset_at (optional)
|
||||||
|
"""
|
||||||
|
if conn is not None:
|
||||||
|
return _check_club_impl(club_id, feature_id, conn)
|
||||||
|
|
||||||
|
with get_db() as c:
|
||||||
|
return _check_club_impl(club_id, feature_id, c)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, limit_type, reset_period, default_limit, active, enforcement_subject
|
||||||
|
FROM features
|
||||||
|
WHERE id = %s AND app = 'shinkan'
|
||||||
|
""",
|
||||||
|
(feature_id,),
|
||||||
|
)
|
||||||
|
feature = cur.fetchone()
|
||||||
|
if not feature or not feature.get("active"):
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"limit": None,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "feature_not_found",
|
||||||
|
"plan_id": get_effective_club_plan(cur, club_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
plan_id = get_effective_club_plan(cur, club_id)
|
||||||
|
limit = _resolve_club_limit(cur, club_id, feature_id, feature)
|
||||||
|
limit_type = (feature.get("limit_type") or "count").strip().lower()
|
||||||
|
|
||||||
|
if limit_type == "boolean":
|
||||||
|
allowed = limit == 1
|
||||||
|
return {
|
||||||
|
"allowed": allowed,
|
||||||
|
"limit": limit,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "enabled" if allowed else "feature_disabled",
|
||||||
|
"plan_id": plan_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT usage_count, reset_at
|
||||||
|
FROM club_feature_usage
|
||||||
|
WHERE club_id = %s AND feature_id = %s
|
||||||
|
""",
|
||||||
|
(club_id, feature_id),
|
||||||
|
)
|
||||||
|
usage = cur.fetchone()
|
||||||
|
used = _maybe_reset_usage(cur, conn, club_id, feature_id, feature, usage)
|
||||||
|
|
||||||
|
period = (feature.get("reset_period") or "never").strip().lower()
|
||||||
|
if period == "never" and feature_id in _INVENTORY_FEATURES:
|
||||||
|
inv = _live_inventory_count(cur, club_id, feature_id)
|
||||||
|
if inv is not None:
|
||||||
|
used = inv
|
||||||
|
|
||||||
|
if limit is None:
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"limit": None,
|
||||||
|
"used": used,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "unlimited",
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"reset_at": usage.get("reset_at") if usage else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit == 0:
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"limit": 0,
|
||||||
|
"used": used,
|
||||||
|
"remaining": 0,
|
||||||
|
"reason": "feature_disabled",
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"reset_at": usage.get("reset_at") if usage else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed = used < limit
|
||||||
|
return {
|
||||||
|
"allowed": allowed,
|
||||||
|
"limit": limit,
|
||||||
|
"used": used,
|
||||||
|
"remaining": max(0, limit - used),
|
||||||
|
"reason": "within_limit" if allowed else "limit_exceeded",
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"reset_at": usage.get("reset_at") if usage else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def club_feature_enforcement_enabled() -> bool:
|
||||||
|
"""Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1|true|yes)."""
|
||||||
|
v = os.getenv("CLUB_FEATURE_ENFORCE", "0").strip().lower()
|
||||||
|
return v in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def probe_club_feature_access(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
action: str,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
tenant: Optional["TenantContext"] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Phase 2: Prüft Vereins-Kontingent, schreibt JSON-Log, blockiert standardmäßig nicht.
|
||||||
|
|
||||||
|
Bei CLUB_FEATURE_ENFORCE=1: HTTP 403 wenn nicht allowed.
|
||||||
|
"""
|
||||||
|
from club_feature_logger import log_club_feature_usage
|
||||||
|
|
||||||
|
if club_id is None:
|
||||||
|
access = {
|
||||||
|
"allowed": not club_feature_enforcement_enabled(),
|
||||||
|
"limit": None,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "no_club_context",
|
||||||
|
"plan_id": None,
|
||||||
|
}
|
||||||
|
log_club_feature_usage(
|
||||||
|
club_id=None,
|
||||||
|
profile_id=profile_id,
|
||||||
|
feature_id=feature_id,
|
||||||
|
action=action,
|
||||||
|
access=access,
|
||||||
|
endpoint=endpoint,
|
||||||
|
phase="enforce" if club_feature_enforcement_enabled() else "probe",
|
||||||
|
)
|
||||||
|
if club_feature_enforcement_enabled() and not access.get("allowed"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=(
|
||||||
|
f"Kein Vereinskontext für {feature_id} — "
|
||||||
|
"aktiven Verein wählen (X-Active-Club-Id)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return access
|
||||||
|
|
||||||
|
def _resolve_access(connection):
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||||
|
|
||||||
|
cur = get_cursor(connection)
|
||||||
|
if is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
feature_id=feature_id,
|
||||||
|
tenant=tenant,
|
||||||
|
):
|
||||||
|
plan_id = get_effective_club_plan(cur, int(club_id))
|
||||||
|
return quota_bypass_access(
|
||||||
|
feature_id=feature_id,
|
||||||
|
club_id=int(club_id),
|
||||||
|
plan_id=plan_id,
|
||||||
|
)
|
||||||
|
return check_club_feature_access(club_id, feature_id, conn=connection)
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
access = _resolve_access(conn)
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
access = _resolve_access(c)
|
||||||
|
|
||||||
|
log_club_feature_usage(
|
||||||
|
club_id=club_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
feature_id=feature_id,
|
||||||
|
action=action,
|
||||||
|
access=access,
|
||||||
|
endpoint=endpoint,
|
||||||
|
phase="enforce" if club_feature_enforcement_enabled() else "probe",
|
||||||
|
)
|
||||||
|
|
||||||
|
if club_feature_enforcement_enabled() and not access.get("allowed"):
|
||||||
|
limit = access.get("limit")
|
||||||
|
used = access.get("used", 0)
|
||||||
|
detail = (
|
||||||
|
f"Kontingent überschritten für {feature_id} "
|
||||||
|
f"({used}/{limit if limit is not None else '∞'}). "
|
||||||
|
f"Grund: {access.get('reason', 'limit_exceeded')}."
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=403, detail=detail)
|
||||||
|
|
||||||
|
return access
|
||||||
|
|
||||||
|
|
||||||
|
def consume_club_feature(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
|
action: Optional[str] = None,
|
||||||
|
amount: int = 1,
|
||||||
|
conn=None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Phase 4 (M5): Zähler nach erfolgreichem Verbrauch erhöhen.
|
||||||
|
Nur wenn club_id gesetzt (Vereins-Kontingent); amount = Anzahl LLM/API-Verbrauchseinheiten.
|
||||||
|
Plattform-Ausnahmen (superadmin, konfigurierte Rollen/Profile) werden nicht gezählt.
|
||||||
|
"""
|
||||||
|
if club_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _is_exempt(connection) -> bool:
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed
|
||||||
|
|
||||||
|
cur = get_cursor(connection)
|
||||||
|
return is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
feature_id=feature_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
if _is_exempt(conn):
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
if _is_exempt(c):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
n = int(amount)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 1
|
||||||
|
if n < 1:
|
||||||
|
return
|
||||||
|
for _ in range(n):
|
||||||
|
increment_club_feature_usage(
|
||||||
|
int(club_id),
|
||||||
|
feature_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
action=action,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _log_consume(connection) -> None:
|
||||||
|
from club_feature_logger import log_club_feature_usage
|
||||||
|
|
||||||
|
access = check_club_feature_access(int(club_id), feature_id, conn=connection)
|
||||||
|
log_club_feature_usage(
|
||||||
|
club_id=int(club_id),
|
||||||
|
profile_id=profile_id,
|
||||||
|
feature_id=feature_id,
|
||||||
|
action=action or "consume",
|
||||||
|
access=access,
|
||||||
|
phase="consume",
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
_log_consume(conn)
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
_log_consume(c)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_club_feature_with_usage(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
|
action: Optional[str] = None,
|
||||||
|
amount: int = 1,
|
||||||
|
cur,
|
||||||
|
tenant: Optional["TenantContext"] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Standard nach erfolgreichem Verbrauch: zählen, protokollieren, Snapshot für Response.
|
||||||
|
|
||||||
|
Alle Endpoints mit Vereins-Kontingent-Verbrauch nutzen diese Funktion und
|
||||||
|
``merge_feature_usage_into_response`` — kein duplizierter Einzelcode pro Route.
|
||||||
|
"""
|
||||||
|
consume_club_feature(
|
||||||
|
feature_id=feature_id,
|
||||||
|
club_id=club_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
action=action,
|
||||||
|
amount=amount,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
if club_id is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
feature_id: club_feature_usage_for_api(
|
||||||
|
cur,
|
||||||
|
club_id=int(club_id),
|
||||||
|
feature_id=feature_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
tenant=tenant,
|
||||||
|
conn=conn,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_feature_usage_into_response(
|
||||||
|
payload: Any,
|
||||||
|
feature_usage: Optional[Dict[str, Dict[str, Any]]],
|
||||||
|
) -> Any:
|
||||||
|
"""Standard-Einbettung ``feature_usage`` in JSON-Responses."""
|
||||||
|
if not feature_usage or not isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
return {**payload, "feature_usage": feature_usage}
|
||||||
|
|
||||||
|
|
||||||
|
def club_feature_usage_for_api(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
club_id: int,
|
||||||
|
feature_id: str,
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
|
tenant: Optional["TenantContext"] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Feature-Zustand wie GET /me/entitlements → features[feature_id] (nach Verbrauch)."""
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||||
|
|
||||||
|
db_conn = conn if conn is not None else cur.connection
|
||||||
|
access = check_club_feature_access(int(club_id), feature_id, conn=db_conn)
|
||||||
|
plan_id = access.get("plan_id") or get_effective_club_plan(cur, int(club_id))
|
||||||
|
|
||||||
|
if is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
feature_id=feature_id,
|
||||||
|
tenant=tenant,
|
||||||
|
):
|
||||||
|
ex = quota_bypass_access(
|
||||||
|
feature_id=feature_id,
|
||||||
|
club_id=int(club_id),
|
||||||
|
plan_id=plan_id,
|
||||||
|
)
|
||||||
|
reset_at = access.get("reset_at")
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"used": access.get("used"),
|
||||||
|
"limit": None,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": ex.get("reason"),
|
||||||
|
"platform_exempt": True,
|
||||||
|
"reset_at": reset_at.isoformat() if hasattr(reset_at, "isoformat") else reset_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"allowed": access.get("allowed"),
|
||||||
|
"used": access.get("used"),
|
||||||
|
"limit": access.get("limit"),
|
||||||
|
"remaining": access.get("remaining"),
|
||||||
|
"reason": access.get("reason"),
|
||||||
|
"platform_exempt": False,
|
||||||
|
"reset_at": access.get("reset_at").isoformat()
|
||||||
|
if access.get("reset_at") is not None and hasattr(access.get("reset_at"), "isoformat")
|
||||||
|
else access.get("reset_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def increment_club_feature_usage(
|
||||||
|
club_id: int,
|
||||||
|
feature_id: str,
|
||||||
|
*,
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
action: Optional[str] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> None:
|
||||||
|
"""Erhöht Vereins-Zähler (nur bei neuem Verbrauch / INSERT-Pfad aufrufen)."""
|
||||||
|
def _run(c):
|
||||||
|
cur = get_cursor(c)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT reset_period, limit_type
|
||||||
|
FROM features
|
||||||
|
WHERE id = %s AND app = 'shinkan' AND active = true
|
||||||
|
""",
|
||||||
|
(feature_id,),
|
||||||
|
)
|
||||||
|
feature = cur.fetchone()
|
||||||
|
if not feature:
|
||||||
|
return
|
||||||
|
if (feature.get("limit_type") or "count").strip().lower() == "boolean":
|
||||||
|
return
|
||||||
|
|
||||||
|
period = (feature.get("reset_period") or "never").strip().lower()
|
||||||
|
next_reset = _calculate_next_reset(period)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO club_feature_usage (club_id, feature_id, usage_count, reset_at, last_used_at)
|
||||||
|
VALUES (%s, %s, 1, %s, NOW())
|
||||||
|
ON CONFLICT (club_id, feature_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
usage_count = club_feature_usage.usage_count + 1,
|
||||||
|
last_used_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(club_id, feature_id, next_reset),
|
||||||
|
)
|
||||||
|
|
||||||
|
if profile_id is not None or action:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO club_feature_usage_events (club_id, feature_id, profile_id, action)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(club_id, feature_id, profile_id, action or feature_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
_run(conn)
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
_run(c)
|
||||||
|
|
||||||
|
|
||||||
|
def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||||
|
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern)."""
|
||||||
|
db_conn = conn if conn is not None else cur.connection
|
||||||
|
plan_id = get_effective_club_plan(cur, club_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, category, limit_type, reset_period
|
||||||
|
FROM features
|
||||||
|
WHERE app = 'shinkan' AND active = true
|
||||||
|
ORDER BY category, id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
features_out = []
|
||||||
|
for row in rows:
|
||||||
|
fid = row["id"]
|
||||||
|
access = _check_club_impl(club_id, fid, db_conn)
|
||||||
|
features_out.append(
|
||||||
|
{
|
||||||
|
"id": fid,
|
||||||
|
"name": row.get("name"),
|
||||||
|
"category": row.get("category"),
|
||||||
|
"limit_type": row.get("limit_type"),
|
||||||
|
"reset_period": row.get("reset_period"),
|
||||||
|
"allowed": access.get("allowed"),
|
||||||
|
"limit": access.get("limit"),
|
||||||
|
"used": access.get("used"),
|
||||||
|
"remaining": access.get("remaining"),
|
||||||
|
"reason": access.get("reason"),
|
||||||
|
"reset_at": access.get("reset_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"club_id": club_id, "plan_id": plan_id, "features": features_out}
|
||||||
|
|
||||||
|
|
||||||
|
def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||||
|
"""Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements)."""
|
||||||
|
raw = list_club_entitlements(cur, club_id, conn=conn)
|
||||||
|
features_dict: Dict[str, Any] = {}
|
||||||
|
for row in raw.get("features") or []:
|
||||||
|
fid = row["id"]
|
||||||
|
features_dict[fid] = {
|
||||||
|
"name": row.get("name"),
|
||||||
|
"category": row.get("category"),
|
||||||
|
"limit_type": row.get("limit_type"),
|
||||||
|
"reset_period": row.get("reset_period"),
|
||||||
|
"allowed": row.get("allowed"),
|
||||||
|
"limit": row.get("limit"),
|
||||||
|
"used": row.get("used"),
|
||||||
|
"remaining": row.get("remaining"),
|
||||||
|
"reason": row.get("reason"),
|
||||||
|
"reset_at": row.get("reset_at"),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"club_id": raw.get("club_id"),
|
||||||
|
"plan_id": raw.get("plan_id"),
|
||||||
|
"features": features_dict,
|
||||||
|
}
|
||||||
180
backend/club_quota_bypass.py
Normal file
180
backend/club_quota_bypass.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""
|
||||||
|
Vereins-Kontingent-Bypass über das Capability-System (kein Parallel-Rechtemodell).
|
||||||
|
|
||||||
|
Capabilities:
|
||||||
|
- platform.club_quota.bypass — alle Vereins-Features (Portal-Admin, Grant via portal_role)
|
||||||
|
- platform.club_quota.bypass.{feature_id} — ein Feature (domain quota_bypass, auch für Nicht-Admins per Grant)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
QUOTA_BYPASS_ALL = "platform.club_quota.bypass"
|
||||||
|
QUOTA_BYPASS_FEATURE_PREFIX = "platform.club_quota.bypass."
|
||||||
|
|
||||||
|
|
||||||
|
def quota_bypass_capability_id_for_feature(feature_id: str) -> str:
|
||||||
|
return f"{QUOTA_BYPASS_FEATURE_PREFIX}{feature_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_quota_bypass_capability(cur, feature_id: str) -> str:
|
||||||
|
"""Legt feature-spezifische Bypass-Capability an falls nötig."""
|
||||||
|
cap_id = quota_bypass_capability_id_for_feature(feature_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES (%s, %s, 'quota_bypass', 'active_member', %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(cap_id, f"Vereins-Kontingent umgehen: {feature_id}", feature_id),
|
||||||
|
)
|
||||||
|
return cap_id
|
||||||
|
|
||||||
|
|
||||||
|
def _bypass_capability_ids(cur, feature_id: str) -> List[str]:
|
||||||
|
ids: List[str] = [QUOTA_BYPASS_ALL, quota_bypass_capability_id_for_feature(feature_id)]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM capabilities
|
||||||
|
WHERE active = true
|
||||||
|
AND domain = 'quota_bypass'
|
||||||
|
AND linked_feature_id = %s
|
||||||
|
AND id <> %s
|
||||||
|
""",
|
||||||
|
(feature_id, quota_bypass_capability_id_for_feature(feature_id)),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
cid = row.get("id")
|
||||||
|
if cid and cid not in ids:
|
||||||
|
ids.append(str(cid))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _portal_role_has_grant(cur, portal_role: str, capability_id: str) -> bool:
|
||||||
|
role = (portal_role or "").strip().lower()
|
||||||
|
if not role:
|
||||||
|
return False
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(role, capability_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_has_grant(cur, profile_id: int, capability_id: str) -> bool:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(int(profile_id), capability_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
profile_id: Optional[int],
|
||||||
|
portal_role: Optional[str],
|
||||||
|
feature_id: str,
|
||||||
|
tenant: Optional["TenantContext"] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
True wenn ein konfigurierter Capability-Grant das Vereins-Kontingent für feature_id umgeht.
|
||||||
|
"""
|
||||||
|
if tenant is not None:
|
||||||
|
from capabilities import check_capability
|
||||||
|
|
||||||
|
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||||
|
if check_capability(cur, tenant, cap_id).get("allowed"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||||
|
if _portal_role_has_grant(cur, portal_role or "", cap_id):
|
||||||
|
return True
|
||||||
|
if profile_id is not None and _profile_has_grant(cur, int(profile_id), cap_id):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def quota_bypass_access(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
plan_id: Optional[str] = None,
|
||||||
|
capability_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"limit": None,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "capability_quota_bypass",
|
||||||
|
"platform_exempt": True,
|
||||||
|
"quota_bypass_capability": capability_id,
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"club_id": club_id,
|
||||||
|
"feature_id": feature_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_quota_bypass_grants(cur) -> Dict[str, Any]:
|
||||||
|
"""Admin: alle Grants zu Kontingent-Bypass-Capabilities."""
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT g.portal_role, g.capability_id, c.name AS capability_name,
|
||||||
|
c.linked_feature_id, c.domain
|
||||||
|
FROM portal_role_capability_grants g
|
||||||
|
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||||
|
WHERE g.capability_id = %s
|
||||||
|
OR g.capability_id LIKE %s
|
||||||
|
OR c.domain = 'quota_bypass'
|
||||||
|
ORDER BY g.portal_role, g.capability_id
|
||||||
|
""",
|
||||||
|
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||||
|
)
|
||||||
|
portal_grants = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT g.profile_id, p.email, p.name AS profile_name,
|
||||||
|
g.capability_id, c.name AS capability_name, c.linked_feature_id,
|
||||||
|
g.reason, g.granted_by_profile_id, g.created_at
|
||||||
|
FROM profile_capability_grants g
|
||||||
|
INNER JOIN profiles p ON p.id = g.profile_id
|
||||||
|
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||||
|
WHERE g.capability_id = %s
|
||||||
|
OR g.capability_id LIKE %s
|
||||||
|
OR c.domain = 'quota_bypass'
|
||||||
|
ORDER BY g.profile_id, g.capability_id
|
||||||
|
""",
|
||||||
|
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||||
|
)
|
||||||
|
profile_grants = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, domain, linked_feature_id
|
||||||
|
FROM capabilities
|
||||||
|
WHERE id = %s OR id LIKE %s OR domain = 'quota_bypass'
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||||
|
)
|
||||||
|
capabilities = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"portal_role_grants": portal_grants,
|
||||||
|
"profile_grants": profile_grants,
|
||||||
|
}
|
||||||
|
|
@ -180,12 +180,17 @@ def init_db():
|
||||||
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
|
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
|
||||||
if cur.fetchone()['count'] == 0:
|
if cur.fetchone()['count'] == 0:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO ai_prompts (slug, name, description, template, active, sort_order)
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, active, sort_order
|
||||||
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
'pipeline',
|
'pipeline',
|
||||||
'Mehrstufige Gesamtanalyse',
|
'Mehrstufige Gesamtanalyse',
|
||||||
'Master-Schalter für die gesamte Pipeline. Deaktiviere diese Analyse, um die Pipeline komplett zu verstecken.',
|
'Master-Schalter fuer die gesamte Pipeline. Deaktiviere diese Zeile um die Pipeline zu verstecken.',
|
||||||
'PIPELINE_MASTER',
|
'PIPELINE_MASTER',
|
||||||
|
'admin',
|
||||||
|
'text',
|
||||||
true,
|
true,
|
||||||
-10
|
-10
|
||||||
)
|
)
|
||||||
|
|
|
||||||
113
backend/entitlements.py
Normal file
113
backend/entitlements.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""
|
||||||
|
Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4).
|
||||||
|
|
||||||
|
Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from capabilities import club_roles_in_club, resolve_capabilities_map
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||||
|
from club_features import club_features_map
|
||||||
|
from club_tenancy import is_platform_admin
|
||||||
|
from tenant_context import _club_exists
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_reset_at(value: Any) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=None).isoformat() + "Z"
|
||||||
|
return value.isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target_club_id(
|
||||||
|
cur,
|
||||||
|
tenant: "TenantContext",
|
||||||
|
club_id: Optional[int],
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Effektiver Verein für Entitlements (Query > Tenant)."""
|
||||||
|
target = int(club_id) if club_id is not None else tenant.effective_club_id
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if is_platform_admin(tenant.global_role):
|
||||||
|
if not _club_exists(cur, target):
|
||||||
|
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
|
||||||
|
return target
|
||||||
|
|
||||||
|
if target not in tenant.club_ids:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def build_me_entitlements(
|
||||||
|
cur,
|
||||||
|
tenant: "TenantContext",
|
||||||
|
*,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Kombiniert Account-Status, Capabilities und Feature-Kontingente.
|
||||||
|
"""
|
||||||
|
target_club = _resolve_target_club_id(cur, tenant, club_id)
|
||||||
|
club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else []
|
||||||
|
|
||||||
|
capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club)
|
||||||
|
|
||||||
|
features: Dict[str, Any] = {}
|
||||||
|
plan_id = None
|
||||||
|
if target_club is not None:
|
||||||
|
raw = club_features_map(cur, target_club)
|
||||||
|
plan_id = raw.get("plan_id")
|
||||||
|
for fid, row in (raw.get("features") or {}).items():
|
||||||
|
if is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
|
feature_id=fid,
|
||||||
|
tenant=tenant,
|
||||||
|
):
|
||||||
|
ex = quota_bypass_access(
|
||||||
|
feature_id=fid,
|
||||||
|
club_id=target_club,
|
||||||
|
plan_id=plan_id,
|
||||||
|
)
|
||||||
|
features[fid] = {
|
||||||
|
"allowed": True,
|
||||||
|
"used": row.get("used"),
|
||||||
|
"limit": None,
|
||||||
|
"remaining": None,
|
||||||
|
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||||
|
"reason": ex.get("reason"),
|
||||||
|
"platform_exempt": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
features[fid] = {
|
||||||
|
"allowed": row.get("allowed"),
|
||||||
|
"used": row.get("used"),
|
||||||
|
"limit": row.get("limit"),
|
||||||
|
"remaining": row.get("remaining"),
|
||||||
|
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||||
|
"reason": row.get("reason"),
|
||||||
|
"platform_exempt": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"account_state": tenant.account_state,
|
||||||
|
"portal_role": tenant.global_role,
|
||||||
|
"club_id": target_club,
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"features": features,
|
||||||
|
}
|
||||||
1122
backend/exercise_ai.py
Normal file
1122
backend/exercise_ai.py
Normal file
File diff suppressed because it is too large
Load Diff
536
backend/exercise_enrichment.py
Normal file
536
backend/exercise_enrichment.py
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
"""
|
||||||
|
Superadmin-Werkzeug: Übungs-Anreicherung per KI (Skills + optional Metadaten).
|
||||||
|
|
||||||
|
Wiederverwendet run_exercise_form_ai_suggestion / exercise_ai — keine neue OpenRouter-Pipeline.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
|
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||||
|
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||||
|
from exercise_ai import strip_html_to_plain
|
||||||
|
from exercise_rich_text import normalize_inline_exercise_media_markup
|
||||||
|
|
||||||
|
from routers.exercises import (
|
||||||
|
enrich_exercise_detail,
|
||||||
|
normalize_exercise_skill_intensity,
|
||||||
|
normalize_exercise_skill_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
SkillMergeMode = Literal["additive", "replace_ai_only", "replace_all"]
|
||||||
|
|
||||||
|
SKILL_MERGE_MODES = frozenset({"additive", "replace_ai_only", "replace_all"})
|
||||||
|
DEFAULT_SET_STATUS = "in_review"
|
||||||
|
# Max. IDs pro Apply-HTTP-Anfrage (kein LLM).
|
||||||
|
MAX_BATCH_EXERCISES = 50
|
||||||
|
# Preview: pro Request nur wenige Übungen — sonst Gateway-504 (Fritz!Box o.ä. ~60s).
|
||||||
|
MAX_PREVIEW_BATCH_EXERCISES = 3
|
||||||
|
|
||||||
|
_INSTRUCTION_FIELDS = ("goal", "execution", "preparation", "trainer_notes")
|
||||||
|
_SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary")
|
||||||
|
|
||||||
|
|
||||||
|
def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]:
|
||||||
|
rows: list[tuple[int, bool]] = []
|
||||||
|
for row in exercise.get("focus_areas") or []:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fid = int(row.get("focus_area_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if fid < 1:
|
||||||
|
continue
|
||||||
|
rows.append((fid, bool(row.get("is_primary"))))
|
||||||
|
rows.sort(key=lambda x: (not x[1], x[0]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str:
|
||||||
|
parts: List[str] = []
|
||||||
|
for row in exercise.get("focus_areas") or []:
|
||||||
|
if isinstance(row, dict):
|
||||||
|
nm = (row.get("name") or "").strip()
|
||||||
|
if nm:
|
||||||
|
parts.append(nm)
|
||||||
|
txt = ", ".join(parts).strip()
|
||||||
|
if len(txt) > 900:
|
||||||
|
return txt[:899] + "…"
|
||||||
|
return txt
|
||||||
|
|
||||||
|
|
||||||
|
def build_form_context_from_exercise(exercise: Dict[str, Any]) -> ExerciseFormAiPromptContext:
|
||||||
|
focus = _focus_area_hint_from_detail(exercise)
|
||||||
|
fctx = _focus_areas_ai_ctx_from_detail(exercise)
|
||||||
|
return ExerciseFormAiPromptContext.from_focus_tuples(
|
||||||
|
title=str(exercise.get("title") or "").strip(),
|
||||||
|
goal=exercise.get("goal"),
|
||||||
|
execution=exercise.get("execution"),
|
||||||
|
preparation=exercise.get("preparation"),
|
||||||
|
trainer_notes=exercise.get("trainer_notes"),
|
||||||
|
focus_hint=focus or None,
|
||||||
|
focus_tuples=fctx or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_exercise_for_enrichment(
|
||||||
|
exercise: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
want_skills: bool = False,
|
||||||
|
want_summary: bool = False,
|
||||||
|
want_instructions: bool = False,
|
||||||
|
) -> Optional[str]:
|
||||||
|
title = str(exercise.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
return "Titel fehlt"
|
||||||
|
|
||||||
|
ctx = build_form_context_from_exercise(exercise)
|
||||||
|
g_plain = strip_html_to_plain(exercise.get("goal"))
|
||||||
|
e_plain = strip_html_to_plain(exercise.get("execution"))
|
||||||
|
|
||||||
|
if want_skills or want_summary:
|
||||||
|
if not (g_plain.strip() or e_plain.strip()):
|
||||||
|
return "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)"
|
||||||
|
|
||||||
|
if want_instructions and not ctx.has_instruction_source_text():
|
||||||
|
return "Für Anleitungs-Überarbeitung fehlt Ausgangstext (Titel oder Anleitungsfeld)"
|
||||||
|
|
||||||
|
if not (want_skills or want_summary or want_instructions):
|
||||||
|
return "Kein Anreicherungsmodus aktiv"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_skill_row(raw: Dict[str, Any], *, ai_suggested: bool) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"skill_id": int(raw["skill_id"]),
|
||||||
|
"skill_name": (raw.get("skill_name") or "").strip() or f"Skill #{raw['skill_id']}",
|
||||||
|
"skill_category": raw.get("skill_category"),
|
||||||
|
"is_primary": bool(raw.get("is_primary")),
|
||||||
|
"intensity": normalize_exercise_skill_intensity(raw.get("intensity")),
|
||||||
|
"required_level": normalize_exercise_skill_level(raw.get("required_level")),
|
||||||
|
"target_level": normalize_exercise_skill_level(raw.get("target_level")),
|
||||||
|
"ai_suggested": ai_suggested,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_meta_differs(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
||||||
|
for k in _SKILL_COMPARE_KEYS:
|
||||||
|
av = a.get(k)
|
||||||
|
bv = b.get(k)
|
||||||
|
if k in ("required_level", "target_level"):
|
||||||
|
av = normalize_exercise_skill_level(av)
|
||||||
|
bv = normalize_exercise_skill_level(bv)
|
||||||
|
elif k == "intensity":
|
||||||
|
av = normalize_exercise_skill_intensity(av)
|
||||||
|
bv = normalize_exercise_skill_intensity(bv)
|
||||||
|
elif k == "is_primary":
|
||||||
|
av = bool(av)
|
||||||
|
bv = bool(bv)
|
||||||
|
if av != bv:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def merge_skills(
|
||||||
|
existing: List[Dict[str, Any]],
|
||||||
|
suggested: List[Dict[str, Any]],
|
||||||
|
mode: SkillMergeMode,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Merge-Modi: additive | replace_ai_only | replace_all (alle KI-Skills mit ai_suggested=true)."""
|
||||||
|
existing_norm = [_normalize_skill_row(s, ai_suggested=bool(s.get("ai_suggested"))) for s in existing]
|
||||||
|
suggested_norm = [_normalize_skill_row(s, ai_suggested=True) for s in suggested]
|
||||||
|
|
||||||
|
suggested_by_id = {int(s["skill_id"]): s for s in suggested_norm}
|
||||||
|
|
||||||
|
if mode == "replace_all":
|
||||||
|
return list(suggested_norm)
|
||||||
|
|
||||||
|
if mode == "replace_ai_only":
|
||||||
|
manual = [s for s in existing_norm if not s.get("ai_suggested")]
|
||||||
|
manual_ids = {int(s["skill_id"]) for s in manual}
|
||||||
|
result = list(manual)
|
||||||
|
for s in suggested_norm:
|
||||||
|
sid = int(s["skill_id"])
|
||||||
|
if sid in manual_ids:
|
||||||
|
continue
|
||||||
|
result.append(s)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# additive
|
||||||
|
result: List[Dict[str, Any]] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for s in existing_norm:
|
||||||
|
sid = int(s["skill_id"])
|
||||||
|
seen.add(sid)
|
||||||
|
if sid in suggested_by_id and s.get("ai_suggested"):
|
||||||
|
merged = {**s, **suggested_by_id[sid], "ai_suggested": True}
|
||||||
|
result.append(merged)
|
||||||
|
else:
|
||||||
|
result.append(dict(s))
|
||||||
|
for s in suggested_norm:
|
||||||
|
sid = int(s["skill_id"])
|
||||||
|
if sid not in seen:
|
||||||
|
result.append(s)
|
||||||
|
seen.add(sid)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compute_skill_diff(
|
||||||
|
before: List[Dict[str, Any]],
|
||||||
|
after: List[Dict[str, Any]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
before_ids = {int(s["skill_id"]): s for s in before}
|
||||||
|
after_ids = {int(s["skill_id"]): s for s in after}
|
||||||
|
added = [after_ids[i] for i in sorted(after_ids) if i not in before_ids]
|
||||||
|
removed = [before_ids[i] for i in sorted(before_ids) if i not in after_ids]
|
||||||
|
changed: List[Dict[str, Any]] = []
|
||||||
|
for sid in before_ids:
|
||||||
|
if sid in after_ids and _skill_meta_differs(before_ids[sid], after_ids[sid]):
|
||||||
|
changed.append(
|
||||||
|
{
|
||||||
|
"skill_id": sid,
|
||||||
|
"skill_name": after_ids[sid].get("skill_name") or before_ids[sid].get("skill_name"),
|
||||||
|
"before": before_ids[sid],
|
||||||
|
"after": after_ids[sid],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
kept = [
|
||||||
|
before_ids[i]
|
||||||
|
for i in sorted(before_ids)
|
||||||
|
if i in after_ids and i not in {c["skill_id"] for c in changed}
|
||||||
|
]
|
||||||
|
return {"added": added, "removed": removed, "changed": changed, "kept": kept}
|
||||||
|
|
||||||
|
|
||||||
|
def _skills_from_ai_payload(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
rows = payload.get("skills")
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
return [_normalize_skill_row(r, ai_suggested=True) for r in rows if isinstance(r, dict) and r.get("skill_id")]
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_from_ai_payload(payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
block = payload.get("summary")
|
||||||
|
if isinstance(block, dict):
|
||||||
|
text = (block.get("text") or "").strip()
|
||||||
|
return text or None
|
||||||
|
if isinstance(block, str) and block.strip():
|
||||||
|
return block.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _instructions_from_ai_payload(payload: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
block = payload.get("instructions")
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
return {}
|
||||||
|
fields = block.get("fields")
|
||||||
|
if not isinstance(fields, dict):
|
||||||
|
return {}
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for key in _INSTRUCTION_FIELDS:
|
||||||
|
val = fields.get(key)
|
||||||
|
if val is not None and str(val).strip():
|
||||||
|
out[key] = str(val).strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _instruction_snapshot(exercise: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for key in _INSTRUCTION_FIELDS:
|
||||||
|
raw = exercise.get(key)
|
||||||
|
plain = strip_html_to_plain(raw, max_len=400) if raw else ""
|
||||||
|
if plain.strip():
|
||||||
|
out[key] = plain.strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compute_instruction_diff(
|
||||||
|
before: Dict[str, str],
|
||||||
|
after: Dict[str, str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
changed: List[Dict[str, Any]] = []
|
||||||
|
added: List[str] = []
|
||||||
|
for key in _INSTRUCTION_FIELDS:
|
||||||
|
b = (before.get(key) or "").strip()
|
||||||
|
a = (after.get(key) or "").strip()
|
||||||
|
if not a:
|
||||||
|
continue
|
||||||
|
if not b:
|
||||||
|
added.append(key)
|
||||||
|
elif b != strip_html_to_plain(a, max_len=400).strip() and b != a:
|
||||||
|
changed.append({"field": key, "before_plain": b, "after_html": a})
|
||||||
|
return {"changed_fields": changed, "added_fields": added}
|
||||||
|
|
||||||
|
|
||||||
|
def preview_exercise_enrichment(
|
||||||
|
cur,
|
||||||
|
exercise_id: int,
|
||||||
|
*,
|
||||||
|
want_skills: bool = True,
|
||||||
|
want_summary: bool = False,
|
||||||
|
want_instructions: bool = False,
|
||||||
|
merge_mode: SkillMergeMode = "additive",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||||
|
if not exercise:
|
||||||
|
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
||||||
|
|
||||||
|
skip_reason = validate_exercise_for_enrichment(
|
||||||
|
exercise,
|
||||||
|
want_skills=want_skills,
|
||||||
|
want_summary=want_summary,
|
||||||
|
want_instructions=want_instructions,
|
||||||
|
)
|
||||||
|
if skip_reason:
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": False,
|
||||||
|
"skipped": True,
|
||||||
|
"error": skip_reason,
|
||||||
|
"title": exercise.get("title"),
|
||||||
|
"status": exercise.get("status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = exercise.get("skills") or []
|
||||||
|
suggested: List[Dict[str, Any]] = []
|
||||||
|
ai_meta: Dict[str, Any] = {}
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
suggested_summary: Optional[str] = None
|
||||||
|
suggested_instructions: Dict[str, str] = {}
|
||||||
|
|
||||||
|
if want_skills or want_summary or want_instructions:
|
||||||
|
ctx = build_form_context_from_exercise(exercise)
|
||||||
|
payload = run_exercise_form_ai_suggestion(
|
||||||
|
cur,
|
||||||
|
ctx,
|
||||||
|
want_summary=want_summary,
|
||||||
|
want_skills=want_skills,
|
||||||
|
want_instructions=want_instructions,
|
||||||
|
)
|
||||||
|
if want_skills:
|
||||||
|
suggested = _skills_from_ai_payload(payload)
|
||||||
|
if want_summary:
|
||||||
|
suggested_summary = _summary_from_ai_payload(payload)
|
||||||
|
if want_instructions:
|
||||||
|
suggested_instructions = _instructions_from_ai_payload(payload)
|
||||||
|
ai_meta = {
|
||||||
|
"models": payload.get("models_by_slug") or {},
|
||||||
|
"llm_calls": sum([want_skills, want_summary, want_instructions]),
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = merge_skills(existing, suggested, merge_mode) if want_skills else list(existing)
|
||||||
|
diff = compute_skill_diff(existing, merged) if want_skills else None
|
||||||
|
|
||||||
|
existing_summary = (exercise.get("summary") or "").strip() or None
|
||||||
|
instr_before = _instruction_snapshot(exercise)
|
||||||
|
instr_after_plain = {
|
||||||
|
k: strip_html_to_plain(v, max_len=400) for k, v in suggested_instructions.items()
|
||||||
|
}
|
||||||
|
instruction_diff = (
|
||||||
|
compute_instruction_diff(instr_before, instr_after_plain) if want_instructions else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": True,
|
||||||
|
"title": exercise.get("title"),
|
||||||
|
"status": exercise.get("status"),
|
||||||
|
"visibility": exercise.get("visibility"),
|
||||||
|
"primary_focus_name": _primary_focus_from_exercise(exercise),
|
||||||
|
"existing_skills": existing,
|
||||||
|
"suggested_skills": suggested,
|
||||||
|
"merged_skills": merged,
|
||||||
|
"diff": diff,
|
||||||
|
"existing_summary": existing_summary,
|
||||||
|
"suggested_summary": suggested_summary,
|
||||||
|
"existing_instructions": instr_before,
|
||||||
|
"suggested_instructions": suggested_instructions,
|
||||||
|
"instruction_diff": instruction_diff,
|
||||||
|
"ai_meta": ai_meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_focus_from_exercise(exercise: Dict[str, Any]) -> Optional[str]:
|
||||||
|
for row in exercise.get("focus_areas") or []:
|
||||||
|
if isinstance(row, dict) and row.get("is_primary"):
|
||||||
|
return (row.get("name") or "").strip() or None
|
||||||
|
for row in exercise.get("focus_areas") or []:
|
||||||
|
if isinstance(row, dict):
|
||||||
|
nm = (row.get("name") or "").strip()
|
||||||
|
if nm:
|
||||||
|
return nm
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def persist_merged_skills(cur, exercise_id: int, merged: List[Dict[str, Any]], merge_mode: SkillMergeMode) -> None:
|
||||||
|
if merge_mode == "replace_all":
|
||||||
|
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
|
||||||
|
elif merge_mode == "replace_ai_only":
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM exercise_skills WHERE exercise_id = %s AND ai_suggested = true",
|
||||||
|
(exercise_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
for sk in merged:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO exercise_skills
|
||||||
|
(exercise_id, skill_id, is_primary, intensity, required_level, target_level, ai_suggested)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
|
||||||
|
intensity = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.intensity ELSE EXCLUDED.intensity END,
|
||||||
|
required_level = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.required_level ELSE EXCLUDED.required_level END,
|
||||||
|
target_level = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.target_level ELSE EXCLUDED.target_level END,
|
||||||
|
is_primary = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.is_primary ELSE EXCLUDED.is_primary END,
|
||||||
|
ai_suggested = CASE
|
||||||
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||||
|
THEN exercise_skills.ai_suggested ELSE EXCLUDED.ai_suggested END
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
exercise_id,
|
||||||
|
int(sk["skill_id"]),
|
||||||
|
bool(sk.get("is_primary")),
|
||||||
|
normalize_exercise_skill_intensity(sk.get("intensity")),
|
||||||
|
normalize_exercise_skill_level(sk.get("required_level")),
|
||||||
|
normalize_exercise_skill_level(sk.get("target_level")),
|
||||||
|
bool(sk.get("ai_suggested")),
|
||||||
|
merge_mode,
|
||||||
|
merge_mode,
|
||||||
|
merge_mode,
|
||||||
|
merge_mode,
|
||||||
|
merge_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_instruction_fields(fields: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||||
|
if not fields:
|
||||||
|
return {}
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for key in _INSTRUCTION_FIELDS:
|
||||||
|
if key not in fields:
|
||||||
|
continue
|
||||||
|
raw = fields.get(key)
|
||||||
|
if raw is None or not str(raw).strip():
|
||||||
|
continue
|
||||||
|
out[key] = normalize_inline_exercise_media_markup(str(raw).strip())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_exercise_enrichment(
|
||||||
|
cur,
|
||||||
|
exercise_id: int,
|
||||||
|
*,
|
||||||
|
merged_skills: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
merge_mode: SkillMergeMode = "additive",
|
||||||
|
set_status: Optional[str] = DEFAULT_SET_STATUS,
|
||||||
|
apply_skills: bool = False,
|
||||||
|
summary_text: Optional[str] = None,
|
||||||
|
apply_summary: bool = False,
|
||||||
|
instruction_fields: Optional[Dict[str, Any]] = None,
|
||||||
|
apply_instructions: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||||
|
if not exercise:
|
||||||
|
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
||||||
|
|
||||||
|
skip_reason = validate_exercise_for_enrichment(
|
||||||
|
exercise,
|
||||||
|
want_skills=apply_skills,
|
||||||
|
want_summary=apply_summary,
|
||||||
|
want_instructions=apply_instructions,
|
||||||
|
)
|
||||||
|
if skip_reason:
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": False,
|
||||||
|
"skipped": True,
|
||||||
|
"error": skip_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
skills_list = merged_skills or []
|
||||||
|
if apply_skills:
|
||||||
|
if not skills_list and merge_mode != "replace_all":
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": False,
|
||||||
|
"error": "Keine Skills zum Anwenden",
|
||||||
|
}
|
||||||
|
persist_merged_skills(cur, exercise_id, skills_list, merge_mode)
|
||||||
|
|
||||||
|
sets: List[str] = []
|
||||||
|
vals: List[Any] = []
|
||||||
|
|
||||||
|
if apply_summary and summary_text is not None:
|
||||||
|
text = str(summary_text).strip()
|
||||||
|
if text:
|
||||||
|
sets.extend(["summary = %s", "summary_ai_generated = true"])
|
||||||
|
vals.append(text[:220])
|
||||||
|
|
||||||
|
if apply_instructions:
|
||||||
|
norm = _normalize_instruction_fields(instruction_fields)
|
||||||
|
for key, val in norm.items():
|
||||||
|
sets.append(f"{key} = %s")
|
||||||
|
vals.append(val)
|
||||||
|
|
||||||
|
new_status = (set_status or "").strip().lower() or None
|
||||||
|
if new_status:
|
||||||
|
if new_status == "approved":
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": False,
|
||||||
|
"error": "Automatisches Freigeben (approved) ist nicht erlaubt",
|
||||||
|
}
|
||||||
|
if new_status not in ("draft", "in_review", "archived"):
|
||||||
|
return {"exercise_id": exercise_id, "ok": False, "error": "Ungültiger Ziel-Status"}
|
||||||
|
sets.append("status = %s")
|
||||||
|
vals.append(new_status)
|
||||||
|
|
||||||
|
if sets:
|
||||||
|
sets.append("updated_at = NOW()")
|
||||||
|
vals.append(exercise_id)
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
|
||||||
|
tuple(vals),
|
||||||
|
)
|
||||||
|
elif not apply_skills:
|
||||||
|
return {"exercise_id": exercise_id, "ok": False, "error": "Nichts anzuwenden"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"ok": True,
|
||||||
|
"status": new_status or exercise.get("status"),
|
||||||
|
"skills_applied": len(skills_list) if apply_skills else 0,
|
||||||
|
"summary_applied": apply_summary and bool(summary_text and str(summary_text).strip()),
|
||||||
|
"instructions_applied": apply_instructions and bool(_normalize_instruction_fields(instruction_fields)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_llm_calls(
|
||||||
|
*,
|
||||||
|
exercise_count: int,
|
||||||
|
want_skills: bool,
|
||||||
|
want_summary: bool,
|
||||||
|
want_instructions: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
per_skills = exercise_count if want_skills else 0
|
||||||
|
per_summary = exercise_count if want_summary else 0
|
||||||
|
per_instructions = exercise_count if want_instructions else 0
|
||||||
|
total = per_skills + per_summary + per_instructions
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"per_exercise": sum([want_skills, want_summary, want_instructions]),
|
||||||
|
"skills": per_skills,
|
||||||
|
"summary": per_summary,
|
||||||
|
"instructions": per_instructions,
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from html.parser import HTMLParser
|
||||||
from typing import FrozenSet, Optional, Set
|
from typing import FrozenSet, Optional, Set
|
||||||
|
from html import escape as html_escape
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
@ -40,7 +42,126 @@ def normalize_inline_exercise_media_markup(html: Optional[str]) -> Optional[str]
|
||||||
f'class="shinkan-inline-media"></span>'
|
f'class="shinkan-inline-media"></span>'
|
||||||
)
|
)
|
||||||
|
|
||||||
return _BRACE_PATTERN.sub(_repl, html)
|
return sanitize_exercise_rich_html(_BRACE_PATTERN.sub(_repl, html))
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_RICH_TAGS = frozenset(
|
||||||
|
{"p", "div", "br", "ul", "ol", "li", "h3", "b", "strong", "i", "em", "u", "span", "a"}
|
||||||
|
)
|
||||||
|
_DROP_WITH_CHILDREN = frozenset(
|
||||||
|
{"script", "iframe", "object", "embed", "style", "link", "meta", "svg", "math"}
|
||||||
|
)
|
||||||
|
_VOID_TAGS = frozenset({"br"})
|
||||||
|
_MEDIA_SIZES = frozenset({"small", "medium", "full"})
|
||||||
|
_CAPTION_MAX = 120
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_caption(raw: str) -> str:
|
||||||
|
text = re.sub(r"[\u0000-\u001f\u007f]", " ", raw or "")
|
||||||
|
text = re.sub(r"[\"'`<>]", " ", text)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
return text[:_CAPTION_MAX]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_rich_attrs(tag: str, attrs: list[tuple[str, Optional[str]]]) -> list[tuple[str, str]]:
|
||||||
|
values = {}
|
||||||
|
for name, value in attrs:
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
values[name.lower()] = "" if value is None else str(value)
|
||||||
|
if tag == "a":
|
||||||
|
href = values.get("href", "").strip()
|
||||||
|
if href.lower().startswith(("http://", "https://")) and not re.search(r"[\u0000-\u001f\u007f]", href):
|
||||||
|
return [("href", href)]
|
||||||
|
return []
|
||||||
|
if tag != "span":
|
||||||
|
return []
|
||||||
|
media_id = values.get("data-shinkan-exercise-media", "").strip()
|
||||||
|
if not re.fullmatch(r"\d+", media_id):
|
||||||
|
return []
|
||||||
|
out = [("data-shinkan-exercise-media", media_id)]
|
||||||
|
size = values.get("data-shinkan-exercise-media-size", "").strip().lower()
|
||||||
|
if size in _MEDIA_SIZES:
|
||||||
|
out.append(("data-shinkan-exercise-media-size", size))
|
||||||
|
caption = _clean_caption(values.get("data-shinkan-exercise-media-caption", ""))
|
||||||
|
if caption:
|
||||||
|
out.append(("data-shinkan-exercise-media-caption", caption))
|
||||||
|
classes = [c for c in values.get("class", "").split() if c == "shinkan-inline-media"]
|
||||||
|
if classes:
|
||||||
|
out.append(("class", "shinkan-inline-media"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class _RichHtmlSanitizer(HTMLParser):
|
||||||
|
"""Baut erlaubtes Übungs-HTML neu auf. Unbekannte Tags fallen weg, ihr Text bleibt."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.parts: list[str] = []
|
||||||
|
self._drop_depth = 0
|
||||||
|
self._stack: list[str] = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
|
||||||
|
self._start(tag.lower(), attrs, void=False)
|
||||||
|
|
||||||
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
|
||||||
|
self._start(tag.lower(), attrs, void=True)
|
||||||
|
|
||||||
|
def _start(self, tag: str, attrs: list[tuple[str, Optional[str]]], *, void: bool) -> None:
|
||||||
|
if self._drop_depth:
|
||||||
|
if not void and tag not in _VOID_TAGS:
|
||||||
|
self._drop_depth += 1
|
||||||
|
return
|
||||||
|
if tag in _DROP_WITH_CHILDREN:
|
||||||
|
if not void:
|
||||||
|
self._drop_depth = 1
|
||||||
|
return
|
||||||
|
if tag not in _ALLOWED_RICH_TAGS:
|
||||||
|
return
|
||||||
|
safe = _safe_rich_attrs(tag, attrs)
|
||||||
|
rendered = tag
|
||||||
|
if safe:
|
||||||
|
rendered += "".join(f' {name}="{html_escape(value, quote=True)}"' for name, value in safe)
|
||||||
|
if void or tag in _VOID_TAGS:
|
||||||
|
self.parts.append(f"<{rendered}>")
|
||||||
|
return
|
||||||
|
self._stack.append(tag)
|
||||||
|
self.parts.append(f"<{rendered}>")
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
if self._drop_depth:
|
||||||
|
if tag in _DROP_WITH_CHILDREN or self._drop_depth > 1:
|
||||||
|
self._drop_depth = max(0, self._drop_depth - 1)
|
||||||
|
return
|
||||||
|
if self._stack and self._stack[-1] == tag:
|
||||||
|
self._stack.pop()
|
||||||
|
self.parts.append(f"</{tag}>")
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self._drop_depth or not data:
|
||||||
|
return
|
||||||
|
self.parts.append(html_escape(data))
|
||||||
|
|
||||||
|
def handle_entityref(self, name: str) -> None:
|
||||||
|
self.handle_data(f"&{name};")
|
||||||
|
|
||||||
|
def handle_charref(self, name: str) -> None:
|
||||||
|
self.handle_data(f"&#{name};")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_exercise_rich_html(html: Optional[str]) -> Optional[str]:
|
||||||
|
"""Allowlist für gespeichertes Übungs-HTML. Skripte, Handler und fremde Attribute fallen weg."""
|
||||||
|
if html is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(html, str):
|
||||||
|
html = str(html)
|
||||||
|
if not html.strip():
|
||||||
|
return html
|
||||||
|
parser = _RichHtmlSanitizer()
|
||||||
|
parser.feed(html)
|
||||||
|
parser.close()
|
||||||
|
return "".join(parser.parts)
|
||||||
|
|
||||||
|
|
||||||
def collect_inline_exercise_media_ids(html: Optional[str]) -> Set[int]:
|
def collect_inline_exercise_media_ids(html: Optional[str]) -> Set[int]:
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,28 @@ else:
|
||||||
print(f"[FAIL] Migration-Laufzeitfehler: {e}")
|
print(f"[FAIL] Migration-Laufzeitfehler: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Registry-first: Module → DB (nur registrierte Rechte/Kontingente in Admin-Matrix)
|
||||||
|
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
|
||||||
|
try:
|
||||||
|
from rights_registry import sync_rights_registry_to_db
|
||||||
|
|
||||||
|
counts = sync_rights_registry_to_db()
|
||||||
|
print(
|
||||||
|
f"[OK] Rights registry sync: {counts['capabilities']} capabilities, "
|
||||||
|
f"{counts['features']} features"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[FAIL] Rights registry sync: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
from club_features import club_feature_enforcement_enabled
|
||||||
|
|
||||||
|
_cfe = os.getenv("CLUB_FEATURE_ENFORCE", "0")
|
||||||
|
print(
|
||||||
|
f"[OK] CLUB_FEATURE_ENFORCE raw={_cfe!r} "
|
||||||
|
f"active={club_feature_enforcement_enabled()}"
|
||||||
|
)
|
||||||
|
|
||||||
from routers.auth import limiter as auth_rate_limiter
|
from routers.auth import limiter as auth_rate_limiter
|
||||||
|
|
||||||
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
|
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
|
||||||
|
|
@ -87,6 +109,34 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def account_onboarding_api_gate(request: Request, call_next):
|
||||||
|
"""
|
||||||
|
Phase A: Domänen-APIs für unverified / verified_pending_club sperren.
|
||||||
|
Siehe account_onboarding_gate.py und MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1
|
||||||
|
"""
|
||||||
|
from account_onboarding_gate import evaluate_request_gate
|
||||||
|
|
||||||
|
token = request.headers.get("x-auth-token") or request.headers.get("X-Auth-Token")
|
||||||
|
allowed, reason, _state = evaluate_request_gate(
|
||||||
|
token,
|
||||||
|
request.url.path,
|
||||||
|
request.method,
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={
|
||||||
|
"detail": (
|
||||||
|
"Zugriff erst nach E-Mail-Bestätigung und Vereinsmitgliedschaft möglich. "
|
||||||
|
"Du kannst einen Beitrittsantrag stellen oder dein Konto in den Einstellungen verwalten."
|
||||||
|
),
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def add_api_security_headers(request: Request, call_next):
|
async def add_api_security_headers(request: Request, call_next):
|
||||||
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
|
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
|
||||||
|
|
@ -193,7 +243,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
|
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, catalog_prompt_slots, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -202,7 +252,11 @@ app.include_router(exercise_progression_graphs.router)
|
||||||
app.include_router(clubs.router)
|
app.include_router(clubs.router)
|
||||||
app.include_router(club_memberships.router)
|
app.include_router(club_memberships.router)
|
||||||
app.include_router(club_join_requests.router)
|
app.include_router(club_join_requests.router)
|
||||||
|
app.include_router(club_creation_requests.router)
|
||||||
app.include_router(admin_users.router)
|
app.include_router(admin_users.router)
|
||||||
|
app.include_router(admin_user_content.router)
|
||||||
|
app.include_router(admin_rights.router)
|
||||||
|
app.include_router(me_entitlements.router)
|
||||||
app.include_router(platform_media_storage.router)
|
app.include_router(platform_media_storage.router)
|
||||||
app.include_router(media_assets.router)
|
app.include_router(media_assets.router)
|
||||||
app.include_router(media_assets.admin_rights_router)
|
app.include_router(media_assets.admin_rights_router)
|
||||||
|
|
@ -210,19 +264,25 @@ app.include_router(media_assets.admin_legal_hold_router)
|
||||||
app.include_router(skills.router)
|
app.include_router(skills.router)
|
||||||
app.include_router(skill_profiles.router)
|
app.include_router(skill_profiles.router)
|
||||||
app.include_router(training_planning.router)
|
app.include_router(training_planning.router)
|
||||||
|
app.include_router(planning_exercise_suggest.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(training_modules.router)
|
app.include_router(training_modules.router)
|
||||||
app.include_router(training_framework_programs.router)
|
app.include_router(training_framework_programs.router)
|
||||||
app.include_router(catalogs.router)
|
app.include_router(catalogs.router)
|
||||||
|
app.include_router(catalog_prompt_slots.router)
|
||||||
app.include_router(maturity_models.router)
|
app.include_router(maturity_models.router)
|
||||||
app.include_router(matrix_stack_bundle.router)
|
app.include_router(matrix_stack_bundle.router)
|
||||||
|
app.include_router(matrix_editor.router)
|
||||||
app.include_router(import_wiki.router)
|
app.include_router(import_wiki.router)
|
||||||
app.include_router(import_wiki_admin.router)
|
app.include_router(import_wiki_admin.router)
|
||||||
app.include_router(legal_documents.router)
|
app.include_router(legal_documents.router)
|
||||||
app.include_router(content_reports.router)
|
app.include_router(content_reports.router)
|
||||||
|
app.include_router(ai_prompts_admin.router)
|
||||||
|
app.include_router(ai_skill_retrieval_admin.router)
|
||||||
|
app.include_router(exercise_enrichment_admin.router)
|
||||||
|
|
||||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken = kurzlebiges Medien-Token, kein Sitzungstoken).
|
||||||
# Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
|
# Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
|
||||||
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
|
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
|
||||||
Path(_media_dir).mkdir(parents=True, exist_ok=True)
|
Path(_media_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
49
backend/media_grant.py
Normal file
49
backend/media_grant.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Kurzlebige Medien-Tokens. Kein Sitzungstoken — nur für Dateiabruf per Query."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
MEDIA_GRANT_TTL_SECONDS = 600
|
||||||
|
_PREFIX = "mg1"
|
||||||
|
|
||||||
|
|
||||||
|
def _key() -> bytes:
|
||||||
|
dedicated = (os.getenv("MEDIA_TOKEN_SECRET") or "").strip()
|
||||||
|
if dedicated:
|
||||||
|
return hashlib.sha256(dedicated.encode("utf-8")).digest()
|
||||||
|
material = (os.getenv("DB_PASSWORD") or "shinkan-dev-media").encode("utf-8")
|
||||||
|
return hashlib.sha256(b"shinkan-media-grant-v1|" + material).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def issue_media_grant(profile_id: int, *, ttl_seconds: int = MEDIA_GRANT_TTL_SECONDS) -> tuple[str, int]:
|
||||||
|
exp = int(time.time()) + int(ttl_seconds)
|
||||||
|
payload = f"{int(profile_id)}.{exp}"
|
||||||
|
sig = hmac.new(_key(), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||||
|
return f"{_PREFIX}.{payload}.{sig}", exp
|
||||||
|
|
||||||
|
|
||||||
|
def profile_id_from_media_grant(token: Optional[str], *, now: Optional[int] = None) -> Optional[int]:
|
||||||
|
if not token or not token.startswith(f"{_PREFIX}."):
|
||||||
|
return None
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 4:
|
||||||
|
return None
|
||||||
|
_, pid_s, exp_s, sig = parts
|
||||||
|
if not pid_s.isdigit() or not exp_s.isdigit():
|
||||||
|
return None
|
||||||
|
payload = f"{pid_s}.{exp_s}"
|
||||||
|
expected = hmac.new(_key(), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(expected, sig):
|
||||||
|
return None
|
||||||
|
exp = int(exp_s)
|
||||||
|
moment = int(time.time() if now is None else now)
|
||||||
|
if exp < moment:
|
||||||
|
return None
|
||||||
|
pid = int(pid_s)
|
||||||
|
if pid < 1:
|
||||||
|
return None
|
||||||
|
return pid
|
||||||
141
backend/migrations/067_ai_prompts_exercise_assistant.sql
Normal file
141
backend/migrations/067_ai_prompts_exercise_assistant.sql
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
-- Migration 067: Konfigurierbare KI-Prompts + Tracking-Feld fuer Uebungs-Zusammenfassung
|
||||||
|
-- Datum: 2026-05-22
|
||||||
|
-- Spec: technical/KI_FEATURES_SPEC.md, AI_PROMPT_SYSTEM_SPEC.md
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- AI PROMPTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_prompts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
display_name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
template TEXT NOT NULL,
|
||||||
|
|
||||||
|
category VARCHAR(50) DEFAULT 'exercise'
|
||||||
|
CHECK (category IN ('exercise', 'training', 'matrix', 'import', 'admin')),
|
||||||
|
|
||||||
|
output_format VARCHAR(10) DEFAULT 'text'
|
||||||
|
CHECK (output_format IN ('text', 'json')),
|
||||||
|
|
||||||
|
output_schema JSONB,
|
||||||
|
is_system_default BOOLEAN DEFAULT false,
|
||||||
|
default_template TEXT,
|
||||||
|
|
||||||
|
active BOOLEAN DEFAULT true,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
|
||||||
|
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompts_slug ON ai_prompts(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompts_category ON ai_prompts(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompts_active ON ai_prompts(active, sort_order);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS ai_prompts_update ON ai_prompts;
|
||||||
|
CREATE TRIGGER ai_prompts_update
|
||||||
|
BEFORE UPDATE ON ai_prompts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRACKING SUMMARY (KI)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE exercises ADD COLUMN IF NOT EXISTS summary_ai_generated BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN exercises.summary_ai_generated IS 'TRUE wenn Kurzbeschreibung zuletzt von KI vorgeschlagen und uebernommen (UI setzt bei manueller Aenderung false)';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SEED PROMPTS (idempotent)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'pipeline',
|
||||||
|
'Mehrstufige Gesamtanalyse',
|
||||||
|
'Master-Schalter fuer die Pipeline-Anzeige.',
|
||||||
|
'PIPELINE_MASTER',
|
||||||
|
'admin',
|
||||||
|
'text',
|
||||||
|
false,
|
||||||
|
'PIPELINE_MASTER',
|
||||||
|
true,
|
||||||
|
-10
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'pipeline');
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'exercise_summary',
|
||||||
|
'Uebungs-Zusammenfassung',
|
||||||
|
'Erzeugt eine kurze Kurzbeschreibung fuer Listen/Galerie.',
|
||||||
|
$s$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
|
||||||
|
|
||||||
|
Anforderungen:
|
||||||
|
- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
|
||||||
|
- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
|
||||||
|
- Sachlich, auf Deutsch
|
||||||
|
|
||||||
|
Uebung: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
|
||||||
|
|
||||||
|
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$,
|
||||||
|
'exercise',
|
||||||
|
'text',
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_summary');
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'exercise_skill_suggestions',
|
||||||
|
'Faehigkeiten-Empfehlungen',
|
||||||
|
'Schlaegt passende Skills mit Stufen/Intensitaet vor (JSON-Ausgabe-Prompt).',
|
||||||
|
$j$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ordne diese Uebung dem globalen Skill-Katalog zu.
|
||||||
|
|
||||||
|
Daten zur Uebung:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext (optional): {{exercise_focus_area}}
|
||||||
|
Ziel (gekuerzt_plain): {{exercise_goal}}
|
||||||
|
Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
|
||||||
|
|
||||||
|
Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden):
|
||||||
|
{{skills_catalog}}
|
||||||
|
|
||||||
|
Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
|
||||||
|
- skill_id: ganze Zahl aus der Liste
|
||||||
|
- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
|
||||||
|
- target_level: derselbe Wertvorrat
|
||||||
|
- intensity: eines von niedrig, mittel, hoch
|
||||||
|
- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
|
||||||
|
|
||||||
|
Beispielformat:
|
||||||
|
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
||||||
|
|
||||||
|
Wenn nichts gut passt, antworte mit [].$j$,
|
||||||
|
'exercise',
|
||||||
|
'json',
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
2
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_skill_suggestions');
|
||||||
125
backend/migrations/068_ai_skill_retrieval_profiles.sql
Normal file
125
backend/migrations/068_ai_skill_retrieval_profiles.sql
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
-- Migration 068: KI Skill-Retrieval-Profile pro Fokusbereich (+ Standardprofil)
|
||||||
|
-- Purpose: Gewichtungen/Quota fuer exercise_ai Skill-Katalog (OpenRouter Kontext)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_skill_retrieval_profiles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_focus_area
|
||||||
|
ON ai_skill_retrieval_profiles (focus_area_id)
|
||||||
|
WHERE focus_area_id IS NOT NULL AND active = TRUE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_default_only
|
||||||
|
ON ai_skill_retrieval_profiles (is_default)
|
||||||
|
WHERE is_default IS TRUE AND active = TRUE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE ai_skill_retrieval_profiles IS
|
||||||
|
'Gewichte/Quota fuer Skill-Katalog in exercise_ai; optional gebunden an focus_areas, genau eine is_default=TRUE';
|
||||||
|
|
||||||
|
INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config)
|
||||||
|
VALUES (
|
||||||
|
NULL,
|
||||||
|
TRUE,
|
||||||
|
'Standard',
|
||||||
|
'Kein/Undefinierter Fokusbereich: neutrale Gewichte mit sanften Caps auf sehr breite Unterkategorien.',
|
||||||
|
TRUE,
|
||||||
|
'{
|
||||||
|
"version": 1,
|
||||||
|
"importance_multiplier": 1,
|
||||||
|
"text_overlap_bonus": 2,
|
||||||
|
"main_slug_weights": { "karate": 1, "allgemeine": 1 },
|
||||||
|
"category_slug_weights": {},
|
||||||
|
"category_max_share": {
|
||||||
|
"kondition": 0.38,
|
||||||
|
"koordination": 0.35
|
||||||
|
},
|
||||||
|
"main_min_share": {},
|
||||||
|
"description_plain_max_len": 160,
|
||||||
|
"karate_relevance_max_len": 72,
|
||||||
|
"keyword_overrides": [
|
||||||
|
{
|
||||||
|
"keywords_any": ["rollenspiel", "szenario", "deesk", "diskussion"],
|
||||||
|
"case_insensitive": true,
|
||||||
|
"patch": {
|
||||||
|
"category_slug_weights": {
|
||||||
|
"psychische_faehigkeiten": 1.65,
|
||||||
|
"soziale_faehigkeiten": 1.65,
|
||||||
|
"kognition": 1.4
|
||||||
|
},
|
||||||
|
"category_max_share": {
|
||||||
|
"kondition": 0.08,
|
||||||
|
"koordination": 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keywords_any": ["befreiung", "haltegriff", "greifer", "umklammer"],
|
||||||
|
"case_insensitive": true,
|
||||||
|
"patch": {
|
||||||
|
"category_slug_weights": {
|
||||||
|
"selbstverteidigung": 2.2,
|
||||||
|
"koordination": 0.9
|
||||||
|
},
|
||||||
|
"main_slug_weights": { "karate": 1.35 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config)
|
||||||
|
SELECT
|
||||||
|
fa.id,
|
||||||
|
FALSE,
|
||||||
|
'Gewaltschutz',
|
||||||
|
'Kaum klassische Sportfaehigkeit; Gewicht auf Deeskalation, Kognition/Soziales; SV-Schwerpunkt per Keywords verstaerken.',
|
||||||
|
TRUE,
|
||||||
|
'{
|
||||||
|
"version": 1,
|
||||||
|
"importance_multiplier": 1,
|
||||||
|
"text_overlap_bonus": 2.25,
|
||||||
|
"main_slug_weights": { "karate": 1.08, "allgemeine": 1.06 },
|
||||||
|
"category_slug_weights": {
|
||||||
|
"kognition": 1.72,
|
||||||
|
"psychische_faehigkeiten": 1.78,
|
||||||
|
"soziale_faehigkeiten": 1.78,
|
||||||
|
"selbstverteidigung": 1.82,
|
||||||
|
"kondition": 0.32,
|
||||||
|
"koordination": 0.4
|
||||||
|
},
|
||||||
|
"category_max_share": {
|
||||||
|
"kondition": 0.12,
|
||||||
|
"koordination": 0.16
|
||||||
|
},
|
||||||
|
"main_min_share": {},
|
||||||
|
"description_plain_max_len": 150,
|
||||||
|
"karate_relevance_max_len": 0,
|
||||||
|
"keyword_overrides": [
|
||||||
|
{
|
||||||
|
"keywords_any": ["befreiung", "haltegriff", "greifer"],
|
||||||
|
"case_insensitive": true,
|
||||||
|
"patch": {
|
||||||
|
"category_slug_weights": {
|
||||||
|
"selbstverteidigung": 3.25,
|
||||||
|
"koordination": 1.08
|
||||||
|
},
|
||||||
|
"main_slug_weights": { "karate": 1.5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'::jsonb
|
||||||
|
FROM focus_areas fa
|
||||||
|
WHERE fa.name = 'Gewaltschutz'
|
||||||
|
AND (fa.status IS NULL OR fa.status = 'active')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ai_skill_retrieval_profiles p
|
||||||
|
WHERE p.focus_area_id = fa.id AND p.active = TRUE
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
10
backend/migrations/069_ai_prompts_default_template.sql
Normal file
10
backend/migrations/069_ai_prompts_default_template.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Migration 069: ai_prompts default_template fuer Ruecksetzen & Transparenz
|
||||||
|
-- Setzt fuer bestehende System-Prompt-Zeilen default_template aus dem aktuellen template,
|
||||||
|
-- sofern noch kein Referenzinhalt gespeichert war (Migration 067 hatte NULL fuer exercise_*).
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE default_template IS NULL
|
||||||
|
AND template IS NOT NULL
|
||||||
|
AND LENGTH(TRIM(template)) > 0;
|
||||||
7
backend/migrations/070_ai_prompts_openrouter_model.sql
Normal file
7
backend/migrations/070_ai_prompts_openrouter_model.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Migration 070: optionales OpenRouter-Modell pro Prompt-Zeile
|
||||||
|
-- Leer/NULL → Umgebungsvariable OPENROUTER_MODEL (wie bisher).
|
||||||
|
|
||||||
|
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS openrouter_model VARCHAR(200);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN ai_prompts.openrouter_model IS
|
||||||
|
'Optional: OpenRouter model id (z.B. anthropic/claude-3.5-haiku); NULL = OPENROUTER_MODEL aus Env';
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
-- Migration 071: KI-Prompt fuer Anleitungs-Ueberarbeitung (Ziel, Durchfuehrung, Vorbereitung, Trainer-Hinweise)
|
||||||
|
-- JSON-Ausgabe; praezise HTML-Fragmente fuer RichTextEditor.
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'exercise_instruction_rewrite',
|
||||||
|
'Anleitung ueberarbeiten',
|
||||||
|
'Ueberarbeitet Ziel, Durchfuehrung, Vorbereitung und Trainer-Hinweise — praezise, strukturiert, ohne Aufblaehen.',
|
||||||
|
$t$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
|
||||||
|
Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
|
||||||
|
|
||||||
|
Stil:
|
||||||
|
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
|
||||||
|
- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
|
||||||
|
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
|
||||||
|
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
|
||||||
|
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
|
||||||
|
|
||||||
|
Format (HTML fuer Rich-Text-Editor):
|
||||||
|
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||||
|
- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
|
||||||
|
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
|
||||||
|
Ziel (Plaintext, Ausgang): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
|
||||||
|
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
|
||||||
|
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
|
||||||
|
{
|
||||||
|
"goal": "<p>…</p>",
|
||||||
|
"execution": "<ol><li>…</li></ol>",
|
||||||
|
"preparation": "<p>…</p> oder \"\"",
|
||||||
|
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$,
|
||||||
|
'exercise',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["goal","execution","preparation","trainer_notes"],"properties":{"goal":{"type":"string"},"execution":{"type":"string"},"preparation":{"type":"string"},"trainer_notes":{"type":"string"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
3
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_instruction_rewrite');
|
||||||
|
|
||||||
|
-- Referenztext fuer Admin-Ruecksetzen (wie 069)
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template
|
||||||
|
WHERE slug = 'exercise_instruction_rewrite'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
-- Migration 072: KI-Prompt Planungs-Übungssuche — LLM-Rerank (Phase 2)
|
||||||
|
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §14
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_search_rank',
|
||||||
|
'Planungs-Übungssuche Rerank',
|
||||||
|
'Ordnet Kandidaten für die Trainingsplanung nach Intent und Kontext; nur IDs aus candidates_json.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer bei der Trainingsplanung.
|
||||||
|
Ordne die vorgegebenen Übungs-Kandidaten nach Eignung für die aktuelle Planungssituation.
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Verwende NUR exercise_id-Werte aus candidates_json (keine erfundenen IDs).
|
||||||
|
- Berücksichtige search_query, intent, planning_context_json und target_profile_json.
|
||||||
|
- Bewerte anhand von Titel, summary, goal und skills jedes Kandidaten.
|
||||||
|
- Gib maximal {{result_limit}} IDs in sinnvoller Reihenfolge zurück (beste zuerst).
|
||||||
|
- Kurze Begründung pro Top-Treffer auf Deutsch (1 Satz, sachlich).
|
||||||
|
|
||||||
|
Intent-Hinweise:
|
||||||
|
- suggest_next / progression_next: logische Fortsetzung, Progression, passende Skills
|
||||||
|
- deepen_exercise: Vertiefung zum Anker, ähnlicher Fokus
|
||||||
|
- continue_plan_goal: schließt an bisherigen Plan und Skill-Lücken an
|
||||||
|
- free_search: Freitext-Relevanz
|
||||||
|
|
||||||
|
Kontext:
|
||||||
|
Intent: {{intent}}
|
||||||
|
Suchanfrage: {{search_query}}
|
||||||
|
Planung: {{planning_context_json}}
|
||||||
|
Zielprofil: {{target_profile_json}}
|
||||||
|
|
||||||
|
Kandidaten (JSON):
|
||||||
|
{{candidates_json}}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON (kein Text davor/danach):
|
||||||
|
{
|
||||||
|
"ranked_ids": [123, 456],
|
||||||
|
"reasons": { "123": "…", "456": "…" }
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["ranked_ids"],"properties":{"ranked_ids":{"type":"array","items":{"type":"integer"}},"reasons":{"type":"object"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
10
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_rank');
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template
|
||||||
|
WHERE slug = 'planning_exercise_search_rank'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Migration 073: KI-Prompt Planungs-Übungssuche — Intent/Query-Overlay (P1)
|
||||||
|
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_search_intent',
|
||||||
|
'Planungs-Übungssuche Intent',
|
||||||
|
'Strukturiert Freitext-Anfrage in Intent, Szenario und Katalog-Hints für Erwartungsprofil-Overlay.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung.
|
||||||
|
Analysiere die Suchanfrage im Kontext der Einheit und des bisherigen Plans.
|
||||||
|
|
||||||
|
Ziel: JSON für ein Erwartungsprofil-Overlay (Fähigkeiten, Fokus, Stil …) — NICHT Übungs-IDs erfinden.
|
||||||
|
|
||||||
|
Szenario-Klassen (scenario):
|
||||||
|
- preset_next: nur „nächste Übung“ ohne Zusatz — selten bei Freitext
|
||||||
|
- progression: Progressionsgraph / Pfad / Folgeübung im Graph
|
||||||
|
- deepen: Vertiefung zur Anker-Übung
|
||||||
|
- continue_plan: baut auf bisherigem Plan der Einheit auf
|
||||||
|
- additive_constraint: Plan beibehalten UND zusätzliche Anforderung (z. B. „außerdem Schnellkraft“)
|
||||||
|
- free_search: offene Stichwortsuche / neues Thema
|
||||||
|
|
||||||
|
Intent (intent): suggest_next | progression_next | deepen_exercise | continue_plan_goal | free_search
|
||||||
|
|
||||||
|
emphasis:
|
||||||
|
- additive: Zusatz zur bestehenden Planung (Default bei „zusätzlich/auch/dazu“)
|
||||||
|
- replace: Suchanfrage soll Schwerpunkt eher ersetzen
|
||||||
|
- neutral: nur leichte Gewichtung
|
||||||
|
|
||||||
|
Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung).
|
||||||
|
Bei requires_partner: true/false/null wenn Partnerbezug erkennbar.
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Suchanfrage: {{search_query}}
|
||||||
|
Heuristik-Intent: {{heuristic_intent}}
|
||||||
|
Szenario-Hinweis (Server): {{scenario_hint}}
|
||||||
|
Planungskontext: {{planning_context_json}}
|
||||||
|
Basis-Zielprofil (deterministisch): {{target_profile_json}}
|
||||||
|
|
||||||
|
Kataloge (Auszug — nur diese Namen/IDs verwenden):
|
||||||
|
Skills: {{skills_catalog_json}}
|
||||||
|
Fokus: {{focus_areas_catalog_json}}
|
||||||
|
Trainingsstil: {{training_types_catalog_json}}
|
||||||
|
Stilrichtung: {{style_directions_catalog_json}}
|
||||||
|
Zielgruppe: {{target_groups_catalog_json}}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"intent": "continue_plan_goal",
|
||||||
|
"scenario": "additive_constraint",
|
||||||
|
"skill_hints": [{"name": "Schnellkraft", "weight": 1.0}],
|
||||||
|
"focus_hints": [],
|
||||||
|
"style_hints": [],
|
||||||
|
"training_type_hints": [],
|
||||||
|
"target_group_hints": [],
|
||||||
|
"requires_partner": null,
|
||||||
|
"emphasis": "additive",
|
||||||
|
"rationale": "Kurz auf Deutsch, 1 Satz"
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["intent","scenario"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
11
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_intent');
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template
|
||||||
|
WHERE slug = 'planning_exercise_search_intent'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
-- Migration 074: KI-Prompt Planungs-Übungssuche — Erwartungsprofil aus Planungskontext (Preset)
|
||||||
|
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_expectation_profile',
|
||||||
|
'Planungs-Übungssuche Erwartungsprofil',
|
||||||
|
'Leitet aus Einheit, Abschnitt, Anker und bisherigem Plan ein Erwartungsprofil für die nächste Übung ab (ohne Freitext-Anfrage).',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung.
|
||||||
|
Der Trainer wählt „nächste Übung aus Kontext“ — es gibt KEINE zusätzliche Freitext-Suchanfrage.
|
||||||
|
|
||||||
|
Deine Aufgabe: Aus dem Planungskontext und dem deterministischen Basis-Zielprofil ein präzises Erwartungsprofil ableiten:
|
||||||
|
- Was soll die nächste Übung fachlich leisten (Fortsetzen, Vertiefen, Lücke schließen, Abwechslung)?
|
||||||
|
- Welche Fähigkeiten, Fokus-Bereiche, Trainingsstile passen dazu?
|
||||||
|
- Berücksichtige: Rahmen/Einheit, Abschnittsziel (guidance_notes), letzte Übung im Abschnitt, Anker-Übung, Skill-Profile Einheit vs. Abschnitt, Skill-Lücken im Basisprofil.
|
||||||
|
|
||||||
|
Intent (intent): meist suggest_next oder continue_plan_goal; progression_next nur wenn Progressionsgraph/Anker klar nahelegt; deepen_exercise nur bei klarer Vertiefungslage.
|
||||||
|
|
||||||
|
continuation (optional, Kurzlabel):
|
||||||
|
- build_on_section: nahtlos an Abschnitt/letzte Übung anknüpfen
|
||||||
|
- close_skill_gap: fehlende Fähigkeiten aus Plan/Rahmen nachziehen
|
||||||
|
- deepen_anchor: Anker-Übung vertiefen
|
||||||
|
- variety: bewusst variieren nach bisherigem Block
|
||||||
|
- balance_load: Belastung ausgleichen / Tempo wechseln
|
||||||
|
|
||||||
|
Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung).
|
||||||
|
emphasis: fast immer additive (baut auf Basisprofil auf), nur replace wenn Kontext eindeutig neuen Schwerpunkt verlangt.
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Heuristik-Intent: {{heuristic_intent}}
|
||||||
|
Planungskontext: {{planning_context_json}}
|
||||||
|
Basis-Zielprofil (deterministisch): {{target_profile_json}}
|
||||||
|
|
||||||
|
Kataloge (Auszug — nur diese Namen/IDs verwenden):
|
||||||
|
Skills: {{skills_catalog_json}}
|
||||||
|
Fokus: {{focus_areas_catalog_json}}
|
||||||
|
Trainingsstil: {{training_types_catalog_json}}
|
||||||
|
Stilrichtung: {{style_directions_catalog_json}}
|
||||||
|
Zielgruppe: {{target_groups_catalog_json}}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"intent": "suggest_next",
|
||||||
|
"scenario": "preset_next",
|
||||||
|
"continuation": "build_on_section",
|
||||||
|
"skill_hints": [{"name": "Kime", "weight": 0.9}],
|
||||||
|
"focus_hints": [],
|
||||||
|
"style_hints": [],
|
||||||
|
"training_type_hints": [],
|
||||||
|
"target_group_hints": [],
|
||||||
|
"requires_partner": null,
|
||||||
|
"emphasis": "additive",
|
||||||
|
"rationale": "Kurz auf Deutsch, 1–2 Sätze: warum diese nächste Übung sinnvoll ist"
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["intent","scenario","rationale"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"continuation":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
12
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_expectation_profile');
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET default_template = template
|
||||||
|
WHERE slug = 'planning_exercise_expectation_profile'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
-- Migration 075: Planungs-KI Phase E — Semantik-Enrichment + Pfad-QA Prompts
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_query_semantics',
|
||||||
|
'Planungs-Übungssuche Semantik',
|
||||||
|
'Erweitert deterministisches Semantic Brief um must/exclude phrases und Entwicklungsbogen.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer bei der semantischen Analyse von Planungs-Anfragen.
|
||||||
|
|
||||||
|
Ziel: JSON für ein Semantic Brief — präzise Kernbegriffe, Ausschlüsse, Entwicklungsbogen.
|
||||||
|
Nutze das bestehende Brief als Basis; ergänze/verfeinere, ersetze aber keine eindeutige Technik-Identität.
|
||||||
|
|
||||||
|
Anfrage: {{search_query}}
|
||||||
|
Bestehendes Brief (deterministisch): {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- must_phrases: konkrete Technik-/Themen-Phrasen aus der Anfrage (z. B. "mae geri", nicht nur "geri")
|
||||||
|
- exclude_phrases: konkurrierende Techniken/Themen, die NICHT gemeint sind
|
||||||
|
- development_arc: geordnete Phasen aus: einstieg, grundlage, vertiefung, anwendung, perfektion
|
||||||
|
- semantic_strength: 0.0–1.0 (höher bei spezifischer Technik/Thema)
|
||||||
|
- primary_topic: Hauptthema in wenigen Worten
|
||||||
|
- topic_type: technique | focus | method | skill | general
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"topic_type": "technique",
|
||||||
|
"must_phrases": ["mae geri"],
|
||||||
|
"exclude_phrases": ["mawashi geri", "sakuto geri"],
|
||||||
|
"development_arc": ["einstieg", "grundlage", "vertiefung", "perfektion"],
|
||||||
|
"semantic_strength": 0.9,
|
||||||
|
"rationale": "Kurz auf Deutsch"
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"must_phrases":{"type":"array"},"exclude_phrases":{"type":"array"},"development_arc":{"type":"array"},"semantic_strength":{"type":"number"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
12
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_query_semantics');
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_exercise_path_qa',
|
||||||
|
'Planungs-Pfad QA',
|
||||||
|
'Semantische Qualitätsprüfung eines vorgeschlagenen Übungspfads inkl. Lücken und Brücken.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte?
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"]
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","required":["overall_ok"],"properties":{"overall_ok":{"type":"boolean"},"quality_score":{"type":"number"},"issues":{"type":"array"},"sequence_notes":{"type":"array"},"recommendations":{"type":"array"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
13
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_path_qa');
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET default_template = template
|
||||||
|
WHERE slug IN ('planning_exercise_query_semantics', 'planning_exercise_path_qa')
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- Migration 076: Planungs-Pfad-QA — Neuordnung + KI-Lückenfüller (Phase E2)
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"]
|
||||||
|
}$t$,
|
||||||
|
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"]
|
||||||
|
}$t$
|
||||||
|
WHERE slug = 'planning_exercise_path_qa';
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
-- Migration 077: Planungs-Pfad-QA — strukturierte Neuanlage-Vorschläge (Phase E3)
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"],
|
||||||
|
"suggested_new_exercises": [
|
||||||
|
{
|
||||||
|
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||||
|
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||||
|
"phase": "vertiefung",
|
||||||
|
"insert_after_step_index": 2,
|
||||||
|
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"],
|
||||||
|
"suggested_new_exercises": [
|
||||||
|
{
|
||||||
|
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||||
|
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||||
|
"phase": "vertiefung",
|
||||||
|
"insert_after_step_index": 2,
|
||||||
|
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
output_schema = '{"type":"object","required":["overall_ok"],"properties":{"overall_ok":{"type":"boolean"},"quality_score":{"type":"number"},"issues":{"type":"array"},"sequence_notes":{"type":"array"},"recommendations":{"type":"array"},"ordered_step_indices":{"type":"array"},"suggested_new_exercises":{"type":"array"}}}'::jsonb
|
||||||
|
WHERE slug = 'planning_exercise_path_qa';
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Migration 078: Planungs-KI Phase F — Progressions-Roadmap Prompts (Zielanalyse + Roadmap)
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_progression_goal_analysis',
|
||||||
|
'Progressions-Roadmap Zielanalyse',
|
||||||
|
'Phase A: Ist-/Soll-Zustand und Erfolgskriterien für einen Progressionsgraphen (ohne Gruppenkontext).',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Wichtig: Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
|
||||||
|
"target_state": "Konkreter Zielzustand der Progression",
|
||||||
|
"success_criteria": ["messbare Kriterien"],
|
||||||
|
"constraints": { "partner_required": false }
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"primary_topic":{"type":"string"},"target_state":{"type":"string"},"success_criteria":{"type":"array"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
14
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_goal_analysis');
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_progression_roadmap',
|
||||||
|
'Progressions-Roadmap Major Steps',
|
||||||
|
'Phase B: 8–12 micro_objectives, Konsolidierung auf N major_steps.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Anzahl Major Steps (N): {{max_steps}}
|
||||||
|
|
||||||
|
Erzeuge zuerst 8–12 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps.
|
||||||
|
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion — in sinnvoller Reihenfolge (Grundlagen vor Perfektion).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"micro_objectives": [
|
||||||
|
{ "id": "m1", "phase": "grundlage", "title": "…", "weight": 0.9, "depends_on": [] }
|
||||||
|
],
|
||||||
|
"major_steps": [
|
||||||
|
{ "index": 0, "phase": "grundlage", "learning_goal": "…", "consolidates": ["m1","m2"], "rationale": "…" }
|
||||||
|
],
|
||||||
|
"consolidation_notes": ["…"]
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"micro_objectives":{"type":"array"},"major_steps":{"type":"array"},"consolidation_notes":{"type":"array"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
15
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_roadmap');
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET default_template = template
|
||||||
|
WHERE slug IN ('planning_progression_goal_analysis', 'planning_progression_roadmap')
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
286
backend/migrations/078_club_features_and_plans.sql
Normal file
286
backend/migrations/078_club_features_and_plans.sql
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
-- Migration 078: Vereins-Feature-Registry (Mitai-v9c-Pattern) + club_plans/subscriptions
|
||||||
|
-- Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md (M1)
|
||||||
|
-- Legacy 001 (SERIAL features, profile tier_limits) wird archiviert, nicht gelöscht.
|
||||||
|
|
||||||
|
-- ── 1. Legacy-Tabellen archivieren (nur alte Struktur) ─────────────────────
|
||||||
|
DO $migration$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features'
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'name'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type'
|
||||||
|
) THEN
|
||||||
|
-- Nach abgebrochenem Erstversuch kann features_legacy_001 schon existieren
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features_legacy_001'
|
||||||
|
) THEN
|
||||||
|
DROP TABLE features;
|
||||||
|
ELSE
|
||||||
|
ALTER TABLE features RENAME TO features_legacy_001;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'tier_limits'
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'tier_limits' AND column_name = 'tier'
|
||||||
|
) THEN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'tier_limits_legacy_001'
|
||||||
|
) THEN
|
||||||
|
DROP TABLE tier_limits;
|
||||||
|
ELSE
|
||||||
|
ALTER TABLE tier_limits RENAME TO tier_limits_legacy_001;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'user_feature_usage'
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'user_feature_usage' AND column_name = 'profile_id'
|
||||||
|
) THEN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'user_feature_usage_legacy_001'
|
||||||
|
) THEN
|
||||||
|
DROP TABLE user_feature_usage;
|
||||||
|
ELSE
|
||||||
|
ALTER TABLE user_feature_usage RENAME TO user_feature_usage_legacy_001;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$migration$;
|
||||||
|
|
||||||
|
-- ── 2. Feature-Registry (TEXT-PK, app=shinkan) ────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS features (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
app TEXT NOT NULL DEFAULT 'shinkan',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT NOT NULL DEFAULT 'content',
|
||||||
|
limit_type TEXT NOT NULL DEFAULT 'count'
|
||||||
|
CHECK (limit_type IN ('count', 'boolean')),
|
||||||
|
reset_period TEXT NOT NULL DEFAULT 'never'
|
||||||
|
CHECK (reset_period IN ('never', 'daily', 'monthly')),
|
||||||
|
default_limit INTEGER,
|
||||||
|
enforcement_subject TEXT NOT NULL DEFAULT 'club'
|
||||||
|
CHECK (enforcement_subject IN ('club', 'profile', 'portal')),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_features_app ON features(app) WHERE active = true;
|
||||||
|
|
||||||
|
-- ── 3. Vereins-Produkte ─────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS club_plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
price_monthly_cents INTEGER,
|
||||||
|
price_yearly_cents INTEGER,
|
||||||
|
stripe_price_id_monthly TEXT,
|
||||||
|
stripe_price_id_yearly TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_plan_limits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
limit_value INTEGER,
|
||||||
|
UNIQUE (plan_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_plan_limits_plan ON club_plan_limits(plan_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_subscriptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT NOT NULL REFERENCES club_plans(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'trial', 'past_due', 'cancelled')),
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ends_at TIMESTAMPTZ,
|
||||||
|
trial_ends_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_subscriptions_plan ON club_subscriptions(plan_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_feature_overrides (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
limit_value INTEGER NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_access_grants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
plan_id TEXT REFERENCES club_plans(id) ON DELETE SET NULL,
|
||||||
|
feature_id TEXT REFERENCES features(id) ON DELETE SET NULL,
|
||||||
|
grant_limit INTEGER,
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ends_at TIMESTAMPTZ NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_access_grants_club ON club_access_grants(club_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_access_grants_window ON club_access_grants(club_id, starts_at, ends_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_feature_usage (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reset_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (club_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_feature_usage_club ON club_feature_usage(club_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_feature_usage_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_feature_usage_events_club
|
||||||
|
ON club_feature_usage_events(club_id, created_at DESC);
|
||||||
|
|
||||||
|
-- ── 4. Seed: Features ─────────────────────────────────────────────────────
|
||||||
|
INSERT INTO features (id, app, name, description, category, limit_type, reset_period, default_limit, enforcement_subject)
|
||||||
|
VALUES
|
||||||
|
('exercises', 'shinkan', 'Übungen', 'Anzahl Übungen im Verein (Bestand)', 'content', 'count', 'never', 100, 'club'),
|
||||||
|
('exercise_media', 'shinkan', 'Medien-Uploads', 'Medien-Uploads pro Monat', 'content', 'count', 'monthly', 20, 'club'),
|
||||||
|
('training_units', 'shinkan', 'Trainingseinheiten', 'Trainingseinheiten pro Monat', 'planning', 'count', 'monthly', 40, 'club'),
|
||||||
|
('training_programs', 'shinkan', 'Trainingsprogramme', 'Module und Rahmenprogramme (Bestand)', 'planning', 'count', 'never', 5, 'club'),
|
||||||
|
('training_groups', 'shinkan', 'Trainingsgruppen', 'Anzahl Trainingsgruppen', 'org', 'count', 'never', 10, 'club'),
|
||||||
|
('active_members', 'shinkan', 'Aktive Mitglieder', 'Anzahl aktiver Vereinsmitglieder', 'org', 'count', 'never', 25, 'club'),
|
||||||
|
('ai_calls', 'shinkan', 'KI-Aufrufe', 'KI-Aufrufe pro Monat (Suggest, Regenerate, Planung)', 'ai', 'count', 'monthly', 0, 'club'),
|
||||||
|
('ai_pipeline', 'shinkan', 'KI-Pipeline', 'Erweiterte KI-Batch-Pipelines', 'ai', 'boolean', 'never', 0, 'club'),
|
||||||
|
('wiki_import', 'shinkan', 'Wiki-Import', 'MediaWiki-Import (Plattform)', 'integration', 'boolean', 'never', 0, 'portal'),
|
||||||
|
('data_export', 'shinkan', 'Daten-Export', 'Export-Funktionen', 'integration', 'boolean', 'never', 0, 'club')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 5. Seed: Pläne ──────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO club_plans (id, name, description, sort_order, active)
|
||||||
|
VALUES
|
||||||
|
('free', 'Free', 'Einstieg für Vereine', 0, true),
|
||||||
|
('verein_starter', 'Verein Starter', 'Erweiterte Kontingente', 10, true),
|
||||||
|
('verein_pro', 'Verein Pro', 'Hohe Limits und KI-Kontingent', 20, true),
|
||||||
|
('pilot', 'Pilot', 'Pilotverein mit großzügigen Limits', 5, true)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Plan-Limits: free
|
||||||
|
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||||
|
SELECT 'free', f.id,
|
||||||
|
CASE f.id
|
||||||
|
WHEN 'exercises' THEN 100
|
||||||
|
WHEN 'exercise_media' THEN 20
|
||||||
|
WHEN 'training_units' THEN 40
|
||||||
|
WHEN 'training_programs' THEN 5
|
||||||
|
WHEN 'training_groups' THEN 10
|
||||||
|
WHEN 'active_members' THEN 25
|
||||||
|
WHEN 'ai_calls' THEN 0
|
||||||
|
WHEN 'ai_pipeline' THEN 0
|
||||||
|
WHEN 'wiki_import' THEN 0
|
||||||
|
WHEN 'data_export' THEN 0
|
||||||
|
END
|
||||||
|
FROM features f
|
||||||
|
WHERE f.app = 'shinkan'
|
||||||
|
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Plan-Limits: verein_starter
|
||||||
|
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||||
|
SELECT 'verein_starter', f.id,
|
||||||
|
CASE f.id
|
||||||
|
WHEN 'exercises' THEN 500
|
||||||
|
WHEN 'exercise_media' THEN 80
|
||||||
|
WHEN 'training_units' THEN 200
|
||||||
|
WHEN 'training_programs' THEN 30
|
||||||
|
WHEN 'training_groups' THEN 30
|
||||||
|
WHEN 'active_members' THEN 80
|
||||||
|
WHEN 'ai_calls' THEN 30
|
||||||
|
WHEN 'ai_pipeline' THEN 0
|
||||||
|
WHEN 'wiki_import' THEN 0
|
||||||
|
WHEN 'data_export' THEN 1
|
||||||
|
END
|
||||||
|
FROM features f
|
||||||
|
WHERE f.app = 'shinkan'
|
||||||
|
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Plan-Limits: verein_pro (NULL = unbegrenzt wo sinnvoll)
|
||||||
|
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||||
|
SELECT 'verein_pro', f.id,
|
||||||
|
CASE f.id
|
||||||
|
WHEN 'exercises' THEN NULL
|
||||||
|
WHEN 'exercise_media' THEN 300
|
||||||
|
WHEN 'training_units' THEN NULL
|
||||||
|
WHEN 'training_programs' THEN NULL
|
||||||
|
WHEN 'training_groups' THEN NULL
|
||||||
|
WHEN 'active_members' THEN NULL
|
||||||
|
WHEN 'ai_calls' THEN 200
|
||||||
|
WHEN 'ai_pipeline' THEN 1
|
||||||
|
WHEN 'wiki_import' THEN 0
|
||||||
|
WHEN 'data_export' THEN 1
|
||||||
|
END
|
||||||
|
FROM features f
|
||||||
|
WHERE f.app = 'shinkan'
|
||||||
|
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Plan-Limits: pilot
|
||||||
|
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||||
|
SELECT 'pilot', f.id,
|
||||||
|
CASE f.id
|
||||||
|
WHEN 'exercises' THEN NULL
|
||||||
|
WHEN 'exercise_media' THEN NULL
|
||||||
|
WHEN 'training_units' THEN NULL
|
||||||
|
WHEN 'training_programs' THEN NULL
|
||||||
|
WHEN 'training_groups' THEN NULL
|
||||||
|
WHEN 'active_members' THEN NULL
|
||||||
|
WHEN 'ai_calls' THEN 100
|
||||||
|
WHEN 'ai_pipeline' THEN 1
|
||||||
|
WHEN 'wiki_import' THEN 0
|
||||||
|
WHEN 'data_export' THEN 1
|
||||||
|
END
|
||||||
|
FROM features f
|
||||||
|
WHERE f.app = 'shinkan'
|
||||||
|
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 6. Backfill: bestehende Vereine → Plan free ───────────────────────────
|
||||||
|
INSERT INTO club_subscriptions (club_id, plan_id, status)
|
||||||
|
SELECT c.id, 'free', 'active'
|
||||||
|
FROM clubs c
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM club_subscriptions cs WHERE cs.club_id = c.id
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- Migration 079: Planungs-KI Phase F — Stufenspezifikation (Prompt in ai_prompts, nicht im Code)
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_progression_stage_spec',
|
||||||
|
'Progressions-Roadmap Stufenspezifikation',
|
||||||
|
'Phase C: Belastungsprofil, Übungstyp und Erfolgskriterien je Major Step.',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Major Steps: {{major_steps_json}}
|
||||||
|
|
||||||
|
Für jeden Major Step: messbares Lernziel, load_profile (z. B. koordination, präzision, kraft), exercise_type (kihon_einzel, partner_drill, kombination, kraft_auxiliary), success_criteria, anti_patterns (z. B. reine Kraft ohne Technikbezug).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"stage_specs": [
|
||||||
|
{
|
||||||
|
"major_step_index": 0,
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["koordination", "gleichgewicht"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["…"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"stage_specs":{"type":"array"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
16
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_stage_spec');
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
225
backend/migrations/079_capabilities.sql
Normal file
225
backend/migrations/079_capabilities.sql
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
-- Migration 079: Capability-Registry + Rollen-Grants (M3 / CAPABILITY_CATALOG.v1.md C1)
|
||||||
|
-- Account-Gates und Enforcement in Python (account_lifecycle.py, capabilities.py).
|
||||||
|
-- Voraussetzung: Migration 078 (features.id TEXT). Kein FK auf features — vermeidet
|
||||||
|
-- Startup-Abbruch wenn 078 noch aussteht oder features-Schema driftet (001 vs v9c).
|
||||||
|
|
||||||
|
DO $migration$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Migration 079: features-Tabelle nicht v9c (limit_type fehlt). Zuerst 078_club_features_and_plans anwenden.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$migration$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS capabilities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
min_account_state TEXT NOT NULL DEFAULT 'active_member'
|
||||||
|
CHECK (min_account_state IN (
|
||||||
|
'unverified', 'verified_pending_club', 'active_member', 'platform_admin'
|
||||||
|
)),
|
||||||
|
linked_feature_id TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_capabilities_domain ON capabilities(domain) WHERE active = true;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_role_capability_grants (
|
||||||
|
role_code TEXT NOT NULL,
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (role_code, capability_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_role_cap_grants_cap ON club_role_capability_grants(capability_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS portal_role_capability_grants (
|
||||||
|
portal_role TEXT NOT NULL,
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (portal_role, capability_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Seed: Capabilities (v1 Katalog §5) ───────────────────────────────────────
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) VALUES
|
||||||
|
('account.settings.read', 'Einstellungen lesen', 'account', 'unverified', NULL),
|
||||||
|
('account.settings.update', 'Einstellungen ändern', 'account', 'unverified', NULL),
|
||||||
|
('account.password.change', 'Passwort ändern', 'account', 'unverified', NULL),
|
||||||
|
('account.resend_verification', 'Verifizierung erneut senden', 'account', 'unverified', NULL),
|
||||||
|
('club.directory.read', 'Vereinsverzeichnis', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.join_request.create', 'Vereinsbeitritt beantragen', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.join_request.withdraw', 'Beitrittsantrag zurückziehen', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.join_request.read_own', 'Eigene Beitrittsanträge', 'club', 'verified_pending_club', NULL),
|
||||||
|
('org.club.read', 'Vereine lesen', 'org', 'active_member', NULL),
|
||||||
|
('org.club.create', 'Verein anlegen', 'org', 'platform_admin', NULL),
|
||||||
|
('org.club.update', 'Verein bearbeiten', 'org', 'active_member', NULL),
|
||||||
|
('org.club.delete', 'Verein löschen', 'org', 'platform_admin', NULL),
|
||||||
|
('org.structure.manage', 'Vereinsstruktur verwalten', 'org', 'active_member', 'training_groups'),
|
||||||
|
('org.members.read', 'Mitgliederliste', 'org', 'active_member', NULL),
|
||||||
|
('org.members.manage', 'Mitglieder verwalten', 'org', 'active_member', 'active_members'),
|
||||||
|
('org.members.directory', 'Mitglieder-Verzeichnis', 'org', 'active_member', NULL),
|
||||||
|
('org.join_request.review', 'Beitrittsanträge prüfen', 'org', 'active_member', NULL),
|
||||||
|
('org.inbox.read', 'Posteingang', 'org', 'active_member', NULL),
|
||||||
|
('exercises.read', 'Übungen lesen', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.create', 'Übung anlegen', 'exercises', 'active_member', 'exercises'),
|
||||||
|
('exercises.update', 'Übung bearbeiten', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.delete', 'Übung löschen', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.bulk_metadata', 'Übungen Stapel-Metadaten', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.ai.suggest', 'KI-Vorschlag Übung', 'exercises', 'active_member', 'ai_calls'),
|
||||||
|
('exercises.ai.regenerate', 'KI neu generieren', 'exercises', 'active_member', 'ai_calls'),
|
||||||
|
('exercises.media.read', 'Übungsmedien lesen', 'exercises', 'active_member', NULL),
|
||||||
|
('exercises.media.upload', 'Übungsmedien hochladen', 'exercises', 'active_member', 'exercise_media'),
|
||||||
|
('exercises.variants.manage', 'Übungsvarianten', 'exercises', 'active_member', NULL),
|
||||||
|
('media.library.read', 'Medienbibliothek lesen', 'media', 'active_member', NULL),
|
||||||
|
('media.library.upload', 'Medienbibliothek Upload', 'media', 'active_member', 'exercise_media'),
|
||||||
|
('media.library.update', 'Medienbibliothek bearbeiten', 'media', 'active_member', NULL),
|
||||||
|
('media.library.lifecycle', 'Medien-Lifecycle', 'media', 'active_member', NULL),
|
||||||
|
('media.rights.declare', 'Medienrechte erklären', 'media', 'active_member', NULL),
|
||||||
|
('media.admin.rights_review', 'Medienrechte Review (Plattform)', 'media', 'platform_admin', NULL),
|
||||||
|
('modules.read', 'Trainingsmodule lesen', 'modules', 'active_member', NULL),
|
||||||
|
('modules.create', 'Trainingsmodul anlegen', 'modules', 'active_member', 'training_programs'),
|
||||||
|
('modules.update', 'Trainingsmodul bearbeiten', 'modules', 'active_member', NULL),
|
||||||
|
('modules.delete', 'Trainingsmodul löschen', 'modules', 'active_member', NULL),
|
||||||
|
('framework.read', 'Rahmenprogramme lesen', 'framework', 'active_member', NULL),
|
||||||
|
('framework.create', 'Rahmenprogramm anlegen', 'framework', 'active_member', 'training_programs'),
|
||||||
|
('framework.update', 'Rahmenprogramm bearbeiten', 'framework', 'active_member', NULL),
|
||||||
|
('framework.delete', 'Rahmenprogramm löschen', 'framework', 'active_member', NULL),
|
||||||
|
('plan_templates.read', 'Planungsvorlagen lesen', 'planning', 'active_member', NULL),
|
||||||
|
('plan_templates.manage', 'Planungsvorlagen verwalten', 'planning', 'active_member', NULL),
|
||||||
|
('progression.read', 'Progressionspfade lesen', 'progression', 'active_member', NULL),
|
||||||
|
('progression.manage', 'Progressionspfade verwalten', 'progression', 'active_member', NULL),
|
||||||
|
('planning.calendar.read', 'Planungskalender lesen', 'planning', 'active_member', NULL),
|
||||||
|
('planning.units.create', 'Trainingseinheit anlegen', 'planning', 'active_member', 'training_units'),
|
||||||
|
('planning.units.update', 'Trainingseinheit bearbeiten', 'planning', 'active_member', NULL),
|
||||||
|
('planning.units.delete', 'Trainingseinheit löschen', 'planning', 'active_member', NULL),
|
||||||
|
('planning.units.run', 'Training durchführen', 'planning', 'active_member', NULL),
|
||||||
|
('planning.coach.execute', 'Coach ausführen', 'planning', 'active_member', NULL),
|
||||||
|
('planning.ai.suggest', 'Planungs-KI Suggest', 'planning', 'active_member', 'ai_calls'),
|
||||||
|
('planning.ai.progression_path', 'Planungs-KI Progressionspfad', 'planning', 'active_member', 'ai_calls'),
|
||||||
|
('skills.catalog.read', 'Fähigkeitenkatalog', 'skills', 'active_member', NULL),
|
||||||
|
('skills.discovery.read', 'Fähigkeiten-Discovery', 'skills', 'active_member', NULL),
|
||||||
|
('skill_profiles.read', 'Skill-Profile lesen', 'skills', 'active_member', NULL),
|
||||||
|
('governance.content_report.create', 'Inhalt melden', 'governance', 'active_member', NULL),
|
||||||
|
('governance.content_report.review', 'Meldungen prüfen', 'governance', 'active_member', NULL),
|
||||||
|
('platform.admin.access', 'Plattform-Admin-Bereich', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.users.manage', 'Nutzer verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.catalogs.manage', 'Kataloge verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.maturity_models.manage', 'Reifegradmodelle', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.wiki_import.execute', 'Wiki-Import', 'platform', 'platform_admin', 'wiki_import'),
|
||||||
|
('platform.ai_prompts.manage', 'KI-Prompts verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.exercise_enrichment.execute', 'Übungs-Anreicherung KI', 'platform', 'platform_admin', 'ai_calls'),
|
||||||
|
('platform.user_content.moderate', 'Nutzer-Inhalte moderieren', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.legal_documents.manage', 'Rechtstexte verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.media_storage.manage', 'Medienspeicher verwalten', 'platform', 'platform_admin', NULL),
|
||||||
|
('platform.club_creation.approve', 'Vereinsgründung freigeben', 'platform', 'platform_admin', NULL)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Vereinsrollen-Grants (§6 — nur eingeschränkte Capabilities) ─────────────
|
||||||
|
-- Konvention: keine Grant-Zeile = alle aktiven Mitglieder (min_account_state reicht).
|
||||||
|
|
||||||
|
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||||
|
SELECT r.role_code, c.id
|
||||||
|
FROM (VALUES
|
||||||
|
('club_admin', 'org.structure.manage'),
|
||||||
|
('division_lead', 'org.structure.manage'),
|
||||||
|
('club_admin', 'org.members.manage'),
|
||||||
|
('club_admin', 'org.join_request.review'),
|
||||||
|
('club_admin', 'org.inbox.read'),
|
||||||
|
('club_admin', 'exercises.create'),
|
||||||
|
('trainer', 'exercises.create'),
|
||||||
|
('content_editor', 'exercises.create'),
|
||||||
|
('division_lead', 'exercises.create'),
|
||||||
|
('club_admin', 'exercises.update'),
|
||||||
|
('trainer', 'exercises.update'),
|
||||||
|
('content_editor', 'exercises.update'),
|
||||||
|
('division_lead', 'exercises.update'),
|
||||||
|
('club_admin', 'exercises.delete'),
|
||||||
|
('club_admin', 'exercises.bulk_metadata'),
|
||||||
|
('content_editor', 'exercises.bulk_metadata'),
|
||||||
|
('club_admin', 'exercises.ai.suggest'),
|
||||||
|
('trainer', 'exercises.ai.suggest'),
|
||||||
|
('content_editor', 'exercises.ai.suggest'),
|
||||||
|
('division_lead', 'exercises.ai.suggest'),
|
||||||
|
('club_admin', 'exercises.ai.regenerate'),
|
||||||
|
('trainer', 'exercises.ai.regenerate'),
|
||||||
|
('content_editor', 'exercises.ai.regenerate'),
|
||||||
|
('division_lead', 'exercises.ai.regenerate'),
|
||||||
|
('club_admin', 'exercises.media.upload'),
|
||||||
|
('trainer', 'exercises.media.upload'),
|
||||||
|
('content_editor', 'exercises.media.upload'),
|
||||||
|
('club_admin', 'exercises.variants.manage'),
|
||||||
|
('trainer', 'exercises.variants.manage'),
|
||||||
|
('content_editor', 'exercises.variants.manage'),
|
||||||
|
('club_admin', 'media.library.upload'),
|
||||||
|
('trainer', 'media.library.upload'),
|
||||||
|
('content_editor', 'media.library.upload'),
|
||||||
|
('club_admin', 'media.library.update'),
|
||||||
|
('trainer', 'media.library.update'),
|
||||||
|
('content_editor', 'media.library.update'),
|
||||||
|
('club_admin', 'media.library.lifecycle'),
|
||||||
|
('trainer', 'media.library.lifecycle'),
|
||||||
|
('club_admin', 'media.rights.declare'),
|
||||||
|
('trainer', 'media.rights.declare'),
|
||||||
|
('club_admin', 'modules.create'),
|
||||||
|
('trainer', 'modules.create'),
|
||||||
|
('content_editor', 'modules.create'),
|
||||||
|
('club_admin', 'modules.update'),
|
||||||
|
('trainer', 'modules.update'),
|
||||||
|
('content_editor', 'modules.update'),
|
||||||
|
('club_admin', 'modules.delete'),
|
||||||
|
('club_admin', 'framework.create'),
|
||||||
|
('trainer', 'framework.create'),
|
||||||
|
('club_admin', 'framework.update'),
|
||||||
|
('trainer', 'framework.update'),
|
||||||
|
('club_admin', 'framework.delete'),
|
||||||
|
('club_admin', 'plan_templates.manage'),
|
||||||
|
('trainer', 'plan_templates.manage'),
|
||||||
|
('club_admin', 'progression.manage'),
|
||||||
|
('trainer', 'progression.manage'),
|
||||||
|
('content_editor', 'progression.manage'),
|
||||||
|
('club_admin', 'planning.units.create'),
|
||||||
|
('trainer', 'planning.units.create'),
|
||||||
|
('division_lead', 'planning.units.create'),
|
||||||
|
('club_admin', 'planning.units.update'),
|
||||||
|
('trainer', 'planning.units.update'),
|
||||||
|
('division_lead', 'planning.units.update'),
|
||||||
|
('club_admin', 'planning.units.delete'),
|
||||||
|
('trainer', 'planning.units.delete'),
|
||||||
|
('club_admin', 'planning.units.run'),
|
||||||
|
('trainer', 'planning.units.run'),
|
||||||
|
('division_lead', 'planning.units.run'),
|
||||||
|
('club_admin', 'planning.coach.execute'),
|
||||||
|
('trainer', 'planning.coach.execute'),
|
||||||
|
('club_admin', 'planning.ai.suggest'),
|
||||||
|
('trainer', 'planning.ai.suggest'),
|
||||||
|
('division_lead', 'planning.ai.suggest'),
|
||||||
|
('club_admin', 'planning.ai.progression_path'),
|
||||||
|
('trainer', 'planning.ai.progression_path'),
|
||||||
|
('division_lead', 'planning.ai.progression_path'),
|
||||||
|
('club_admin', 'skills.discovery.read'),
|
||||||
|
('trainer', 'skills.discovery.read'),
|
||||||
|
('content_editor', 'skills.discovery.read'),
|
||||||
|
('club_admin', 'governance.content_report.review')
|
||||||
|
) AS r(role_code, cap_id)
|
||||||
|
JOIN capabilities c ON c.id = r.cap_id
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- org.club.update: club_admin (zusätzlich zu platform_admin via Bypass)
|
||||||
|
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||||
|
VALUES ('club_admin', 'org.club.update')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Portal-Rollen ───────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
SELECT 'admin', id FROM capabilities WHERE id = 'platform.admin.access'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
SELECT 'superadmin', id FROM capabilities WHERE domain = 'platform'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
41
backend/migrations/080_club_creation_requests.sql
Normal file
41
backend/migrations/080_club_creation_requests.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- Migration 080: Antrag auf Vereinsgründung (M7)
|
||||||
|
-- Nutzer verified_pending_club stellt Antrag; Plattform-Admin legt Verein + Abo an.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS club_creation_requests (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
proposed_name VARCHAR(200) NOT NULL,
|
||||||
|
proposed_abbreviation VARCHAR(50),
|
||||||
|
proposed_description TEXT,
|
||||||
|
message TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')),
|
||||||
|
decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
decided_at TIMESTAMP,
|
||||||
|
created_club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_creation_requests_pending
|
||||||
|
ON club_creation_requests (profile_id)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_creation_requests_status
|
||||||
|
ON club_creation_requests (status, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_club_creation_requests_profile
|
||||||
|
ON club_creation_requests (profile_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS club_creation_requests_update ON club_creation_requests;
|
||||||
|
CREATE TRIGGER club_creation_requests_update
|
||||||
|
BEFORE UPDATE ON club_creation_requests
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||||
|
|
||||||
|
-- Capabilities (CAPABILITY_CATALOG.v1.md — club.creation_request.*)
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES
|
||||||
|
('club.creation_request.create', 'Vereinsgründung beantragen', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.creation_request.read_own', 'Eigene Gründungsanträge', 'club', 'verified_pending_club', NULL),
|
||||||
|
('club.creation_request.withdraw', 'Gründungsantrag zurückziehen', 'club', 'verified_pending_club', NULL)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
13
backend/migrations/081_club_creation_request_superseded.sql
Normal file
13
backend/migrations/081_club_creation_request_superseded.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Migration 081: Status superseded wenn freigegebener Verein gelöscht wurde
|
||||||
|
|
||||||
|
ALTER TABLE club_creation_requests
|
||||||
|
DROP CONSTRAINT IF EXISTS club_creation_requests_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE club_creation_requests
|
||||||
|
ADD CONSTRAINT club_creation_requests_status_check
|
||||||
|
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn', 'superseded'));
|
||||||
|
|
||||||
|
-- Bestehende Drift: approved ohne Verein (ON DELETE SET NULL auf created_club_id)
|
||||||
|
UPDATE club_creation_requests
|
||||||
|
SET status = 'superseded', updated_at = NOW()
|
||||||
|
WHERE status = 'approved' AND created_club_id IS NULL;
|
||||||
36
backend/migrations/082_platform_club_feature_exemptions.sql
Normal file
36
backend/migrations/082_platform_club_feature_exemptions.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- Migration 082: Plattform-/Profil-Ausnahmen vom Vereins-Kontingent (M5+)
|
||||||
|
-- Superadmin & konfigurierbare Rollen/Profile verbrauchen kein club_feature_usage.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_role_club_feature_exemptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
portal_role TEXT NOT NULL,
|
||||||
|
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_role_club_feat_exempt
|
||||||
|
ON platform_role_club_feature_exemptions (portal_role, COALESCE(feature_id, '*'));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_club_feature_exemptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT,
|
||||||
|
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_profile_club_feat_exempt
|
||||||
|
ON profile_club_feature_exemptions (profile_id, COALESCE(feature_id, '*'));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_club_feat_exempt_profile
|
||||||
|
ON profile_club_feature_exemptions (profile_id);
|
||||||
|
|
||||||
|
-- Superadmin: alle Vereins-Features ohne Kontingent-Verbrauch
|
||||||
|
INSERT INTO platform_role_club_feature_exemptions (portal_role, feature_id, note)
|
||||||
|
SELECT 'superadmin', NULL, 'Plattform-Administrator: kein Vereins-Kontingent'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM platform_role_club_feature_exemptions
|
||||||
|
WHERE portal_role = 'superadmin' AND feature_id IS NULL
|
||||||
|
);
|
||||||
103
backend/migrations/083_capability_quota_bypass.sql
Normal file
103
backend/migrations/083_capability_quota_bypass.sql
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
-- Migration 083: Vereins-Kontingent-Bypass über Capability-System (kein Parallel-Schema)
|
||||||
|
-- Ersetzt platform_role_club_feature_exemptions / profile_club_feature_exemptions aus 082.
|
||||||
|
|
||||||
|
-- Einzelprofil-Grants (ergänzt portal_role_capability_grants)
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_capability_grants (
|
||||||
|
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT,
|
||||||
|
granted_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (profile_id, capability_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_capability_grants_cap
|
||||||
|
ON profile_capability_grants(capability_id);
|
||||||
|
|
||||||
|
-- Bypass-Capabilities (CAPABILITY_CATALOG — konfigurierbar via portal/profile grants)
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'platform.club_quota.bypass',
|
||||||
|
'Vereins-Kontingent umgehen (alle Features)',
|
||||||
|
'platform',
|
||||||
|
'platform_admin',
|
||||||
|
NULL
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Superadmin: alle Plattform-Capabilities inkl. bypass (079-Seed deckt domain=platform ab)
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
SELECT 'superadmin', 'platform.club_quota.bypass'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = 'superadmin' AND capability_id = 'platform.club_quota.bypass'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Daten aus 082 übernehmen (falls vorhanden) ─────────────────────────────
|
||||||
|
DO $migrate082$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
cap_id TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'platform_role_club_feature_exemptions'
|
||||||
|
) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
FOR r IN
|
||||||
|
SELECT portal_role, feature_id, note
|
||||||
|
FROM platform_role_club_feature_exemptions
|
||||||
|
LOOP
|
||||||
|
IF r.feature_id IS NULL THEN
|
||||||
|
cap_id := 'platform.club_quota.bypass';
|
||||||
|
ELSE
|
||||||
|
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES (
|
||||||
|
cap_id,
|
||||||
|
'Vereins-Kontingent umgehen: ' || r.feature_id,
|
||||||
|
'quota_bypass',
|
||||||
|
'active_member',
|
||||||
|
r.feature_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
VALUES (lower(trim(r.portal_role)), cap_id)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
FOR r IN
|
||||||
|
SELECT profile_id, feature_id, reason, set_by_profile_id
|
||||||
|
FROM profile_club_feature_exemptions
|
||||||
|
LOOP
|
||||||
|
IF r.feature_id IS NULL THEN
|
||||||
|
cap_id := 'platform.club_quota.bypass';
|
||||||
|
ELSE
|
||||||
|
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES (
|
||||||
|
cap_id,
|
||||||
|
'Vereins-Kontingent umgehen: ' || r.feature_id,
|
||||||
|
'quota_bypass',
|
||||||
|
'active_member',
|
||||||
|
r.feature_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO profile_capability_grants (
|
||||||
|
profile_id, capability_id, reason, granted_by_profile_id
|
||||||
|
)
|
||||||
|
VALUES (r.profile_id, cap_id, r.reason, r.set_by_profile_id)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS profile_club_feature_exemptions;
|
||||||
|
DROP TABLE IF EXISTS platform_role_club_feature_exemptions;
|
||||||
|
END
|
||||||
|
$migrate082$;
|
||||||
15
backend/migrations/084_rights_registry_module.sql
Normal file
15
backend/migrations/084_rights_registry_module.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- Migration 084: Modul-Registrierung für Rechte & Kontingente (Registry-first)
|
||||||
|
-- capabilities/features mit module=NULL = Legacy-Katalog-Seed (nicht in Admin-Matrix).
|
||||||
|
-- module IS NOT NULL = vom Modul bei Implementierung registriert.
|
||||||
|
|
||||||
|
ALTER TABLE capabilities
|
||||||
|
ADD COLUMN IF NOT EXISTS module TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE features
|
||||||
|
ADD COLUMN IF NOT EXISTS module TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_capabilities_module
|
||||||
|
ON capabilities(module) WHERE module IS NOT NULL AND active = true;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_features_module
|
||||||
|
ON features(module) WHERE module IS NOT NULL AND active = true;
|
||||||
181
backend/migrations/085_ai_prompt_exercise_planning_context.sql
Normal file
181
backend/migrations/085_ai_prompt_exercise_planning_context.sql
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
-- Migration 085: Planungskontext in Übungs-KI-Prompts (Phase D)
|
||||||
|
-- Platzhalter: {{planning_context_json}}, {{#has_planning_context}} … {{/has_planning_context}}
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $s$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
|
||||||
|
|
||||||
|
Anforderungen:
|
||||||
|
- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
|
||||||
|
- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
|
||||||
|
- Sachlich, auf Deutsch
|
||||||
|
|
||||||
|
Uebung: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$,
|
||||||
|
default_template = $s$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
|
||||||
|
|
||||||
|
Anforderungen:
|
||||||
|
- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
|
||||||
|
- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
|
||||||
|
- Sachlich, auf Deutsch
|
||||||
|
|
||||||
|
Uebung: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$
|
||||||
|
WHERE slug = 'exercise_summary';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $j$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ordne diese Uebung dem globalen Skill-Katalog zu.
|
||||||
|
|
||||||
|
Daten zur Uebung:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext (optional): {{exercise_focus_area}}
|
||||||
|
Ziel (gekuerzt_plain): {{exercise_goal}}
|
||||||
|
Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden):
|
||||||
|
{{skills_catalog}}
|
||||||
|
|
||||||
|
Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
|
||||||
|
- skill_id: ganze Zahl aus der Liste
|
||||||
|
- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
|
||||||
|
- target_level: derselbe Wertvorrat
|
||||||
|
- intensity: eines von niedrig, mittel, hoch
|
||||||
|
- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
|
||||||
|
|
||||||
|
Beispielformat:
|
||||||
|
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
||||||
|
|
||||||
|
Wenn nichts gut passt, antworte mit [].$j$,
|
||||||
|
default_template = $j$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ordne diese Uebung dem globalen Skill-Katalog zu.
|
||||||
|
|
||||||
|
Daten zur Uebung:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext (optional): {{exercise_focus_area}}
|
||||||
|
Ziel (gekuerzt_plain): {{exercise_goal}}
|
||||||
|
Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden):
|
||||||
|
{{skills_catalog}}
|
||||||
|
|
||||||
|
Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
|
||||||
|
- skill_id: ganze Zahl aus der Liste
|
||||||
|
- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
|
||||||
|
- target_level: derselbe Wertvorrat
|
||||||
|
- intensity: eines von niedrig, mittel, hoch
|
||||||
|
- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
|
||||||
|
|
||||||
|
Beispielformat:
|
||||||
|
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
||||||
|
|
||||||
|
Wenn nichts gut passt, antworte mit [].$j$
|
||||||
|
WHERE slug = 'exercise_skill_suggestions';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
|
||||||
|
Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
|
||||||
|
|
||||||
|
Stil:
|
||||||
|
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
|
||||||
|
- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
|
||||||
|
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
|
||||||
|
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
|
||||||
|
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
|
||||||
|
|
||||||
|
Format (HTML fuer Rich-Text-Editor):
|
||||||
|
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||||
|
- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
|
||||||
|
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
|
||||||
|
Ziel (Plaintext, Ausgang): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
|
||||||
|
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
|
||||||
|
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
|
||||||
|
{
|
||||||
|
"goal": "<p>…</p>",
|
||||||
|
"execution": "<ol><li>…</li></ol>",
|
||||||
|
"preparation": "<p>…</p> oder \"\"",
|
||||||
|
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$,
|
||||||
|
default_template = $t$Du bist Assistent fuer Kampfsport-Trainer.
|
||||||
|
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
|
||||||
|
Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
|
||||||
|
|
||||||
|
Stil:
|
||||||
|
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
|
||||||
|
- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
|
||||||
|
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
|
||||||
|
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
|
||||||
|
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
|
||||||
|
|
||||||
|
Format (HTML fuer Rich-Text-Editor):
|
||||||
|
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||||
|
- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
|
||||||
|
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
|
||||||
|
|
||||||
|
Eingabe:
|
||||||
|
Titel: {{exercise_title}}
|
||||||
|
Fokuskontext: {{exercise_focus_area}}
|
||||||
|
|
||||||
|
Ziel (Plaintext, Ausgang): {{exercise_goal}}
|
||||||
|
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
|
||||||
|
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
|
||||||
|
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
|
||||||
|
{{#has_planning_context}}
|
||||||
|
Planungskontext (JSON):
|
||||||
|
{{planning_context_json}}
|
||||||
|
{{/has_planning_context}}
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
|
||||||
|
{
|
||||||
|
"goal": "<p>…</p>",
|
||||||
|
"execution": "<ol><li>…</li></ol>",
|
||||||
|
"preparation": "<p>…</p> oder \"\"",
|
||||||
|
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$
|
||||||
|
WHERE slug = 'exercise_instruction_rewrite';
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
-- Migration 087: Planungs-KI — LLM Start/Ziel-Extraktion aus Trainer-Anfrage (Alternative zu Regex)
|
||||||
|
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
slug, display_name, description, template,
|
||||||
|
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'planning_progression_start_target',
|
||||||
|
'Progressions-Roadmap Start/Ziel-Extraktion',
|
||||||
|
'Versteht die Trainer-Anfrage und formuliert dedizierte Ausgangslage, Zielzustand und Ergänzungen (ohne Gruppen-Tracking).',
|
||||||
|
$t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen didaktischen Progressionsgraphen.
|
||||||
|
|
||||||
|
Trainer-Anfrage (Ursprungstext):
|
||||||
|
{{goal_query}}
|
||||||
|
|
||||||
|
Semantic Brief (heuristisch): {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Bereits vom Trainer eingegebene Ergänzungen (falls vorhanden): {{user_notes}}
|
||||||
|
|
||||||
|
Aufgabe:
|
||||||
|
1. **primary_topic** — Kern-Thema/Technik in kurzer, präziser Bezeichnung (z. B. „Kumite Beinarbeit“, „Mae Geri“).
|
||||||
|
2. **start_situation** — Ausgangslage in eigenen Worten: Was kann der Athlet/die Gruppe *jetzt* (laut Anfrage oder sinnvoll ableitbar)? Konkret, beobachtbar, ohne Gruppenanalyse aus der Datenbank.
|
||||||
|
3. **target_state** — Zielzustand in eigenen Worten: Was soll am Ende der Progression erreicht sein? Konkret, didaktisch nutzbar.
|
||||||
|
4. **roadmap_notes** — Ergänzungen aus dem Ursprungstext: Fokus, Kontext (z. B. Kumite), besondere Anforderungen, Einschränkungen, die der Trainer erwähnt hat oder die für die Roadmap relevant sind. Nicht wiederholen, was bereits in start_situation/target_state steht.
|
||||||
|
5. **extraction_notes** — Kurz (1–2 Sätze): Was war explizit vs. abgeleitet? Wo war die Anfrage unklar?
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Keine Gruppenanalyse — nur das, was aus dem Text hervorgeht oder didaktisch naheliegend formuliert ist.
|
||||||
|
- Formuliere start_situation und target_state **eigenständig und verständlich**, nicht nur Textfragmente kopieren.
|
||||||
|
- Bei „von … bis …“: Start und Ziel aus diesem Bogen schärfen und präzise beschreiben.
|
||||||
|
- Bei nur einem Thema ohne Bogen: start_situation und target_state didaktisch sinnvoll formulieren oder leer lassen, wenn nicht ableitbar — dann in extraction_notes erklären.
|
||||||
|
- Antworte NUR mit JSON.
|
||||||
|
|
||||||
|
{
|
||||||
|
"primary_topic": "…",
|
||||||
|
"start_situation": "…",
|
||||||
|
"target_state": "…",
|
||||||
|
"roadmap_notes": "…",
|
||||||
|
"extraction_notes": "…"
|
||||||
|
}$t$,
|
||||||
|
'training',
|
||||||
|
'json',
|
||||||
|
'{"type":"object","properties":{"primary_topic":{"type":"string"},"start_situation":{"type":"string"},"target_state":{"type":"string"},"roadmap_notes":{"type":"string"},"extraction_notes":{"type":"string"}}}'::jsonb,
|
||||||
|
true,
|
||||||
|
NULL,
|
||||||
|
true,
|
||||||
|
13
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_start_target');
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET default_template = template
|
||||||
|
WHERE slug = 'planning_progression_start_target'
|
||||||
|
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Migration 088: Planungs-Roadmap-Artefakt am Progressionsgraph (JSONB, optional).
|
||||||
|
-- Speichert Ziel, Start/Ziel, progression_roadmap + stage_specs für Wiederaufnahme der KI-Planung.
|
||||||
|
|
||||||
|
ALTER TABLE exercise_progression_graphs
|
||||||
|
ADD COLUMN IF NOT EXISTS planning_roadmap JSONB;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN exercise_progression_graphs.planning_roadmap IS
|
||||||
|
'Optionales Planungs-Artefakt (goal_query, resolved_structured, progression_roadmap, stage_specs) — Schema v1 im App-Code.';
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
-- Migration 089: Planungs-Intent — Zielanalyse + Stufenspecs (anti_patterns, success_criteria)
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET
|
||||||
|
description = 'Phase A: Ist-/Soll, Erfolgskriterien und explizite Ausschlüsse (ohne Gruppenkontext).',
|
||||||
|
template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema.
|
||||||
|
- Explizite Negationen aus der Anfrage (ohne/kein/nicht …) in constraints.excluded_themes übernehmen — nicht raten.
|
||||||
|
- success_criteria: messbar, für späteres Übungs-Matching (Titel + Kurzbeschreibung + Übungsziel).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "Hauptthema",
|
||||||
|
"start_assumption": "Voraussetzungen für den Einstieg",
|
||||||
|
"target_state": "Konkreter Zielzustand der Progression",
|
||||||
|
"success_criteria": ["messbare Kriterien entlang des Pfads"],
|
||||||
|
"constraints": {
|
||||||
|
"partner_required": false,
|
||||||
|
"excluded_themes": ["wörtliche Negationen, z. B. keine Kumite-Anwendung"],
|
||||||
|
"trainer_notes": "optional: Fokus aus Ergänzungen"
|
||||||
|
}
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_goal_analysis';
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET
|
||||||
|
description = 'Phase C: Belastung, Übungstyp, Erfolgskriterien und anti_patterns je Major Step — für Retrieval-Matching.',
|
||||||
|
template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Major Steps: {{major_steps_json}}
|
||||||
|
Planungs-Intent (Pfadweite Regeln): {{intent_context_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Aufgabe je Major Step — Felder für automatisches Übungs-Matching (nicht nur Titel):
|
||||||
|
- learning_goal: messbares Stufen-Lernziel (was die Übung bringen soll)
|
||||||
|
- load_profile: z. B. koordination, präzision, kraft, athletik
|
||||||
|
- exercise_type: kihon_einzel | partner_drill | kombination | kraft_auxiliary
|
||||||
|
- success_criteria: 2–4 prüfbare Kriterien an Kurzbeschreibung + Übungsziel (nicht nur Technikname im Titel)
|
||||||
|
- anti_patterns: 2–5 Dinge, die für diese Stufe unpassend sind
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
1. Jede explicit_exclusions / excluded_themes aus intent_context und Zielanalyse MUSS in anti_patterns jeder Stufe vorkommen (umformuliert ok).
|
||||||
|
2. Keine neuen Ausschlüsse erfinden, die nicht in Anfrage/Intent/Zielanalyse stehen.
|
||||||
|
3. success_criteria Pfad-weit + stufenspezifisch kombinieren.
|
||||||
|
4. partner_drill nur wenn Partner/Kumite nicht ausgeschlossen ist.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"stage_specs": [
|
||||||
|
{
|
||||||
|
"major_step_index": 0,
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["koordination"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["…"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec';
|
||||||
43
backend/migrations/090_ai_prompt_stage_transition_states.sql
Normal file
43
backend/migrations/090_ai_prompt_stage_transition_states.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- Migration 090: Stufenspecs — start_state / target_state pro Major Step (Soll-Verkettung)
|
||||||
|
|
||||||
|
UPDATE ai_prompts SET
|
||||||
|
description = 'Phase C: Stufenspezifikation inkl. Soll-Start und Stufen-Ziel je Major Step.',
|
||||||
|
template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Major Steps: {{major_steps_json}}
|
||||||
|
Planungs-Intent (Pfadweite Regeln): {{intent_context_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Jede Stufe ist ein Übergang im Gesamtpfad:
|
||||||
|
- start_state: Soll-Zustand zu Beginn (= Ziel der vorherigen Stufe; Stufe 0 = Pfad-Start)
|
||||||
|
- target_state: Zielzustand nach dieser Stufe (= Soll für die nächste Stufe)
|
||||||
|
- learning_goal: messbares Lernziel der Übungssuche (was die Übung bringen soll)
|
||||||
|
|
||||||
|
Felder je Major Step:
|
||||||
|
- load_profile, exercise_type, success_criteria, anti_patterns (wie bisher)
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
1. start_state/target_state aus Zielanalyse und Major Steps ableiten — konsistente Kette.
|
||||||
|
2. explicit_exclusions aus intent_context in anti_patterns jeder Stufe.
|
||||||
|
3. success_criteria: prüfbar an Kurzbeschreibung + Übungsziel.
|
||||||
|
4. Keine erfundenen Ausschlüsse.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"stage_specs": [
|
||||||
|
{
|
||||||
|
"major_step_index": 0,
|
||||||
|
"start_state": "…",
|
||||||
|
"target_state": "…",
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["koordination"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["…"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec';
|
||||||
172
backend/migrations/091_ai_prompt_catalog_guidance.sql
Normal file
172
backend/migrations/091_ai_prompt_catalog_guidance.sql
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
-- Migration 091: Planungs-KI H1 — Katalog-Guidance-Platzhalter in Progressions-Prompts
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"],
|
||||||
|
"suggested_new_exercises": [
|
||||||
|
{
|
||||||
|
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||||
|
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||||
|
"phase": "vertiefung",
|
||||||
|
"insert_after_step_index": 2,
|
||||||
|
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||||
|
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||||
|
|
||||||
|
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"],
|
||||||
|
"suggested_new_exercises": [
|
||||||
|
{
|
||||||
|
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||||
|
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||||
|
"phase": "vertiefung",
|
||||||
|
"insert_after_step_index": 2,
|
||||||
|
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$
|
||||||
|
WHERE slug = 'planning_exercise_path_qa';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
|
||||||
|
Wichtig: Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
|
||||||
|
"target_state": "Konkreter Zielzustand der Progression",
|
||||||
|
"success_criteria": ["messbare Kriterien"],
|
||||||
|
"constraints": { "partner_required": false }
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_goal_analysis';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Anzahl Major Steps (N): {{max_steps}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Erzeuge zuerst 8–12 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps.
|
||||||
|
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion — in sinnvoller Reihenfolge (Grundlagen vor Perfektion).
|
||||||
|
Beachte Katalog-Roadmap-Hinweise, falls gesetzt.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"micro_objectives": [
|
||||||
|
{ "id": "m1", "phase": "grundlage", "title": "…", "weight": 0.9, "depends_on": [] }
|
||||||
|
],
|
||||||
|
"major_steps": [
|
||||||
|
{ "index": 0, "phase": "grundlage", "learning_goal": "…", "consolidates": ["m1","m2"], "rationale": "…" }
|
||||||
|
],
|
||||||
|
"consolidation_notes": ["…"]
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_roadmap';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Major Steps: {{major_steps_json}}
|
||||||
|
|
||||||
|
{{catalog_guidance_block}}
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Für jeden Major Step: messbares Lernziel, load_profile (z. B. koordination, präzision, kraft), exercise_type (kihon_einzel, partner_drill, kombination, kraft_auxiliary), success_criteria, anti_patterns (z. B. reine Kraft ohne Technikbezug).
|
||||||
|
Beachte Katalog-QS-Kriterien und Anti-Patterns, falls gesetzt.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"stage_specs": [
|
||||||
|
{
|
||||||
|
"major_step_index": 0,
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["koordination", "gleichgewicht"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["…"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec';
|
||||||
86
backend/migrations/092_catalog_prompt_slots.sql
Normal file
86
backend/migrations/092_catalog_prompt_slots.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
-- Migration 092: Katalog-Prompt-Slots (H2) — Slot-Typ-Vokabular + leere Wertetabelle
|
||||||
|
-- Keine Inhalts-Seeds: Stammdaten (Primärfokus etc.) sind mandantenspezifisch.
|
||||||
|
-- Slot-Inhalte: Admin-UI oder catalog_slot_fallbacks.py (Namens-Match zur Laufzeit).
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
-- Migration 093: Planungs-KI — granulare Katalog-Slot-Platzhalter in Prompt-Templates
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||||
|
|
||||||
|
Ziel-Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Schritte (JSON): {{steps_json}}
|
||||||
|
Erkannte Lücken: {{gaps_json}}
|
||||||
|
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||||
|
|
||||||
|
Katalog-Kontext für Bewertung (Trainer-Auswahl — leere Zeilen ignorieren):
|
||||||
|
|
||||||
|
Primärfokus:
|
||||||
|
{{focus_area_description}}
|
||||||
|
QS: {{focus_area_hints_on_path_qa}}
|
||||||
|
Vermeiden: {{focus_area_anti_patterns}}
|
||||||
|
|
||||||
|
Trainingsstil:
|
||||||
|
{{training_type_description}}
|
||||||
|
QS: {{training_type_hints_on_path_qa}}
|
||||||
|
|
||||||
|
Zielgruppe:
|
||||||
|
{{target_group_description}}
|
||||||
|
QS: {{target_group_hints_on_path_qa}}
|
||||||
|
|
||||||
|
Stilrichtung:
|
||||||
|
{{style_direction_description}}
|
||||||
|
QS: {{style_direction_hints_on_path_qa}}
|
||||||
|
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Wichtig: Wenn Katalog-Slots gesetzt sind, haben diese Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
|
||||||
|
|
||||||
|
Prüfe:
|
||||||
|
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||||
|
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||||
|
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||||
|
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||||
|
5. Fehlen wichtige Zwischenschritte — gemäß Katalog-QS-Hinweisen, nicht pauschal „Perfektion“?
|
||||||
|
6. Gibt es Schritte ohne Bezug zum Hauptthema?
|
||||||
|
|
||||||
|
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes.
|
||||||
|
Wenn wichtige Zwischenschritte fehlen: suggested_new_exercises mit Titel + Kurzskizze und insert_after_step_index.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"overall_ok": true,
|
||||||
|
"quality_score": 0.85,
|
||||||
|
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||||
|
"ordered_step_indices": [0, 1, 2, 3],
|
||||||
|
"issues": ["…"],
|
||||||
|
"sequence_notes": ["…"],
|
||||||
|
"recommendations": ["…"],
|
||||||
|
"suggested_new_exercises": []
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_exercise_path_qa';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Katalog-Kontext (Primärfokus / Trainingsstil / Zielgruppe / Stil — leere Zeilen ignorieren):
|
||||||
|
|
||||||
|
Primärfokus: {{focus_area_description}}
|
||||||
|
Progression: {{focus_area_hints_on_progression}}
|
||||||
|
|
||||||
|
Trainingsstil: {{training_type_description}}
|
||||||
|
Progression: {{training_type_hints_on_progression}}
|
||||||
|
|
||||||
|
Zielgruppe: {{target_group_description}}
|
||||||
|
|
||||||
|
Stilrichtung: {{style_direction_description}}
|
||||||
|
|
||||||
|
Wichtig: Keine Gruppenanalyse — nur didaktischer Pfad. Katalog-Hinweise beachten.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "Mae Geri",
|
||||||
|
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
|
||||||
|
"target_state": "Konkreter Zielzustand der Progression",
|
||||||
|
"success_criteria": ["messbare Kriterien"],
|
||||||
|
"constraints": { "partner_required": false }
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_goal_analysis';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Anzahl Major Steps (N): {{max_steps}}
|
||||||
|
|
||||||
|
Katalog-Kontext für Stufenlogik:
|
||||||
|
|
||||||
|
Primärfokus:
|
||||||
|
{{focus_area_description}}
|
||||||
|
Roadmap: {{focus_area_hints_on_progression}}
|
||||||
|
Vermeiden: {{focus_area_anti_patterns}}
|
||||||
|
|
||||||
|
Trainingsstil:
|
||||||
|
{{training_type_description}}
|
||||||
|
Roadmap: {{training_type_hints_on_progression}}
|
||||||
|
|
||||||
|
Zielgruppe:
|
||||||
|
{{target_group_description}}
|
||||||
|
Roadmap: {{target_group_hints_on_progression}}
|
||||||
|
|
||||||
|
Stilrichtung:
|
||||||
|
{{style_direction_description}}
|
||||||
|
Roadmap: {{style_direction_hints_on_progression}}
|
||||||
|
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Erzeuge zuerst 8–12 micro_objectives, dann konsolidiere auf genau N major_steps.
|
||||||
|
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion — Katalog-Roadmap-Hinweise beachten.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"micro_objectives": [
|
||||||
|
{ "id": "m1", "phase": "grundlage", "title": "…", "weight": 0.9, "depends_on": [] }
|
||||||
|
],
|
||||||
|
"major_steps": [
|
||||||
|
{ "index": 0, "phase": "grundlage", "learning_goal": "…", "consolidates": ["m1","m2"], "rationale": "…" }
|
||||||
|
],
|
||||||
|
"consolidation_notes": ["…"]
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_roadmap';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Zielanalyse: {{goal_analysis_json}}
|
||||||
|
Major Steps: {{major_steps_json}}
|
||||||
|
Intent-Kontext: {{intent_context_json}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
|
||||||
|
Katalog-Kontext je Stufe:
|
||||||
|
|
||||||
|
Primärfokus — Progression: {{focus_area_hints_on_progression}}
|
||||||
|
Primärfokus — Vermeiden: {{focus_area_anti_patterns}}
|
||||||
|
|
||||||
|
Trainingsstil — Progression: {{training_type_hints_on_progression}}
|
||||||
|
Trainingsstil — Vermeiden: {{training_type_anti_patterns}}
|
||||||
|
|
||||||
|
Zielgruppe — Progression: {{target_group_hints_on_progression}}
|
||||||
|
Zielgruppe — Vermeiden: {{target_group_anti_patterns}}
|
||||||
|
|
||||||
|
Stilrichtung — Progression: {{style_direction_hints_on_progression}}
|
||||||
|
|
||||||
|
{{catalog_context_json}}
|
||||||
|
|
||||||
|
Für jeden Major Step: messbares Lernziel, load_profile, exercise_type, success_criteria, anti_patterns — Katalog-Slots beachten.
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"stage_specs": [
|
||||||
|
{
|
||||||
|
"major_step_index": 0,
|
||||||
|
"learning_goal": "…",
|
||||||
|
"load_profile": ["koordination", "gleichgewicht"],
|
||||||
|
"exercise_type": "kihon_einzel",
|
||||||
|
"success_criteria": ["…"],
|
||||||
|
"anti_patterns": ["…"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_stage_spec';
|
||||||
|
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET template = $t$Du bist Assistent für Kampfsport-Trainer und extrahierst Start, Ziel und Ergänzungen für einen Progressionsgraphen.
|
||||||
|
|
||||||
|
Anfrage: {{goal_query}}
|
||||||
|
Semantic Brief: {{semantic_brief_json}}
|
||||||
|
Trainer-Notizen: {{user_notes}}
|
||||||
|
|
||||||
|
Katalog-Einordnung:
|
||||||
|
Primärfokus: {{focus_area_description}}
|
||||||
|
Trainingsstil: {{training_type_description}}
|
||||||
|
Zielgruppe: {{target_group_description}}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON:
|
||||||
|
{
|
||||||
|
"primary_topic": "…",
|
||||||
|
"start_situation": "…",
|
||||||
|
"target_state": "…",
|
||||||
|
"roadmap_notes": "…",
|
||||||
|
"extraction_notes": "…"
|
||||||
|
}$t$,
|
||||||
|
default_template = template
|
||||||
|
WHERE slug = 'planning_progression_start_target';
|
||||||
11
backend/migrations/094_catalog_prompt_slots_full_seed.sql
Normal file
11
backend/migrations/094_catalog_prompt_slots_full_seed.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Migration 094: bewusst ohne Daten-Seed (ersetzt frühere Voll-Befüllung)
|
||||||
|
--
|
||||||
|
-- Katalog-Stammdaten (Primärfokus, Trainingsstile, …) sind pro Umgebung unterschiedlich.
|
||||||
|
-- Migrationen dürfen keine Dev-Standardtexte an Prod-Kataloge hängen.
|
||||||
|
--
|
||||||
|
-- Slot-Inhalte stattdessen:
|
||||||
|
-- • Admin-UI (catalog_prompt_slots pro Eintrag)
|
||||||
|
-- • Laufzeit-Fallback catalog_slot_fallbacks.py (Namens-Match, kein DB-Schreiben)
|
||||||
|
-- • optional: backend/scripts/seed_catalog_prompt_slots_dev.py (nur lokale Dev-DB)
|
||||||
|
|
||||||
|
SELECT 1;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Migration 095: Entfernt per 092/094 (alte Version) eingespielte Standard-Slot-Texte
|
||||||
|
--
|
||||||
|
-- Betrifft Dev-Umgebungen, die die frühen Seed-Migrationen bereits erhalten haben.
|
||||||
|
-- Prod mit eigener Katalogstruktur: Tabelle bleibt leer bis Admin-Inhalte gepflegt werden.
|
||||||
|
-- Manuell in der Admin-UI gesetzte Texte nach 095 bitte erneut prüfen (waren ggf. identisch mit Seeds).
|
||||||
|
|
||||||
|
DELETE FROM catalog_prompt_slots;
|
||||||
29
backend/migrations/096_login_lockout_and_totp.sql
Normal file
29
backend/migrations/096_login_lockout_and_totp.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- Login-Sperre, Sitzungszweck (Vollzugriff / 2FA-Einrichtung / 2FA-Challenge), TOTP.
|
||||||
|
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS failed_login_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS totp_secret TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS totp_pending_secret TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) NOT NULL DEFAULT 'full',
|
||||||
|
ADD COLUMN IF NOT EXISTS failed_attempts INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
UPDATE sessions
|
||||||
|
SET purpose = 'password_reset'
|
||||||
|
WHERE token LIKE 'reset_%' AND purpose = 'full';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_profile_purpose ON sessions (profile_id, purpose);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_recovery_codes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
code_hash VARCHAR(64) NOT NULL,
|
||||||
|
used_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_recovery_codes_profile
|
||||||
|
ON profile_recovery_codes (profile_id);
|
||||||
|
|
@ -13,16 +13,38 @@ from datetime import date, time, datetime
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginTotpRequest(BaseModel):
|
||||||
|
challenge_token: str = Field(min_length=8, max_length=200)
|
||||||
|
code: str = Field(min_length=6, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=128)
|
||||||
|
new_password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class TotpCodeRequest(BaseModel):
|
||||||
|
code: str = Field(min_length=6, max_length=12)
|
||||||
|
|
||||||
|
|
||||||
|
class TotpDisableRequest(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=128)
|
||||||
|
code: str = Field(min_length=6, max_length=32)
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str = Field(min_length=1, max_length=128)
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
requested_club_id: Optional[int] = Field(default=None, ge=1)
|
requested_club_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
|
||||||
class PasswordResetRequest(BaseModel):
|
class PasswordResetRequest(BaseModel):
|
||||||
email: str
|
email: EmailStr
|
||||||
|
|
||||||
|
class ResendVerificationRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
class PasswordResetConfirm(BaseModel):
|
class PasswordResetConfirm(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
|
|
|
||||||
231
backend/openrouter_chat.py
Normal file
231
backend/openrouter_chat.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"""
|
||||||
|
Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MODEL / OPENROUTER_BASE_URL from env.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.openrouter")
|
||||||
|
|
||||||
|
_SKIP_ANTHROPIC_BLOCK_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"thinking",
|
||||||
|
"redacted_thinking",
|
||||||
|
"reasoning",
|
||||||
|
"tool_use",
|
||||||
|
"tool_calls",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _shinkan_ai_debug() -> bool:
|
||||||
|
return os.getenv("SHINKAN_AI_DEBUG", "").strip().lower() in ("1", "true", "yes", "full")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_nested_text(val: Any) -> str:
|
||||||
|
if val is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(val, str):
|
||||||
|
return val.strip()
|
||||||
|
if isinstance(val, bool) or isinstance(val, (int, float)):
|
||||||
|
return str(val).strip()
|
||||||
|
if isinstance(val, list):
|
||||||
|
return "".join(_coerce_nested_text(x) for x in val).strip()
|
||||||
|
if isinstance(val, dict):
|
||||||
|
# OpenRouter/Anthropic: verschachtelte text/content-Hüllen
|
||||||
|
for key in ("text", "content", "value"):
|
||||||
|
if key in val:
|
||||||
|
nested = _coerce_nested_text(val.get(key))
|
||||||
|
if nested:
|
||||||
|
return nested
|
||||||
|
return ""
|
||||||
|
return str(val).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_message_content(content: Any) -> str:
|
||||||
|
"""
|
||||||
|
Chat-Completion: `content` als String oder als Liste strukturierter Blöcke
|
||||||
|
(Anthropic Claude über OpenRouter/Bedrock, teils verschachtelt).
|
||||||
|
"""
|
||||||
|
if content is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: List[str] = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
bits = _coerce_nested_text(block)
|
||||||
|
if bits:
|
||||||
|
parts.append(bits)
|
||||||
|
elif isinstance(block, dict):
|
||||||
|
t_raw = block.get("type")
|
||||||
|
ts = str(t_raw or "").strip().lower()
|
||||||
|
if ts and (ts in _SKIP_ANTHROPIC_BLOCK_TYPES or ts.endswith("_thinking")):
|
||||||
|
continue
|
||||||
|
txt = None
|
||||||
|
if ts in ("text", "output_text", ""):
|
||||||
|
txt = block.get("text")
|
||||||
|
if txt is None:
|
||||||
|
txt = block.get("content")
|
||||||
|
else:
|
||||||
|
# unbekannten Typ weiter versuchen (Provider-Varianten), aber tool-use überspringen
|
||||||
|
low = ts
|
||||||
|
if "tool_use" in low or low.startswith("tool_"):
|
||||||
|
continue
|
||||||
|
txt = block.get("text") if block.get("text") is not None else block.get("content")
|
||||||
|
bits = _coerce_nested_text(txt)
|
||||||
|
if bits:
|
||||||
|
parts.append(bits)
|
||||||
|
return "".join(parts).strip()
|
||||||
|
if isinstance(content, dict):
|
||||||
|
return _coerce_nested_text(content)
|
||||||
|
return str(content).strip()
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterError(Exception):
|
||||||
|
"""Upstream or transport failure."""
|
||||||
|
|
||||||
|
|
||||||
|
def openrouter_chat_completion(
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
user_content: str,
|
||||||
|
system_content: Optional[str] = None,
|
||||||
|
timeout_sec: float = 120.0,
|
||||||
|
temperature: float = 0.25,
|
||||||
|
site_url: Optional[str] = None,
|
||||||
|
app_title: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Returns assistant message content (plain string). Caller validates empty responses.
|
||||||
|
"""
|
||||||
|
base = (os.getenv("OPENROUTER_BASE_URL") or "").strip().rstrip("/") or "https://openrouter.ai/api/v1"
|
||||||
|
url = f"{base}/chat/completions"
|
||||||
|
|
||||||
|
headers: Dict[str, str] = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
referer = (site_url or os.getenv("APP_URL") or "").strip()
|
||||||
|
if referer:
|
||||||
|
headers["HTTP-Referer"] = referer
|
||||||
|
title = (app_title or os.getenv("OPENROUTER_APP_TITLE") or "Shinkan Jinkendo").strip()
|
||||||
|
if title:
|
||||||
|
headers["X-Title"] = title
|
||||||
|
|
||||||
|
messages: List[Dict[str, str]] = []
|
||||||
|
if system_content and str(system_content).strip():
|
||||||
|
messages.append({"role": "system", "content": str(system_content).strip()})
|
||||||
|
messages.append({"role": "user", "content": user_content})
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout_sec) as client:
|
||||||
|
resp = client.post(url, headers=headers, json=payload)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise OpenRouterError(str(e)) from e
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
j = resp.json()
|
||||||
|
detail = (
|
||||||
|
str(j.get("error", {}).get("message"))
|
||||||
|
if isinstance(j.get("error"), dict)
|
||||||
|
else str(j.get("message") or j)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
detail = (resp.text or "")[:600]
|
||||||
|
raise OpenRouterError(f"HTTP {resp.status_code}: {detail}".strip())
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise OpenRouterError("Ungueltige JSON-Antwort von OpenRouter") from e
|
||||||
|
|
||||||
|
choices = data.get("choices") if isinstance(data, dict) else None
|
||||||
|
if not choices or not isinstance(choices, list):
|
||||||
|
raise OpenRouterError("OpenRouter: keine choices in Antwort")
|
||||||
|
|
||||||
|
msg0 = choices[0] if choices else {}
|
||||||
|
inner = msg0.get("message") if isinstance(msg0, dict) else None
|
||||||
|
|
||||||
|
blobs: List[Any] = []
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
if inner.get("content") is not None:
|
||||||
|
blobs.append(inner.get("content"))
|
||||||
|
if inner.get("refusal") is not None:
|
||||||
|
blobs.append(inner.get("refusal"))
|
||||||
|
elif isinstance(inner, str):
|
||||||
|
blobs.append(inner)
|
||||||
|
if isinstance(msg0, dict) and msg0.get("content") is not None and msg0.get("content") not in blobs:
|
||||||
|
blobs.append(msg0.get("content"))
|
||||||
|
|
||||||
|
pieces = [_flatten_message_content(b).strip() for b in blobs if b is not None]
|
||||||
|
joined = ("\n".join(p for p in pieces if p)).strip()
|
||||||
|
|
||||||
|
if _shinkan_ai_debug():
|
||||||
|
fr = str(msg0.get("finish_reason") or "") if isinstance(msg0, dict) else ""
|
||||||
|
fu = data.get("usage") if isinstance(data.get("usage"), dict) else {}
|
||||||
|
pu = str(fu.get("prompt_tokens") or "")
|
||||||
|
pc = str(fu.get("completion_tokens") or "")
|
||||||
|
pt = str(fu.get("total_tokens") or "")
|
||||||
|
raw_cls = type(blobs[0]).__name__ if blobs else "none"
|
||||||
|
cc = str(len(joined))
|
||||||
|
_logger.warning(
|
||||||
|
"[AI_DEBUG/openrouter] model=%s finish_reason=%s usage_prompt=%s usage_completion=%s usage_total=%s "
|
||||||
|
"raw_content_cls=%s out_chars=%s",
|
||||||
|
model,
|
||||||
|
fr,
|
||||||
|
pu,
|
||||||
|
pc,
|
||||||
|
pt,
|
||||||
|
raw_cls,
|
||||||
|
cc,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from planning_llm_usage import record_planning_llm_call
|
||||||
|
|
||||||
|
record_planning_llm_call(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return joined
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_openrouter_env() -> tuple[str, str]:
|
||||||
|
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
|
||||||
|
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
|
||||||
|
return key, model
|
||||||
|
|
||||||
|
|
||||||
|
def default_openrouter_model_id() -> str:
|
||||||
|
"""Standard-Modell aus OPENROUTER_MODEL (ohne API-Key zu pruefen)."""
|
||||||
|
_, model = normalize_openrouter_env()
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def effective_openrouter_model_for_prompt_row(row: Optional[Mapping[str, Any]]) -> str:
|
||||||
|
"""
|
||||||
|
Pro-Prompt-Override in ai_prompts.openrouter_model, sonst Env-Default.
|
||||||
|
|
||||||
|
`row` kann eine partial Row aus load_ai_prompt_row sein (Felder slug, openrouter_model, …).
|
||||||
|
"""
|
||||||
|
if row:
|
||||||
|
custom = str(row.get("openrouter_model") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return custom
|
||||||
|
return default_openrouter_model_id()
|
||||||
|
|
@ -33,8 +33,8 @@ def insert_password_reset_session(cur: Any, profile_id: int, *, hours_valid: int
|
||||||
expires = datetime.now() + timedelta(hours=hours_valid)
|
expires = datetime.now() + timedelta(hours=hours_valid)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sessions (token, profile_id, expires_at, created_at)
|
INSERT INTO sessions (token, profile_id, expires_at, created_at, purpose)
|
||||||
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
|
VALUES (%s, %s, %s, CURRENT_TIMESTAMP, 'password_reset')
|
||||||
""",
|
""",
|
||||||
(f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()),
|
(f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()),
|
||||||
)
|
)
|
||||||
|
|
@ -53,6 +53,7 @@ Neues Passwort setzen:
|
||||||
|
|
||||||
Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes
|
Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes
|
||||||
Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden.
|
Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden.
|
||||||
|
Der Link meldet dich nicht an und schaltet die Zwei-Faktor-Anmeldung nicht ab.
|
||||||
|
|
||||||
Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert.
|
Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert.
|
||||||
|
|
||||||
|
|
|
||||||
147
backend/planning_catalog_context.py
Normal file
147
backend/planning_catalog_context.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
"""
|
||||||
|
Katalog-Kontext für Progressionsgraph-Planung — Fokusbereich, Stil, Trainingsstil, Zielgruppe.
|
||||||
|
|
||||||
|
Explizite Trainer-Auswahl ergänzt Freitext/LLM; ersetzt kein Roadmap-Didaktik-Modell.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from planning_exercise_profiles import PlanningTargetProfile, _normalize_weight_map
|
||||||
|
from planning_exercise_target_pipeline import (
|
||||||
|
SCENARIO_FREE_SEARCH,
|
||||||
|
merge_query_overlay_into_target,
|
||||||
|
)
|
||||||
|
from planning_exercise_text_signals import resolve_planning_text_to_catalog_weights
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningCatalogContextItem(BaseModel):
|
||||||
|
id: int = Field(..., ge=1)
|
||||||
|
is_primary: bool = False
|
||||||
|
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressionPlanningCatalogContext(BaseModel):
|
||||||
|
focus_areas: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||||
|
style_directions: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||||
|
training_types: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||||
|
target_groups: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_context_has_items(catalog: Optional[ProgressionPlanningCatalogContext]) -> bool:
|
||||||
|
if catalog is None:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
catalog.focus_areas
|
||||||
|
or catalog.style_directions
|
||||||
|
or catalog.training_types
|
||||||
|
or catalog.target_groups
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_items_to_weight_map(
|
||||||
|
items: Sequence[PlanningCatalogContextItem],
|
||||||
|
*,
|
||||||
|
primary_weight: float = 0.95,
|
||||||
|
secondary_weight: float = 0.78,
|
||||||
|
) -> Dict[int, float]:
|
||||||
|
out: Dict[int, float] = {}
|
||||||
|
for item in items or []:
|
||||||
|
base = primary_weight if item.is_primary else secondary_weight
|
||||||
|
w = base * float(item.weight)
|
||||||
|
iid = int(item.id)
|
||||||
|
out[iid] = max(out.get(iid, 0.0), w)
|
||||||
|
return _normalize_weight_map(out) if out else out
|
||||||
|
|
||||||
|
|
||||||
|
def merge_catalog_context_into_target(
|
||||||
|
target: PlanningTargetProfile,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||||
|
*,
|
||||||
|
emphasis: str = "replace",
|
||||||
|
) -> PlanningTargetProfile:
|
||||||
|
"""Trainer-Katalog-Kontext ins Erwartungsprofil — beeinflusst Retrieval-Scoring."""
|
||||||
|
if not catalog_context_has_items(catalog):
|
||||||
|
return target
|
||||||
|
|
||||||
|
focus = catalog_items_to_weight_map(catalog.focus_areas)
|
||||||
|
style = catalog_items_to_weight_map(catalog.style_directions, primary_weight=0.9, secondary_weight=0.72)
|
||||||
|
tt = catalog_items_to_weight_map(catalog.training_types, primary_weight=0.9, secondary_weight=0.72)
|
||||||
|
tg = catalog_items_to_weight_map(catalog.target_groups, primary_weight=0.88, secondary_weight=0.7)
|
||||||
|
|
||||||
|
merged = merge_query_overlay_into_target(
|
||||||
|
target,
|
||||||
|
focus=focus,
|
||||||
|
style=style,
|
||||||
|
tt=tt,
|
||||||
|
tg=tg,
|
||||||
|
skills={},
|
||||||
|
emphasis=emphasis,
|
||||||
|
scenario=SCENARIO_FREE_SEARCH,
|
||||||
|
)
|
||||||
|
sources = list(merged.sources or [])
|
||||||
|
if "catalog_context" not in sources:
|
||||||
|
sources.append("catalog_context")
|
||||||
|
merged.sources = sources
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_target_from_planning_text_blobs(
|
||||||
|
cur,
|
||||||
|
target: PlanningTargetProfile,
|
||||||
|
*text_blobs: Optional[str],
|
||||||
|
) -> PlanningTargetProfile:
|
||||||
|
"""Additive Katalog-Signale aus Freitext (Anfrage, Start/Ziel, Notizen)."""
|
||||||
|
combined = " ".join(str(t or "").strip() for t in text_blobs if (t or "").strip())
|
||||||
|
if len(combined) < 4:
|
||||||
|
return target
|
||||||
|
focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(cur, combined)
|
||||||
|
if not (focus or style or tt or tg or skills):
|
||||||
|
return target
|
||||||
|
merged = merge_query_overlay_into_target(
|
||||||
|
target,
|
||||||
|
focus=focus,
|
||||||
|
style=style,
|
||||||
|
tt=tt,
|
||||||
|
tg=tg,
|
||||||
|
skills=skills,
|
||||||
|
emphasis="additive",
|
||||||
|
scenario=SCENARIO_FREE_SEARCH,
|
||||||
|
)
|
||||||
|
sources = list(merged.sources or [])
|
||||||
|
if "text_catalog_signals" not in sources:
|
||||||
|
sources.append("text_catalog_signals")
|
||||||
|
merged.sources = sources
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_context_from_mapping(raw: Any) -> Optional[ProgressionPlanningCatalogContext]:
|
||||||
|
if not raw or not isinstance(raw, Mapping):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ctx = ProgressionPlanningCatalogContext.model_validate(dict(raw))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return ctx if catalog_context_has_items(ctx) else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_catalog_context_from_graph_row(
|
||||||
|
planning_roadmap: Any,
|
||||||
|
) -> Optional[ProgressionPlanningCatalogContext]:
|
||||||
|
if not isinstance(planning_roadmap, dict):
|
||||||
|
return None
|
||||||
|
return catalog_context_from_mapping(planning_roadmap.get("planning_catalog_context"))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlanningCatalogContextItem",
|
||||||
|
"ProgressionPlanningCatalogContext",
|
||||||
|
"catalog_context_from_mapping",
|
||||||
|
"catalog_context_has_items",
|
||||||
|
"catalog_items_to_weight_map",
|
||||||
|
"enrich_target_from_planning_text_blobs",
|
||||||
|
"load_catalog_context_from_graph_row",
|
||||||
|
"merge_catalog_context_into_target",
|
||||||
|
]
|
||||||
16
backend/planning_catalog_prompt_snippets.py
Normal file
16
backend/planning_catalog_prompt_snippets.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""
|
||||||
|
Katalog-Prompt-Snippets — Abwärtskompatibilität (H1-Importpfade).
|
||||||
|
|
||||||
|
Implementierung: catalog_prompt_slots.py (H2).
|
||||||
|
"""
|
||||||
|
from catalog_prompt_slots import (
|
||||||
|
build_catalog_guidance_for_prompt,
|
||||||
|
get_rematch_guard_for_catalog,
|
||||||
|
pick_active_catalog_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_catalog_guidance_for_prompt",
|
||||||
|
"get_rematch_guard_for_catalog",
|
||||||
|
"pick_active_catalog_item",
|
||||||
|
]
|
||||||
69
backend/planning_exercise_expectation.py
Normal file
69
backend/planning_exercise_expectation.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""
|
||||||
|
Preset „Nächste aus Kontext“: LLM leitet Erwartungsprofil aus Planungskontext ab.
|
||||||
|
|
||||||
|
Prompt: planning_exercise_expectation_profile (Migration 074)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
from planning_exercise_intent import (
|
||||||
|
PlanningQueryIntentParsed,
|
||||||
|
_compact_json,
|
||||||
|
_load_compact_catalog,
|
||||||
|
_load_skills_catalog_compact,
|
||||||
|
parse_planning_query_intent_response,
|
||||||
|
)
|
||||||
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
|
from openrouter_chat import (
|
||||||
|
effective_openrouter_model_for_prompt_row,
|
||||||
|
normalize_openrouter_env,
|
||||||
|
openrouter_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_expectation")
|
||||||
|
|
||||||
|
|
||||||
|
def try_build_planning_expectation_from_context(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
heuristic_intent: str,
|
||||||
|
context_summary: Mapping[str, Any],
|
||||||
|
target_profile_summary: Mapping[str, Any],
|
||||||
|
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
||||||
|
"""
|
||||||
|
LLM-Erwartungsprofil für preset_next / leere Anfrage mit Planungsbezug.
|
||||||
|
Returns (parsed overlay, applied).
|
||||||
|
"""
|
||||||
|
api_key, _ = normalize_openrouter_env()
|
||||||
|
if not api_key:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"heuristic_intent": heuristic_intent or "suggest_next",
|
||||||
|
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||||
|
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||||
|
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||||
|
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||||
|
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||||
|
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||||
|
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_expectation_profile", variables)
|
||||||
|
model = effective_openrouter_model_for_prompt_row(prow)
|
||||||
|
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||||
|
parsed = parse_planning_query_intent_response(raw)
|
||||||
|
if parsed.scenario not in ("preset_next", "continue_plan", "free_search"):
|
||||||
|
parsed = parsed.model_copy(update={"scenario": "preset_next"})
|
||||||
|
return parsed, True
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
return None, False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Planungs-Erwartungsprofil-LLM fehlgeschlagen: %s", exc)
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["try_build_planning_expectation_from_context"]
|
||||||
395
backend/planning_exercise_form_context.py
Normal file
395
backend/planning_exercise_form_context.py
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
"""
|
||||||
|
Planungs-KI Phase D: strukturierter Planungskontext für POST /exercises/ai/suggest.
|
||||||
|
|
||||||
|
Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instructions) injiziert.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
_MAX_JSON_CHARS = 6000
|
||||||
|
_MAX_STRING = 800
|
||||||
|
|
||||||
|
|
||||||
|
def compact_planning_context_json(obj: Any) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_str(val: Any, *, limit: int = _MAX_STRING) -> Optional[str]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
s = str(val).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if len(s) > limit:
|
||||||
|
return s[: limit - 1] + "…"
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_planning_context_for_ai(ctx: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Reduziert Client-Payload auf prompt-taugliche, begrenzte Felder."""
|
||||||
|
if not ctx:
|
||||||
|
return {}
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
for key, val in dict(ctx).items():
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
k = str(key).strip()
|
||||||
|
if not k:
|
||||||
|
continue
|
||||||
|
if isinstance(val, str):
|
||||||
|
t = _trim_str(val)
|
||||||
|
if t:
|
||||||
|
out[k] = t
|
||||||
|
elif isinstance(val, (int, float, bool)):
|
||||||
|
out[k] = val
|
||||||
|
elif isinstance(val, list):
|
||||||
|
items = []
|
||||||
|
for item in val[:12]:
|
||||||
|
if isinstance(item, str):
|
||||||
|
t = _trim_str(item, limit=200)
|
||||||
|
if t:
|
||||||
|
items.append(t)
|
||||||
|
elif isinstance(item, (int, float, bool)):
|
||||||
|
items.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
sub = sanitize_planning_context_for_ai(item)
|
||||||
|
if sub:
|
||||||
|
items.append(sub)
|
||||||
|
if items:
|
||||||
|
out[k] = items
|
||||||
|
elif isinstance(val, dict):
|
||||||
|
sub = sanitize_planning_context_for_ai(val)
|
||||||
|
if sub:
|
||||||
|
out[k] = sub
|
||||||
|
raw = compact_planning_context_json(out)
|
||||||
|
if len(raw) > _MAX_JSON_CHARS:
|
||||||
|
out["truncated"] = True
|
||||||
|
out.pop("path_steps_preview", None)
|
||||||
|
raw = compact_planning_context_json(out)
|
||||||
|
if len(raw) > _MAX_JSON_CHARS:
|
||||||
|
return {"source": out.get("source"), "truncated": True, "goal_query": out.get("goal_query")}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def planning_context_prompt_variables(
|
||||||
|
planning_context: Optional[Mapping[str, Any]],
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
cleaned = sanitize_planning_context_for_ai(planning_context)
|
||||||
|
if not cleaned:
|
||||||
|
return {"planning_context_json": "-", "has_planning_context": ""}
|
||||||
|
return {
|
||||||
|
"planning_context_json": compact_planning_context_json(cleaned),
|
||||||
|
"has_planning_context": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _major_index_from_step(step: Mapping[str, Any]) -> Optional[int]:
|
||||||
|
for key in ("roadmap_major_step_index", "major_step_index"):
|
||||||
|
raw = step.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prior_path_steps_before_major(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
major_idx: int,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Pfadschritte mit kleinerem roadmap_major_step_index, sortiert."""
|
||||||
|
prior: List[Dict[str, Any]] = []
|
||||||
|
for step in steps:
|
||||||
|
mi = _major_index_from_step(step)
|
||||||
|
if mi is not None and mi < major_idx:
|
||||||
|
prior.append(dict(step))
|
||||||
|
prior.sort(key=lambda s: _major_index_from_step(s) or 0)
|
||||||
|
return prior
|
||||||
|
|
||||||
|
|
||||||
|
def _step_display_fields(step: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
title = _trim_str(
|
||||||
|
step.get("title") or step.get("exercise_title"),
|
||||||
|
limit=200,
|
||||||
|
)
|
||||||
|
learning_goal = _trim_str(
|
||||||
|
step.get("roadmap_learning_goal") or step.get("learning_goal"),
|
||||||
|
limit=500,
|
||||||
|
)
|
||||||
|
summary = _trim_str(step.get("summary"), limit=400)
|
||||||
|
start_state = _trim_str(step.get("roadmap_start_state") or step.get("start_state"))
|
||||||
|
target_state = _trim_str(step.get("roadmap_target_state") or step.get("target_state"))
|
||||||
|
phase = _trim_str(step.get("roadmap_phase") or step.get("phase"))
|
||||||
|
criteria_raw = step.get("stage_success_criteria") or step.get("success_criteria") or []
|
||||||
|
criteria = [
|
||||||
|
t
|
||||||
|
for x in criteria_raw
|
||||||
|
if (t := _trim_str(x, limit=200))
|
||||||
|
][:4]
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"title": title,
|
||||||
|
"learning_goal": learning_goal,
|
||||||
|
"summary": summary,
|
||||||
|
"start_state": start_state,
|
||||||
|
"target_state": target_state,
|
||||||
|
"phase": phase,
|
||||||
|
"success_criteria": criteria or None,
|
||||||
|
"major_step_index": _major_index_from_step(step),
|
||||||
|
}
|
||||||
|
return {k: v for k, v in out.items() if v is not None and v != "" and v != []}
|
||||||
|
|
||||||
|
|
||||||
|
def build_progression_entry_state(
|
||||||
|
*,
|
||||||
|
major_step_index: Optional[int] = None,
|
||||||
|
prior_steps: Sequence[Mapping[str, Any]] = (),
|
||||||
|
start_situation: Optional[str] = None,
|
||||||
|
current_stage_start: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Eingangszustand für eine Roadmap-Stufe: erreichte Voraussetzungen aus Vorstufen.
|
||||||
|
"""
|
||||||
|
prior_compact = [_step_display_fields(s) for s in prior_steps]
|
||||||
|
prior_compact = [
|
||||||
|
p
|
||||||
|
for p in prior_compact
|
||||||
|
if any(p.get(k) for k in ("title", "learning_goal", "summary", "success_criteria"))
|
||||||
|
]
|
||||||
|
|
||||||
|
achievements: List[str] = []
|
||||||
|
detail_lines: List[str] = []
|
||||||
|
for p in prior_compact:
|
||||||
|
if p.get("success_criteria"):
|
||||||
|
achievements.extend(p["success_criteria"])
|
||||||
|
elif p.get("learning_goal"):
|
||||||
|
achievements.append(p["learning_goal"])
|
||||||
|
|
||||||
|
label_parts: List[str] = []
|
||||||
|
if p.get("major_step_index") is not None:
|
||||||
|
label_parts.append(f"Stufe {int(p['major_step_index']) + 1}")
|
||||||
|
if p.get("phase"):
|
||||||
|
label_parts.append(f"({p['phase']})")
|
||||||
|
if p.get("title"):
|
||||||
|
label_parts.append(f"„{p['title']}\"")
|
||||||
|
prefix = " ".join(label_parts) if label_parts else "Vorstufe"
|
||||||
|
achieved = ""
|
||||||
|
if p.get("target_state"):
|
||||||
|
achieved = p["target_state"]
|
||||||
|
elif p.get("success_criteria"):
|
||||||
|
achieved = "; ".join(p["success_criteria"])
|
||||||
|
elif p.get("learning_goal"):
|
||||||
|
achieved = p["learning_goal"]
|
||||||
|
elif p.get("summary"):
|
||||||
|
achieved = p["summary"]
|
||||||
|
if achieved:
|
||||||
|
detail_lines.append(f"{prefix}: erreicht — {achieved}")
|
||||||
|
|
||||||
|
immediate_entry: Optional[str] = _trim_str(current_stage_start)
|
||||||
|
if not immediate_entry and prior_compact:
|
||||||
|
immediate = prior_compact[-1]
|
||||||
|
if immediate.get("target_state"):
|
||||||
|
immediate_entry = immediate["target_state"]
|
||||||
|
elif immediate.get("success_criteria"):
|
||||||
|
immediate_entry = "; ".join(immediate["success_criteria"])
|
||||||
|
elif immediate.get("learning_goal"):
|
||||||
|
immediate_entry = immediate["learning_goal"]
|
||||||
|
elif immediate.get("summary"):
|
||||||
|
immediate_entry = immediate["summary"]
|
||||||
|
elif not immediate_entry and start_situation:
|
||||||
|
immediate_entry = start_situation
|
||||||
|
|
||||||
|
entry_state = immediate_entry or start_situation
|
||||||
|
if prior_compact and start_situation and not immediate_entry:
|
||||||
|
detail_lines.insert(0, f"Ausgangsbasis Pfad: {start_situation}")
|
||||||
|
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
if entry_state:
|
||||||
|
out["entry_state"] = _trim_str(entry_state, limit=1200)
|
||||||
|
if detail_lines:
|
||||||
|
out["entry_state_detail"] = _trim_str("\n".join(detail_lines), limit=2000)
|
||||||
|
if prior_compact:
|
||||||
|
out["prior_steps"] = prior_compact[:6]
|
||||||
|
if achievements:
|
||||||
|
out["prior_achievements"] = list(dict.fromkeys(achievements))[:8]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_gap_snapshot_with_entry_state(
|
||||||
|
snapshot: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
major_step_index: Optional[int],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
snap = dict(snapshot)
|
||||||
|
if major_step_index is None:
|
||||||
|
return snap
|
||||||
|
try:
|
||||||
|
mi = int(major_step_index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return snap
|
||||||
|
prior = prior_path_steps_before_major(steps, mi)
|
||||||
|
entry = build_progression_entry_state(
|
||||||
|
major_step_index=mi,
|
||||||
|
prior_steps=prior,
|
||||||
|
start_situation=snap.get("start_situation"),
|
||||||
|
current_stage_start=snap.get("stage_start_state"),
|
||||||
|
)
|
||||||
|
snap.update(entry)
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
def build_progression_gap_snapshot(
|
||||||
|
*,
|
||||||
|
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||||
|
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||||
|
stage_spec: Optional[Mapping[str, Any]] = None,
|
||||||
|
semantic_brief: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Kompakter Roadmap-Kontext für Lücken-Übungen (Start, Ziel, Stufe, Fähigkeiten-Hinweise)."""
|
||||||
|
ga = dict(goal_analysis or {})
|
||||||
|
rs = dict(resolved_structured or {})
|
||||||
|
spec = dict(stage_spec or {})
|
||||||
|
brief = dict(semantic_brief or {})
|
||||||
|
|
||||||
|
start = _trim_str(rs.get("start_situation") or ga.get("start_assumption"))
|
||||||
|
target = _trim_str(rs.get("target_state") or ga.get("target_state"))
|
||||||
|
notes = _trim_str(rs.get("roadmap_notes"))
|
||||||
|
topic = _trim_str(ga.get("primary_topic") or brief.get("primary_topic"))
|
||||||
|
|
||||||
|
skill_hints: List[str] = []
|
||||||
|
for item in (brief.get("must_phrases") or [])[:4]:
|
||||||
|
t = _trim_str(item, limit=120)
|
||||||
|
if t:
|
||||||
|
skill_hints.append(t)
|
||||||
|
arc = brief.get("development_arc")
|
||||||
|
if isinstance(arc, list) and arc:
|
||||||
|
skill_hints.append(f"Entwicklungsbogen: {' → '.join(str(x) for x in arc[:5])}")
|
||||||
|
|
||||||
|
success_path = [
|
||||||
|
_trim_str(x, limit=200)
|
||||||
|
for x in (ga.get("success_criteria") or [])
|
||||||
|
if _trim_str(x, limit=200)
|
||||||
|
][:4]
|
||||||
|
stage_success = [
|
||||||
|
_trim_str(x, limit=200)
|
||||||
|
for x in (spec.get("success_criteria") or [])
|
||||||
|
if _trim_str(x, limit=200)
|
||||||
|
][:4]
|
||||||
|
load_profile = [
|
||||||
|
_trim_str(x, limit=80)
|
||||||
|
for x in (spec.get("load_profile") or [])
|
||||||
|
if _trim_str(x, limit=80)
|
||||||
|
][:6]
|
||||||
|
anti_patterns = [
|
||||||
|
_trim_str(x, limit=200)
|
||||||
|
for x in (spec.get("anti_patterns") or [])
|
||||||
|
if _trim_str(x, limit=200)
|
||||||
|
][:3]
|
||||||
|
|
||||||
|
snap: Dict[str, Any] = {
|
||||||
|
"primary_topic": topic,
|
||||||
|
"start_situation": start,
|
||||||
|
"target_state": target,
|
||||||
|
"roadmap_notes": notes,
|
||||||
|
"stage_learning_goal": _trim_str(
|
||||||
|
spec.get("learning_goal"), limit=1200
|
||||||
|
),
|
||||||
|
"stage_start_state": _trim_str(spec.get("start_state")),
|
||||||
|
"stage_target_state": _trim_str(spec.get("target_state")),
|
||||||
|
"stage_phase": _trim_str(spec.get("phase")),
|
||||||
|
"stage_exercise_type": _trim_str(spec.get("exercise_type")),
|
||||||
|
"stage_load_profile": load_profile or None,
|
||||||
|
"stage_success_criteria": stage_success or None,
|
||||||
|
"stage_anti_patterns": anti_patterns or None,
|
||||||
|
"path_success_criteria": success_path or None,
|
||||||
|
"skill_hints": skill_hints or None,
|
||||||
|
}
|
||||||
|
return {k: v for k, v in snap.items() if v is not None and v != "" and v != []}
|
||||||
|
|
||||||
|
|
||||||
|
def build_progression_path_gap_planning_context(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
primary_topic: Optional[str] = None,
|
||||||
|
progression_graph_id: Optional[int] = None,
|
||||||
|
offer: Optional[Mapping[str, Any]] = None,
|
||||||
|
neighbor_before: Optional[Mapping[str, Any]] = None,
|
||||||
|
neighbor_after: Optional[Mapping[str, Any]] = None,
|
||||||
|
prior_path_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
path_step_count: int = 0,
|
||||||
|
major_step_count: Optional[int] = None,
|
||||||
|
roadmap_phase: Optional[str] = None,
|
||||||
|
roadmap_learning_goal: Optional[str] = None,
|
||||||
|
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||||
|
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||||
|
stage_spec: Optional[Mapping[str, Any]] = None,
|
||||||
|
semantic_brief: Optional[Mapping[str, Any]] = None,
|
||||||
|
stage_learning_goal_override: Optional[str] = None,
|
||||||
|
gap_trainer_supplements: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
|
||||||
|
offer = offer or {}
|
||||||
|
gap = offer.get("gap") if isinstance(offer.get("gap"), dict) else {}
|
||||||
|
major_idx = offer.get("roadmap_major_step_index")
|
||||||
|
if major_idx is None and isinstance(gap, dict):
|
||||||
|
major_idx = gap.get("roadmap_major_step_index")
|
||||||
|
|
||||||
|
ctx: Dict[str, Any] = {
|
||||||
|
"source": "progression_path_gap_fill",
|
||||||
|
"goal_query": _trim_str(goal_query, limit=2000),
|
||||||
|
"primary_topic": _trim_str(primary_topic),
|
||||||
|
"progression_graph_id": progression_graph_id,
|
||||||
|
"gap_source": _trim_str(offer.get("source")),
|
||||||
|
"gap_phase": _trim_str(offer.get("phase") or gap.get("expected_phase")),
|
||||||
|
"roadmap_major_step_index": major_idx,
|
||||||
|
"roadmap_phase": _trim_str(roadmap_phase or offer.get("phase")),
|
||||||
|
"roadmap_learning_goal": _trim_str(
|
||||||
|
roadmap_learning_goal or offer.get("title_hint") or gap.get("learning_goal"),
|
||||||
|
limit=1200,
|
||||||
|
),
|
||||||
|
"neighbor_before_title": _trim_str(
|
||||||
|
(neighbor_before or {}).get("title") or offer.get("from_title")
|
||||||
|
),
|
||||||
|
"neighbor_after_title": _trim_str(
|
||||||
|
(neighbor_after or {}).get("title") or offer.get("to_title")
|
||||||
|
),
|
||||||
|
"path_step_count": path_step_count,
|
||||||
|
"major_step_count": major_step_count,
|
||||||
|
}
|
||||||
|
snap = build_progression_gap_snapshot(
|
||||||
|
goal_analysis=goal_analysis,
|
||||||
|
resolved_structured=resolved_structured,
|
||||||
|
stage_spec=stage_spec,
|
||||||
|
semantic_brief=semantic_brief,
|
||||||
|
)
|
||||||
|
ctx.update(snap)
|
||||||
|
if major_idx is not None and prior_path_steps:
|
||||||
|
ctx.update(
|
||||||
|
build_progression_entry_state(
|
||||||
|
major_step_index=major_idx,
|
||||||
|
prior_steps=list(prior_path_steps),
|
||||||
|
start_situation=ctx.get("start_situation"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if stage_learning_goal_override and stage_learning_goal_override.strip():
|
||||||
|
ctx["stage_learning_goal"] = _trim_str(stage_learning_goal_override, limit=1200)
|
||||||
|
ctx["roadmap_learning_goal"] = ctx["stage_learning_goal"]
|
||||||
|
if gap_trainer_supplements and gap_trainer_supplements.strip():
|
||||||
|
ctx["gap_trainer_supplements"] = _trim_str(gap_trainer_supplements, limit=2000)
|
||||||
|
return sanitize_planning_context_for_ai(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_progression_entry_state",
|
||||||
|
"build_progression_gap_snapshot",
|
||||||
|
"build_progression_path_gap_planning_context",
|
||||||
|
"enrich_gap_snapshot_with_entry_state",
|
||||||
|
"prior_path_steps_before_major",
|
||||||
|
"compact_planning_context_json",
|
||||||
|
"planning_context_prompt_variables",
|
||||||
|
"sanitize_planning_context_for_ai",
|
||||||
|
]
|
||||||
272
backend/planning_exercise_intent.py
Normal file
272
backend/planning_exercise_intent.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""
|
||||||
|
P1: LLM-Intent aus Planungs-Suchfrage → strukturiertes Query-Overlay für PlanningTargetProfile.
|
||||||
|
|
||||||
|
Prompt: planning_exercise_search_intent (Migration 073)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
|
from openrouter_chat import (
|
||||||
|
effective_openrouter_model_for_prompt_row,
|
||||||
|
normalize_openrouter_env,
|
||||||
|
openrouter_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_intent")
|
||||||
|
|
||||||
|
VALID_PARSED_INTENTS = {
|
||||||
|
"suggest_next",
|
||||||
|
"progression_next",
|
||||||
|
"deepen_exercise",
|
||||||
|
"continue_plan_goal",
|
||||||
|
"free_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_SCENARIOS = {
|
||||||
|
"preset_next",
|
||||||
|
"progression",
|
||||||
|
"deepen",
|
||||||
|
"continue_plan",
|
||||||
|
"additive_constraint",
|
||||||
|
"free_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_EMPHASIS = {"additive", "replace", "neutral"}
|
||||||
|
|
||||||
|
|
||||||
|
class SkillHint(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
|
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningQueryIntentParsed(BaseModel):
|
||||||
|
intent: str = "free_search"
|
||||||
|
scenario: str = "free_search"
|
||||||
|
skill_hints: List[SkillHint] = Field(default_factory=list)
|
||||||
|
focus_hints: List[str] = Field(default_factory=list)
|
||||||
|
style_hints: List[str] = Field(default_factory=list)
|
||||||
|
training_type_hints: List[str] = Field(default_factory=list)
|
||||||
|
target_group_hints: List[str] = Field(default_factory=list)
|
||||||
|
requires_partner: Optional[bool] = None
|
||||||
|
emphasis: str = "additive"
|
||||||
|
rationale: Optional[str] = Field(default=None, max_length=400)
|
||||||
|
|
||||||
|
@field_validator("intent")
|
||||||
|
@classmethod
|
||||||
|
def _intent(cls, v: str) -> str:
|
||||||
|
s = (v or "").strip().lower()
|
||||||
|
return s if s in VALID_PARSED_INTENTS else "free_search"
|
||||||
|
|
||||||
|
@field_validator("scenario")
|
||||||
|
@classmethod
|
||||||
|
def _scenario(cls, v: str) -> str:
|
||||||
|
s = (v or "").strip().lower()
|
||||||
|
return s if s in VALID_SCENARIOS else "free_search"
|
||||||
|
|
||||||
|
@field_validator("emphasis")
|
||||||
|
@classmethod
|
||||||
|
def _emphasis(cls, v: str) -> str:
|
||||||
|
s = (v or "").strip().lower()
|
||||||
|
return s if s in VALID_EMPHASIS else "additive"
|
||||||
|
|
||||||
|
@field_validator("focus_hints", "style_hints", "training_type_hints", "target_group_hints", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _str_list(cls, v: Any) -> List[str]:
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [v.strip()] if v.strip() else []
|
||||||
|
out: List[str] = []
|
||||||
|
for item in v:
|
||||||
|
s = str(item or "").strip()
|
||||||
|
if s and s not in out:
|
||||||
|
out.append(s[:120])
|
||||||
|
return out[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||||
|
s = (text or "").strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
|
if s.endswith("```"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
start = s.find("{")
|
||||||
|
end = s.rfind("}")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||||
|
obj = json.loads(s[start : end + 1])
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def parse_planning_query_intent_response(text: str) -> PlanningQueryIntentParsed:
|
||||||
|
obj = _extract_json_object(text)
|
||||||
|
return PlanningQueryIntentParsed.model_validate(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_json(obj: Any) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_compact_catalog(cur, table: str, id_col: str, name_col: str = "name", limit: int = 80) -> List[Dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {id_col} AS id, {name_col} AS name
|
||||||
|
FROM {table}
|
||||||
|
ORDER BY {name_col} ASC NULLS LAST
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [{"id": int(r["id"]), "name": str(r["name"] or "")[:80]} for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skills_catalog_compact(cur, limit: int = 120) -> List[Dict[str, Any]]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, category
|
||||||
|
FROM skills
|
||||||
|
WHERE status IS NULL OR status = 'active'
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(r["id"]),
|
||||||
|
"name": str(r["name"] or "")[:80],
|
||||||
|
"category": str(r.get("category") or "")[:40],
|
||||||
|
}
|
||||||
|
for r in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_name_hint(cur, table: str, hint: str, *, extra_where: str = "") -> Optional[int]:
|
||||||
|
h = (hint or "").strip()
|
||||||
|
if len(h) < 2:
|
||||||
|
return None
|
||||||
|
q = h.lower()
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, name
|
||||||
|
FROM {table}
|
||||||
|
WHERE LOWER(name) LIKE %s {extra_where}
|
||||||
|
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||||
|
LENGTH(name) ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(f"%{q}%", q, f"{q}%"),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row["id"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_query_intent_catalog_ids(
|
||||||
|
cur,
|
||||||
|
parsed: PlanningQueryIntentParsed,
|
||||||
|
) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Mappt Text-Hints auf Katalog-IDs. Returns (focus, style, tt, tg, skills, resolved_skills_meta).
|
||||||
|
"""
|
||||||
|
focus: Dict[int, float] = {}
|
||||||
|
style: Dict[int, float] = {}
|
||||||
|
tt: Dict[int, float] = {}
|
||||||
|
tg: Dict[int, float] = {}
|
||||||
|
skills: Dict[int, float] = {}
|
||||||
|
resolved_skills: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for hint in parsed.focus_hints:
|
||||||
|
fid = _resolve_name_hint(cur, "focus_areas", hint)
|
||||||
|
if fid:
|
||||||
|
focus[fid] = max(focus.get(fid, 0.0), 0.9)
|
||||||
|
|
||||||
|
for hint in parsed.style_hints:
|
||||||
|
sid = _resolve_name_hint(cur, "style_directions", hint)
|
||||||
|
if sid:
|
||||||
|
style[sid] = max(style.get(sid, 0.0), 0.85)
|
||||||
|
|
||||||
|
for hint in parsed.training_type_hints:
|
||||||
|
tid = _resolve_name_hint(cur, "training_types", hint)
|
||||||
|
if tid:
|
||||||
|
tt[tid] = max(tt.get(tid, 0.0), 0.85)
|
||||||
|
|
||||||
|
for hint in parsed.target_group_hints:
|
||||||
|
gid = _resolve_name_hint(cur, "target_groups", hint)
|
||||||
|
if gid:
|
||||||
|
tg[gid] = max(tg.get(gid, 0.0), 0.85)
|
||||||
|
|
||||||
|
for sh in parsed.skill_hints[:8]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name FROM skills
|
||||||
|
WHERE (status IS NULL OR status = 'active')
|
||||||
|
AND LOWER(name) LIKE %s
|
||||||
|
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||||
|
LENGTH(name) ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(f"%{sh.name.lower()}%", sh.name.lower(), f"{sh.name.lower()}%"),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
sid = int(row["id"])
|
||||||
|
skills[sid] = max(skills.get(sid, 0.0), float(sh.weight))
|
||||||
|
resolved_skills.append({"skill_id": sid, "name": str(row["name"] or sh.name), "weight": skills[sid]})
|
||||||
|
|
||||||
|
return focus, style, tt, tg, skills, resolved_skills
|
||||||
|
|
||||||
|
|
||||||
|
def try_parse_planning_query_intent(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
heuristic_intent: str,
|
||||||
|
scenario_hint: str,
|
||||||
|
context_summary: Mapping[str, Any],
|
||||||
|
target_profile_summary: Mapping[str, Any],
|
||||||
|
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
||||||
|
api_key, _ = normalize_openrouter_env()
|
||||||
|
if not api_key or not (query or "").strip():
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"search_query": (query or "").strip(),
|
||||||
|
"heuristic_intent": heuristic_intent or "",
|
||||||
|
"scenario_hint": scenario_hint or "",
|
||||||
|
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||||
|
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||||
|
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||||
|
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||||
|
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||||
|
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||||
|
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_intent", variables)
|
||||||
|
model = effective_openrouter_model_for_prompt_row(prow)
|
||||||
|
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||||
|
parsed = parse_planning_query_intent_response(raw)
|
||||||
|
return parsed, True
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
return None, False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Planungs-Intent-LLM fehlgeschlagen: %s", exc)
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlanningQueryIntentParsed",
|
||||||
|
"parse_planning_query_intent_response",
|
||||||
|
"resolve_query_intent_catalog_ids",
|
||||||
|
"try_parse_planning_query_intent",
|
||||||
|
]
|
||||||
223
backend/planning_exercise_llm_rank.py
Normal file
223
backend/planning_exercise_llm_rank.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""
|
||||||
|
Phase 2 Planungs-Übungssuche: LLM-Rerank über Hybrid-Kandidaten.
|
||||||
|
|
||||||
|
Prompt-Slug: planning_exercise_search_rank (Migration 072)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
|
from exercise_ai import strip_html_to_plain
|
||||||
|
from openrouter_chat import (
|
||||||
|
effective_openrouter_model_for_prompt_row,
|
||||||
|
normalize_openrouter_env,
|
||||||
|
openrouter_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_llm_rank")
|
||||||
|
|
||||||
|
_LLM_RERANK_POOL = 32
|
||||||
|
_MAX_GOAL_PLAIN = 480
|
||||||
|
_MAX_SUMMARY_PLAIN = 320
|
||||||
|
_MAX_REASON_LEN = 160
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_json(obj: Any) -> str:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||||
|
s = (text or "").strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
|
if s.endswith("```"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
start = s.find("{")
|
||||||
|
end = s.rfind("}")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||||
|
obj = json.loads(s[start : end + 1])
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def parse_planning_exercise_rank_response(
|
||||||
|
text: str,
|
||||||
|
allowed_ids: Set[int],
|
||||||
|
) -> Tuple[List[int], Dict[int, str]]:
|
||||||
|
"""
|
||||||
|
Validiert LLM-Ranking: nur erlaubte exercise_id, dedupliziert, Reihenfolge beibehalten.
|
||||||
|
"""
|
||||||
|
obj = _extract_json_object(text)
|
||||||
|
ranked_raw = obj.get("ranked_ids") or obj.get("ranked") or obj.get("ids")
|
||||||
|
if not isinstance(ranked_raw, list):
|
||||||
|
raise ValueError("ranked_ids fehlt oder ist keine Liste")
|
||||||
|
|
||||||
|
ranked: List[int] = []
|
||||||
|
seen: Set[int] = set()
|
||||||
|
for raw in ranked_raw:
|
||||||
|
try:
|
||||||
|
eid = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if eid < 1 or eid not in allowed_ids or eid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(eid)
|
||||||
|
ranked.append(eid)
|
||||||
|
|
||||||
|
reasons_out: Dict[int, str] = {}
|
||||||
|
reasons_raw = obj.get("reasons") or obj.get("reasons_by_id") or {}
|
||||||
|
if isinstance(reasons_raw, dict):
|
||||||
|
for k, v in reasons_raw.items():
|
||||||
|
try:
|
||||||
|
eid = int(k)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if eid not in allowed_ids:
|
||||||
|
continue
|
||||||
|
txt = str(v or "").strip()
|
||||||
|
if txt:
|
||||||
|
reasons_out[eid] = txt[:_MAX_REASON_LEN]
|
||||||
|
|
||||||
|
return ranked, reasons_out
|
||||||
|
|
||||||
|
|
||||||
|
def _build_candidate_payload(
|
||||||
|
hit: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
goal_plain: str,
|
||||||
|
skill_names: Sequence[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": int(hit["id"]),
|
||||||
|
"title": str(hit.get("title") or "").strip()[:200],
|
||||||
|
"summary": strip_html_to_plain(hit.get("summary"), max_len=_MAX_SUMMARY_PLAIN),
|
||||||
|
"goal": goal_plain,
|
||||||
|
"skills": list(skill_names)[:8],
|
||||||
|
"retrieval_score": float(hit.get("score") or 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_exercise_goals(cur, exercise_ids: Sequence[int]) -> Dict[int, str]:
|
||||||
|
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
ph = ",".join(["%s"] * len(ids))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id, goal FROM exercises WHERE id IN ({ph})",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
return {int(r["id"]): str(r.get("goal") or "") for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skill_names(cur, skill_ids: Sequence[int]) -> Dict[int, str]:
|
||||||
|
ids = sorted({int(x) for x in skill_ids if int(x) > 0})
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
ph = ",".join(["%s"] * len(ids))
|
||||||
|
cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", ids)
|
||||||
|
return {int(r["id"]): str(r.get("name") or "") for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def try_llm_rerank_planning_hits(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
hits: List[Dict[str, Any]],
|
||||||
|
skills_by_ex: Mapping[int, Set[int]],
|
||||||
|
query: str,
|
||||||
|
intent: str,
|
||||||
|
context_summary: Mapping[str, Any],
|
||||||
|
target_profile_summary: Mapping[str, Any],
|
||||||
|
limit: int,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||||
|
"""
|
||||||
|
Optionaler LLM-Rerank der Top-Kandidaten. Bei Fehler: Original-Reihenfolge, llm_applied=False.
|
||||||
|
"""
|
||||||
|
if not hits:
|
||||||
|
return hits, False
|
||||||
|
|
||||||
|
api_key, _ = normalize_openrouter_env()
|
||||||
|
if not api_key:
|
||||||
|
return hits, False
|
||||||
|
|
||||||
|
pool = hits[:_LLM_RERANK_POOL]
|
||||||
|
allowed_ids = {int(h["id"]) for h in pool}
|
||||||
|
goals = _load_exercise_goals(cur, list(allowed_ids))
|
||||||
|
|
||||||
|
all_skill_ids: Set[int] = set()
|
||||||
|
for eid in allowed_ids:
|
||||||
|
all_skill_ids.update(skills_by_ex.get(eid) or set())
|
||||||
|
skill_name_map = _load_skill_names(cur, list(all_skill_ids))
|
||||||
|
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
for hit in pool:
|
||||||
|
eid = int(hit["id"])
|
||||||
|
sk_ids = sorted(skills_by_ex.get(eid) or set())
|
||||||
|
sk_names = [skill_name_map.get(sid, f"#{sid}") for sid in sk_ids[:8]]
|
||||||
|
goal_plain = strip_html_to_plain(goals.get(eid), max_len=_MAX_GOAL_PLAIN)
|
||||||
|
candidates.append(
|
||||||
|
_build_candidate_payload(hit, goal_plain=goal_plain, skill_names=sk_names)
|
||||||
|
)
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"search_query": query or "",
|
||||||
|
"intent": intent or "",
|
||||||
|
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||||
|
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||||
|
"candidates_json": _compact_json(candidates),
|
||||||
|
"result_limit": str(max(1, min(int(limit), 50))),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_rank", variables)
|
||||||
|
model = effective_openrouter_model_for_prompt_row(prow)
|
||||||
|
raw = openrouter_chat_completion(
|
||||||
|
api_key=api_key,
|
||||||
|
model=model,
|
||||||
|
user_content=rendered.text,
|
||||||
|
)
|
||||||
|
ranked_ids, llm_reasons = parse_planning_exercise_rank_response(raw, allowed_ids)
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
return hits, False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Planungs-LLM-Rerank fehlgeschlagen: %s", exc)
|
||||||
|
return hits, False
|
||||||
|
|
||||||
|
if not ranked_ids:
|
||||||
|
return hits, False
|
||||||
|
|
||||||
|
hit_by_id = {int(h["id"]): h for h in hits}
|
||||||
|
reranked: List[Dict[str, Any]] = []
|
||||||
|
used: Set[int] = set()
|
||||||
|
for eid in ranked_ids:
|
||||||
|
hit = hit_by_id.get(eid)
|
||||||
|
if not hit:
|
||||||
|
continue
|
||||||
|
used.add(eid)
|
||||||
|
new_hit = dict(hit)
|
||||||
|
reasons = list(hit.get("reasons") or [])
|
||||||
|
llm_reason = llm_reasons.get(eid)
|
||||||
|
if llm_reason and llm_reason not in reasons:
|
||||||
|
reasons.insert(0, llm_reason)
|
||||||
|
new_hit["reasons"] = reasons
|
||||||
|
new_hit["llm_rank"] = len(reranked) + 1
|
||||||
|
reranked.append(new_hit)
|
||||||
|
|
||||||
|
for hit in hits:
|
||||||
|
eid = int(hit["id"])
|
||||||
|
if eid in used:
|
||||||
|
continue
|
||||||
|
reranked.append(dict(hit))
|
||||||
|
|
||||||
|
return reranked[: max(int(limit), len(reranked))], True
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"parse_planning_exercise_rank_response",
|
||||||
|
"try_llm_rerank_planning_hits",
|
||||||
|
]
|
||||||
818
backend/planning_exercise_path_ai_fill.py
Normal file
818
backend/planning_exercise_path_ai_fill.py
Normal file
|
|
@ -0,0 +1,818 @@
|
||||||
|
"""
|
||||||
|
Planungs-KI Phase E2/E3: KI-Neuanlage für Pfad-Lücken + strukturierte Angebote für die UI.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||||
|
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||||
|
from exercise_ai import strip_html_to_plain
|
||||||
|
|
||||||
|
from planning_exercise_path_qa import find_step_pair_index
|
||||||
|
from planning_exercise_form_context import (
|
||||||
|
build_progression_entry_state,
|
||||||
|
build_progression_gap_snapshot,
|
||||||
|
enrich_gap_snapshot_with_entry_state,
|
||||||
|
prior_path_steps_before_major,
|
||||||
|
)
|
||||||
|
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
|
||||||
|
from planning_catalog_context import ProgressionPlanningCatalogContext
|
||||||
|
from planning_prompt_variables import catalog_exercise_hints_block
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_neighbor_steps_by_major_index(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
major_idx: int,
|
||||||
|
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
|
||||||
|
"""Nachbarn im Pfad anhand roadmap_major_step_index (nicht Array-Position)."""
|
||||||
|
step_before: Optional[Mapping[str, Any]] = None
|
||||||
|
step_after: Optional[Mapping[str, Any]] = None
|
||||||
|
for step in steps:
|
||||||
|
raw = step.get("roadmap_major_step_index")
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mi = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if mi < major_idx:
|
||||||
|
step_before = step
|
||||||
|
elif mi > major_idx and step_after is None:
|
||||||
|
step_after = step
|
||||||
|
return step_before, step_after
|
||||||
|
|
||||||
|
|
||||||
|
def _build_stage_ai_context(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
step_before: Optional[Mapping[str, Any]] = None,
|
||||||
|
step_after: Optional[Mapping[str, Any]] = None,
|
||||||
|
prior_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
start_situation: Optional[str] = None,
|
||||||
|
catalog_exercise_hints: Optional[str] = None,
|
||||||
|
) -> ExerciseFormAiPromptContext:
|
||||||
|
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
|
||||||
|
gap = dict(spec.get("gap") or {})
|
||||||
|
phase = spec.get("phase") or gap.get("expected_phase") or "vertiefung"
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
learning_goal = (
|
||||||
|
gap.get("learning_goal")
|
||||||
|
or spec.get("title_hint")
|
||||||
|
or spec.get("sketch")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
title = (spec.get("title_hint") or f"{topic} — {phase}").strip()[:280]
|
||||||
|
major_idx = spec.get("roadmap_major_step_index")
|
||||||
|
entry: Dict[str, Any] = {}
|
||||||
|
if prior_steps is not None and major_idx is not None:
|
||||||
|
entry = build_progression_entry_state(
|
||||||
|
major_step_index=major_idx,
|
||||||
|
prior_steps=prior_steps,
|
||||||
|
start_situation=start_situation,
|
||||||
|
)
|
||||||
|
|
||||||
|
goal_parts = [
|
||||||
|
f"Planungsziel: {goal_query}",
|
||||||
|
f"Roadmap-Stufe ({phase}): {learning_goal}",
|
||||||
|
"Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.",
|
||||||
|
]
|
||||||
|
if entry.get("entry_state"):
|
||||||
|
goal_parts.append(
|
||||||
|
f"Eingangszustand (erreichte Voraussetzungen): {entry['entry_state']}"
|
||||||
|
)
|
||||||
|
if entry.get("entry_state_detail") and entry.get("entry_state_detail") != entry.get("entry_state"):
|
||||||
|
goal_parts.append(f"Bisheriger Pfad:\n{entry['entry_state_detail']}")
|
||||||
|
if step_before:
|
||||||
|
goal_parts.append(
|
||||||
|
f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}“"
|
||||||
|
)
|
||||||
|
if step_after:
|
||||||
|
goal_parts.append(
|
||||||
|
f"Nächste Stufe im Pfad: „{(step_after.get('title') or '').strip()}“"
|
||||||
|
)
|
||||||
|
sketch = (spec.get("sketch") or "").strip()
|
||||||
|
if sketch and sketch != learning_goal:
|
||||||
|
goal_parts.extend(["", f"Kontext: {sketch}"])
|
||||||
|
hints = (catalog_exercise_hints or "").strip()
|
||||||
|
if hints:
|
||||||
|
goal_parts.extend(["", "Katalog-Hinweise Übungsanlage:", hints])
|
||||||
|
goal = "\n".join(goal_parts)
|
||||||
|
|
||||||
|
focus_hint = topic if brief.topic_type == "technique" else None
|
||||||
|
if brief.must_phrases:
|
||||||
|
focus_hint = ", ".join(brief.must_phrases[:2])
|
||||||
|
|
||||||
|
return ExerciseFormAiPromptContext(
|
||||||
|
title=title[:280],
|
||||||
|
goal=goal[:8000],
|
||||||
|
execution=None,
|
||||||
|
focus_hint=focus_hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def try_suggest_ai_stage_step(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""KI-Vorschlag für leere Roadmap-Stufe."""
|
||||||
|
major_idx = spec.get("roadmap_major_step_index")
|
||||||
|
if major_idx is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
mi = int(major_idx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi)
|
||||||
|
prior_steps = prior_path_steps_before_major(steps, mi)
|
||||||
|
gap = dict(spec.get("gap") or {})
|
||||||
|
if not gap.get("expected_phase"):
|
||||||
|
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
||||||
|
gap["roadmap_major_step_index"] = mi
|
||||||
|
if not gap.get("learning_goal"):
|
||||||
|
gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
|
||||||
|
|
||||||
|
exercise_hints = catalog_exercise_hints_block(cur, catalog) if catalog else ""
|
||||||
|
|
||||||
|
ctx = _build_stage_ai_context(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
spec=spec,
|
||||||
|
step_before=step_before,
|
||||||
|
step_after=step_after,
|
||||||
|
prior_steps=prior_steps,
|
||||||
|
catalog_exercise_hints=exercise_hints or None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("roadmap_unfilled AI suggest failed")
|
||||||
|
return None
|
||||||
|
if not ai_payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
summary_text = ""
|
||||||
|
summary_obj = ai_payload.get("summary")
|
||||||
|
if isinstance(summary_obj, dict):
|
||||||
|
summary_text = str(summary_obj.get("text") or "").strip()
|
||||||
|
elif isinstance(summary_obj, str):
|
||||||
|
summary_text = summary_obj.strip()
|
||||||
|
|
||||||
|
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
|
||||||
|
title = (ctx.title or spec.get("title_hint") or "KI-Vorschlag").strip()
|
||||||
|
return {
|
||||||
|
"exercise_id": None,
|
||||||
|
"proposal_key": proposal_key,
|
||||||
|
"variant_id": None,
|
||||||
|
"title": title,
|
||||||
|
"summary": summary_text or None,
|
||||||
|
"score": None,
|
||||||
|
"semantic_score": None,
|
||||||
|
"reasons": ["KI-Neuanlage für Roadmap-Stufe ohne Bibliothekstreffer"],
|
||||||
|
"variants": [],
|
||||||
|
"is_bridge": False,
|
||||||
|
"is_ai_proposal": True,
|
||||||
|
"ai_suggestion": dict(ai_payload),
|
||||||
|
"roadmap_major_step_index": mi,
|
||||||
|
"roadmap_phase": gap.get("expected_phase"),
|
||||||
|
"roadmap_learning_goal": gap.get("learning_goal"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_gap_ai_context(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
step_a: Mapping[str, Any],
|
||||||
|
step_b: Mapping[str, Any],
|
||||||
|
gap: Mapping[str, Any],
|
||||||
|
title_hint: Optional[str] = None,
|
||||||
|
sketch_hint: Optional[str] = None,
|
||||||
|
catalog_exercise_hints: Optional[str] = None,
|
||||||
|
) -> ExerciseFormAiPromptContext:
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
phase = gap.get("expected_phase") or "vertiefung"
|
||||||
|
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
|
||||||
|
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
|
||||||
|
|
||||||
|
title = (title_hint or f"Brücke {topic} ({phase})").strip()[:280]
|
||||||
|
sketch = (sketch_hint or "").strip()
|
||||||
|
goal_parts = [
|
||||||
|
f"Planungsziel: {goal_query}",
|
||||||
|
"",
|
||||||
|
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.",
|
||||||
|
f"Phase: {phase}. Thema: {topic}.",
|
||||||
|
"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor.",
|
||||||
|
]
|
||||||
|
if sketch:
|
||||||
|
goal_parts.extend(["", f"Hinweis: {sketch}"])
|
||||||
|
hints = (catalog_exercise_hints or "").strip()
|
||||||
|
if hints:
|
||||||
|
goal_parts.extend(["", "Katalog-Hinweise Übungsanlage:", hints])
|
||||||
|
goal = "\n".join(goal_parts)
|
||||||
|
|
||||||
|
focus_hint = topic if brief.topic_type == "technique" else None
|
||||||
|
if brief.must_phrases:
|
||||||
|
focus_hint = ", ".join(brief.must_phrases[:2])
|
||||||
|
|
||||||
|
return ExerciseFormAiPromptContext(
|
||||||
|
title=title[:280],
|
||||||
|
goal=goal[:8000],
|
||||||
|
execution=None,
|
||||||
|
focus_hint=focus_hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ai_proposal_to_path_step(
|
||||||
|
*,
|
||||||
|
ai_payload: Mapping[str, Any],
|
||||||
|
ctx_title: str,
|
||||||
|
gap: Mapping[str, Any],
|
||||||
|
step_a: Mapping[str, Any],
|
||||||
|
step_b: Mapping[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
summary_text = ""
|
||||||
|
summary_obj = ai_payload.get("summary")
|
||||||
|
if isinstance(summary_obj, dict):
|
||||||
|
summary_text = str(summary_obj.get("text") or "").strip()
|
||||||
|
elif isinstance(summary_obj, str):
|
||||||
|
summary_text = summary_obj.strip()
|
||||||
|
|
||||||
|
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
|
||||||
|
title = (ctx_title or "").strip() or "KI-Vorschlag (Brücke)"
|
||||||
|
reasons = ["KI-Neuanlage-Vorschlag — Lücke ohne passende Bibliotheks-Übung"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"exercise_id": None,
|
||||||
|
"proposal_key": proposal_key,
|
||||||
|
"variant_id": None,
|
||||||
|
"title": title,
|
||||||
|
"summary": summary_text or None,
|
||||||
|
"score": None,
|
||||||
|
"semantic_score": None,
|
||||||
|
"reasons": reasons,
|
||||||
|
"variants": [],
|
||||||
|
"is_bridge": True,
|
||||||
|
"is_ai_proposal": True,
|
||||||
|
"ai_suggestion": dict(ai_payload),
|
||||||
|
"bridge_for_gap": {
|
||||||
|
"from_exercise_id": step_a.get("exercise_id"),
|
||||||
|
"to_exercise_id": step_b.get("exercise_id"),
|
||||||
|
"gap_score": gap.get("gap_score"),
|
||||||
|
"expected_phase": gap.get("expected_phase"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def try_suggest_ai_bridge_step(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
step_a: Mapping[str, Any],
|
||||||
|
step_b: Mapping[str, Any],
|
||||||
|
gap: Mapping[str, Any],
|
||||||
|
title_hint: Optional[str] = None,
|
||||||
|
sketch_hint: Optional[str] = None,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
|
||||||
|
exercise_hints = catalog_exercise_hints_block(cur, catalog) if catalog else ""
|
||||||
|
ctx = _build_gap_ai_context(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
gap=gap,
|
||||||
|
title_hint=title_hint,
|
||||||
|
sketch_hint=sketch_hint,
|
||||||
|
catalog_exercise_hints=exercise_hints or None,
|
||||||
|
)
|
||||||
|
g_plain = strip_html_to_plain(ctx.goal)
|
||||||
|
if not g_plain.strip() and not (ctx.title or "").strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = run_exercise_form_ai_suggestion(
|
||||||
|
cur,
|
||||||
|
ctx,
|
||||||
|
want_summary=True,
|
||||||
|
want_skills=True,
|
||||||
|
want_instructions=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("KI-Lückenfüller fehlgeschlagen: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
return ai_proposal_to_path_step(
|
||||||
|
ai_payload=payload,
|
||||||
|
ctx_title=ctx.title or "",
|
||||||
|
gap=gap,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_sketch(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
step_a: Optional[Mapping[str, Any]],
|
||||||
|
step_b: Optional[Mapping[str, Any]],
|
||||||
|
phase: str,
|
||||||
|
rationale: str = "",
|
||||||
|
) -> str:
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
from_t = (step_a or {}).get("title") or "vorherigem Schritt"
|
||||||
|
to_t = (step_b or {}).get("title") or "nächstem Schritt"
|
||||||
|
parts = [
|
||||||
|
f"Planungsziel: {goal_query}",
|
||||||
|
f"Zwischenschritt für {topic} ({phase}) zwischen „{from_t}“ und „{to_t}“.",
|
||||||
|
]
|
||||||
|
if rationale:
|
||||||
|
parts.append(rationale)
|
||||||
|
return " ".join(parts)[:1200]
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
|
||||||
|
return (
|
||||||
|
spec.get("source"),
|
||||||
|
int(spec.get("insert_after_index") or 0),
|
||||||
|
str(spec.get("title_hint") or "")[:48],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _step_neighbors_at_index(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
idx: int,
|
||||||
|
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
|
||||||
|
"""Vorheriger/nächster Pfadschritt ohne IndexError (Rand-Slots, leere Stufen)."""
|
||||||
|
if idx < 0 or idx >= len(steps):
|
||||||
|
return None, None
|
||||||
|
step_a = steps[idx - 1] if idx > 0 else None
|
||||||
|
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
||||||
|
return step_a, step_b
|
||||||
|
|
||||||
|
|
||||||
|
def collect_gap_fill_specs(
|
||||||
|
*,
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
unfilled_gaps: Sequence[Mapping[str, Any]],
|
||||||
|
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||||
|
llm_specs: Sequence[Mapping[str, Any]],
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
goal_query: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Sammelt alle Lücken, für die ein KI-Anlege-Angebot sinnvoll ist."""
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
specs: List[Dict[str, Any]] = []
|
||||||
|
seen: set = set()
|
||||||
|
|
||||||
|
def add(spec: Dict[str, Any]) -> None:
|
||||||
|
key = _spec_dedupe_key(spec)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
specs.append(spec)
|
||||||
|
|
||||||
|
for gap in unfilled_gaps:
|
||||||
|
idx = find_step_pair_index(
|
||||||
|
steps,
|
||||||
|
int(gap["from_exercise_id"]),
|
||||||
|
int(gap["to_exercise_id"]),
|
||||||
|
)
|
||||||
|
if idx is None or idx + 1 >= len(steps):
|
||||||
|
continue
|
||||||
|
step_a = steps[idx]
|
||||||
|
step_b = steps[idx + 1]
|
||||||
|
phase = gap.get("expected_phase") or "vertiefung"
|
||||||
|
add(
|
||||||
|
{
|
||||||
|
"source": "unfilled_gap",
|
||||||
|
"insert_after_index": idx,
|
||||||
|
"gap": dict(gap),
|
||||||
|
"phase": phase,
|
||||||
|
"title_hint": f"{topic} — {phase}",
|
||||||
|
"sketch": _default_sketch(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
phase=str(phase),
|
||||||
|
rationale="Bibliothek enthält keine passende Brücke.",
|
||||||
|
),
|
||||||
|
"rationale": "Lücke zwischen benachbaren Schritten — keine passende Bibliotheks-Übung.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for ot in off_topic_steps:
|
||||||
|
major_idx = ot.get("roadmap_major_step_index")
|
||||||
|
idx: Optional[int] = None
|
||||||
|
if major_idx is not None:
|
||||||
|
try:
|
||||||
|
mi = int(major_idx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
mi = None
|
||||||
|
if mi is not None:
|
||||||
|
idx = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, s in enumerate(steps)
|
||||||
|
if s.get("roadmap_major_step_index") is not None
|
||||||
|
and int(s["roadmap_major_step_index"]) == mi
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if idx is None:
|
||||||
|
idx = int(ot.get("step_index") or 0)
|
||||||
|
if idx < 0 or idx >= len(steps):
|
||||||
|
continue
|
||||||
|
step_a, step_b = _step_neighbors_at_index(steps, idx)
|
||||||
|
phase = ot.get("expected_phase") or "vertiefung"
|
||||||
|
insert_after = max(idx - 1, -1)
|
||||||
|
stage_goal = str(ot.get("roadmap_learning_goal") or "").strip()
|
||||||
|
if str(ot.get("issue") or "") == "stage_mismatch" and stage_goal:
|
||||||
|
title_hint = stage_goal[:120]
|
||||||
|
rationale = (
|
||||||
|
f"Keine passende Bibliotheks-Übung für Stufen-Lernziel „{stage_goal[:100]}“."
|
||||||
|
)
|
||||||
|
sketch_rationale = (
|
||||||
|
f"Slot braucht Übung passend zu: {stage_goal[:200]}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
title_hint = f"{topic} — {phase} (Ersatz für themenfremden Schritt)"
|
||||||
|
rationale = f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema."
|
||||||
|
sketch_rationale = f"Ersetzt themenfremden Schritt „{ot.get('title')}“."
|
||||||
|
add(
|
||||||
|
{
|
||||||
|
"source": "off_topic" if ot.get("issue") != "stage_mismatch" else "stage_mismatch",
|
||||||
|
"insert_after_index": insert_after,
|
||||||
|
"replace_step_index": idx,
|
||||||
|
"roadmap_major_step_index": major_idx,
|
||||||
|
"gap": {
|
||||||
|
"expected_phase": phase,
|
||||||
|
"off_topic_title": ot.get("title"),
|
||||||
|
"off_topic_exercise_id": ot.get("exercise_id"),
|
||||||
|
"roadmap_learning_goal": stage_goal or None,
|
||||||
|
},
|
||||||
|
"phase": phase,
|
||||||
|
"title_hint": title_hint,
|
||||||
|
"sketch": _default_sketch(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
phase=str(phase),
|
||||||
|
rationale=sketch_rationale,
|
||||||
|
),
|
||||||
|
"rationale": rationale,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for spec in llm_specs:
|
||||||
|
add(dict(spec))
|
||||||
|
|
||||||
|
return specs[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def build_gap_fill_goal_text(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
step_a: Optional[Mapping[str, Any]] = None,
|
||||||
|
step_b: Optional[Mapping[str, Any]] = None,
|
||||||
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||||
|
catalog_exercise_hints: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext."""
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
phase = spec.get("phase") or "vertiefung"
|
||||||
|
from_title = (step_a or {}).get("title") or spec.get("from_title") or "vorherigem Schritt"
|
||||||
|
to_title = (step_b or {}).get("title") or spec.get("to_title") or "nächstem Schritt"
|
||||||
|
arc = ", ".join(brief.development_arc or []) or "einstieg → grundlage → vertiefung → anwendung → perfektion"
|
||||||
|
snap = dict(roadmap_snapshot or {})
|
||||||
|
if not snap:
|
||||||
|
snap = build_progression_gap_snapshot(semantic_brief=brief_to_summary_dict(brief))
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f"Planungsziel (gesamter Pfad): {goal_query}",
|
||||||
|
f"Hauptthema: {snap.get('primary_topic') or topic}",
|
||||||
|
]
|
||||||
|
if snap.get("entry_state"):
|
||||||
|
parts.append(
|
||||||
|
f"Eingangszustand (erreichte Voraussetzungen aus Vorstufen): {snap['entry_state']}"
|
||||||
|
)
|
||||||
|
if snap.get("entry_state_detail") and snap.get("entry_state_detail") != snap.get("entry_state"):
|
||||||
|
parts.append(f"Bisheriger Pfad:\n{snap['entry_state_detail']}")
|
||||||
|
if snap.get("start_situation") and not snap.get("entry_state"):
|
||||||
|
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
|
||||||
|
elif snap.get("start_situation") and snap.get("prior_steps"):
|
||||||
|
parts.append(f"Ausgangsbasis des gesamten Pfads: {snap['start_situation']}")
|
||||||
|
if snap.get("target_state"):
|
||||||
|
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
|
||||||
|
if snap.get("roadmap_notes"):
|
||||||
|
parts.append(f"Ergänzender Kontext: {snap['roadmap_notes']}")
|
||||||
|
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
|
||||||
|
if stage_goal:
|
||||||
|
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
|
||||||
|
parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}")
|
||||||
|
parts.append(f"Erwarteter Entwicklungsbogen: {arc}")
|
||||||
|
if spec.get("source") == "roadmap_unfilled":
|
||||||
|
parts.append(
|
||||||
|
"Einordnung: Übung für diese Roadmap-Stufe — das Stufen-Lernziel steht im Vordergrund."
|
||||||
|
)
|
||||||
|
if step_a:
|
||||||
|
parts.append(f"Vorherige Stufe: „{from_title}“")
|
||||||
|
if step_b:
|
||||||
|
parts.append(f"Nächste Stufe: „{to_title}“")
|
||||||
|
else:
|
||||||
|
parts.append(
|
||||||
|
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“."
|
||||||
|
)
|
||||||
|
if snap.get("stage_load_profile"):
|
||||||
|
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
|
||||||
|
if snap.get("stage_success_criteria"):
|
||||||
|
parts.append(
|
||||||
|
"Erfolgskriterien dieser Stufe: "
|
||||||
|
+ "; ".join(str(x) for x in snap["stage_success_criteria"][:4])
|
||||||
|
)
|
||||||
|
if snap.get("stage_anti_patterns"):
|
||||||
|
parts.append(
|
||||||
|
"Vermeiden: " + "; ".join(str(x) for x in snap["stage_anti_patterns"][:3])
|
||||||
|
)
|
||||||
|
if snap.get("skill_hints"):
|
||||||
|
parts.append(
|
||||||
|
"Fähigkeiten-/Fokus-Hinweise: "
|
||||||
|
+ "; ".join(str(x) for x in snap["skill_hints"][:4])
|
||||||
|
)
|
||||||
|
expected = snap.get("expected_skills") or []
|
||||||
|
if expected:
|
||||||
|
names = [
|
||||||
|
str(s.get("skill_name") or "").strip()
|
||||||
|
for s in expected[:5]
|
||||||
|
if str(s.get("skill_name") or "").strip()
|
||||||
|
]
|
||||||
|
if names:
|
||||||
|
parts.append(
|
||||||
|
"Erwartete Fähigkeiten (Scoring): " + ", ".join(names)
|
||||||
|
)
|
||||||
|
if spec.get("rationale"):
|
||||||
|
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
|
||||||
|
if spec.get("sketch"):
|
||||||
|
parts.append(f"Skizze: {spec['sketch']}")
|
||||||
|
hints = (catalog_exercise_hints or "").strip()
|
||||||
|
if hints:
|
||||||
|
parts.append(f"Katalog-Hinweise Übungsanlage:\n{hints}")
|
||||||
|
parts.append(
|
||||||
|
"Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, "
|
||||||
|
"Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. "
|
||||||
|
"Konkrete Durchführung, Ziel und Trainerhinweise ausformulieren."
|
||||||
|
)
|
||||||
|
return "\n\n".join(parts)[:8000]
|
||||||
|
|
||||||
|
|
||||||
|
def build_gap_fill_offer(
|
||||||
|
*,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
goal_query: str = "",
|
||||||
|
brief: Optional[PlanningSemanticBrief] = None,
|
||||||
|
proposal: Optional[Mapping[str, Any]] = None,
|
||||||
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||||
|
catalog_exercise_hints: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
source = spec.get("source")
|
||||||
|
idx = int(spec.get("insert_after_index") or 0)
|
||||||
|
major_idx = spec.get("roadmap_major_step_index")
|
||||||
|
if source == "roadmap_unfilled" and major_idx is not None:
|
||||||
|
try:
|
||||||
|
mi = int(major_idx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
mi = idx
|
||||||
|
step_a, step_b = _resolve_neighbor_steps_by_major_index(steps, mi)
|
||||||
|
idx = mi
|
||||||
|
else:
|
||||||
|
step_a = steps[idx] if idx < len(steps) else None
|
||||||
|
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
||||||
|
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||||
|
enriched_snapshot = dict(roadmap_snapshot) if roadmap_snapshot else {}
|
||||||
|
major_raw = spec.get("roadmap_major_step_index")
|
||||||
|
if major_raw is not None:
|
||||||
|
enriched_snapshot = enrich_gap_snapshot_with_entry_state(
|
||||||
|
enriched_snapshot,
|
||||||
|
steps=steps,
|
||||||
|
major_step_index=major_raw,
|
||||||
|
)
|
||||||
|
goal_for_ai = ""
|
||||||
|
if brief and goal_query:
|
||||||
|
goal_for_ai = build_gap_fill_goal_text(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
spec=spec,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
roadmap_snapshot=enriched_snapshot or None,
|
||||||
|
catalog_exercise_hints=catalog_exercise_hints,
|
||||||
|
)
|
||||||
|
ctx_preview = enriched_snapshot or None
|
||||||
|
offer: Dict[str, Any] = {
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"source": spec.get("source"),
|
||||||
|
"insert_after_index": idx,
|
||||||
|
"replace_step_index": spec.get("replace_step_index"),
|
||||||
|
"title_hint": spec.get("title_hint"),
|
||||||
|
"sketch": spec.get("sketch"),
|
||||||
|
"goal_for_ai": goal_for_ai or spec.get("sketch"),
|
||||||
|
"context_preview": ctx_preview,
|
||||||
|
"phase": spec.get("phase"),
|
||||||
|
"rationale": spec.get("rationale"),
|
||||||
|
"has_ai_payload": False,
|
||||||
|
"from_title": (step_a or {}).get("title"),
|
||||||
|
"to_title": (step_b or {}).get("title"),
|
||||||
|
"primary_topic": (brief.primary_topic if brief else None),
|
||||||
|
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
||||||
|
}
|
||||||
|
if proposal:
|
||||||
|
offer["has_ai_payload"] = True
|
||||||
|
offer["proposal_key"] = proposal.get("proposal_key")
|
||||||
|
offer["ai_suggestion"] = proposal.get("ai_suggestion")
|
||||||
|
offer["proposal_title"] = proposal.get("title")
|
||||||
|
offer["proposal_summary"] = proposal.get("summary")
|
||||||
|
return offer
|
||||||
|
|
||||||
|
|
||||||
|
def apply_gap_fill_after_qa(
|
||||||
|
cur,
|
||||||
|
steps: List[Dict[str, Any]],
|
||||||
|
specs: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
include_ai_calls: bool = True,
|
||||||
|
max_ai_proposals: int = 3,
|
||||||
|
auto_insert_proposals: bool = False,
|
||||||
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
|
||||||
|
Returns: (steps, ai_proposals, gap_fill_offers)
|
||||||
|
"""
|
||||||
|
if not specs:
|
||||||
|
return steps, [], []
|
||||||
|
|
||||||
|
out = list(steps)
|
||||||
|
proposals: List[Dict[str, Any]] = []
|
||||||
|
offers: List[Dict[str, Any]] = []
|
||||||
|
exercise_hints = catalog_exercise_hints_block(cur, catalog) if catalog else ""
|
||||||
|
|
||||||
|
for spec in specs:
|
||||||
|
source = spec.get("source")
|
||||||
|
|
||||||
|
if source == "roadmap_unfilled":
|
||||||
|
proposal: Optional[Dict[str, Any]] = None
|
||||||
|
if include_ai_calls and len(proposals) < max_ai_proposals:
|
||||||
|
proposal = try_suggest_ai_stage_step(
|
||||||
|
cur,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
catalog=catalog,
|
||||||
|
)
|
||||||
|
offer = build_gap_fill_offer(
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
proposal=proposal,
|
||||||
|
roadmap_snapshot=roadmap_snapshot,
|
||||||
|
catalog_exercise_hints=exercise_hints or None,
|
||||||
|
)
|
||||||
|
offers.append(offer)
|
||||||
|
if proposal and auto_insert_proposals:
|
||||||
|
proposals.append(
|
||||||
|
{
|
||||||
|
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
||||||
|
"proposal_key": proposal.get("proposal_key"),
|
||||||
|
"proposal_title": proposal.get("title"),
|
||||||
|
"offer_id": offer.get("offer_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
idx = int(spec.get("insert_after_index") or 0)
|
||||||
|
if idx < 0 or idx >= len(out) - 1:
|
||||||
|
continue
|
||||||
|
step_a = out[idx]
|
||||||
|
step_b = out[idx + 1]
|
||||||
|
if step_a.get("is_ai_proposal") or step_b.get("is_ai_proposal"):
|
||||||
|
offer = build_gap_fill_offer(
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
proposal=None,
|
||||||
|
roadmap_snapshot=roadmap_snapshot,
|
||||||
|
catalog_exercise_hints=exercise_hints or None,
|
||||||
|
)
|
||||||
|
offers.append(offer)
|
||||||
|
continue
|
||||||
|
|
||||||
|
gap = dict(spec.get("gap") or {})
|
||||||
|
if not gap.get("expected_phase"):
|
||||||
|
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
||||||
|
|
||||||
|
proposal = None
|
||||||
|
if include_ai_calls and len(proposals) < max_ai_proposals:
|
||||||
|
proposal = try_suggest_ai_bridge_step(
|
||||||
|
cur,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
step_a=step_a,
|
||||||
|
step_b=step_b,
|
||||||
|
gap=gap,
|
||||||
|
title_hint=str(spec.get("title_hint") or ""),
|
||||||
|
sketch_hint=str(spec.get("sketch") or ""),
|
||||||
|
catalog=catalog,
|
||||||
|
)
|
||||||
|
|
||||||
|
offer = build_gap_fill_offer(
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
proposal=proposal,
|
||||||
|
roadmap_snapshot=roadmap_snapshot,
|
||||||
|
catalog_exercise_hints=exercise_hints or None,
|
||||||
|
)
|
||||||
|
offers.append(offer)
|
||||||
|
|
||||||
|
if proposal and auto_insert_proposals:
|
||||||
|
out.insert(idx + 1, proposal)
|
||||||
|
proposals.append(
|
||||||
|
{
|
||||||
|
"inserted_after_index": idx,
|
||||||
|
"proposal_key": proposal.get("proposal_key"),
|
||||||
|
"proposal_title": proposal.get("title"),
|
||||||
|
"gap": gap,
|
||||||
|
"offer_id": offer.get("offer_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return out, proposals, offers
|
||||||
|
|
||||||
|
|
||||||
|
def insert_ai_proposals_for_gaps(
|
||||||
|
cur,
|
||||||
|
steps: list,
|
||||||
|
unfilled_gaps: list,
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
max_proposals: int = 2,
|
||||||
|
) -> tuple[list, list]:
|
||||||
|
"""Legacy: Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
|
||||||
|
specs = collect_gap_fill_specs(
|
||||||
|
steps=steps,
|
||||||
|
unfilled_gaps=unfilled_gaps,
|
||||||
|
off_topic_steps=[],
|
||||||
|
llm_specs=[],
|
||||||
|
brief=brief,
|
||||||
|
goal_query=goal_query,
|
||||||
|
)
|
||||||
|
out, proposals, _offers = apply_gap_fill_after_qa(
|
||||||
|
cur,
|
||||||
|
steps,
|
||||||
|
specs,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
include_ai_calls=True,
|
||||||
|
max_ai_proposals=max_proposals,
|
||||||
|
auto_insert_proposals=True,
|
||||||
|
)
|
||||||
|
return out, proposals
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"apply_gap_fill_after_qa",
|
||||||
|
"build_gap_fill_goal_text",
|
||||||
|
"build_gap_fill_offer",
|
||||||
|
"collect_gap_fill_specs",
|
||||||
|
"insert_ai_proposals_for_gaps",
|
||||||
|
"try_suggest_ai_bridge_step",
|
||||||
|
"try_suggest_ai_stage_step",
|
||||||
|
]
|
||||||
4457
backend/planning_exercise_path_builder.py
Normal file
4457
backend/planning_exercise_path_builder.py
Normal file
File diff suppressed because it is too large
Load Diff
962
backend/planning_exercise_path_qa.py
Normal file
962
backend/planning_exercise_path_qa.py
Normal file
|
|
@ -0,0 +1,962 @@
|
||||||
|
"""
|
||||||
|
Planungs-KI Phase E: Pfad-QA — Lücken erkennen, Brücken vorschlagen, LLM-Prüfung.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
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,
|
||||||
|
normalize_openrouter_env,
|
||||||
|
openrouter_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
from planning_exercise_semantics import (
|
||||||
|
PlanningSemanticBrief,
|
||||||
|
_blob_from_fields,
|
||||||
|
_blob_matches_stage_excludes,
|
||||||
|
brief_to_summary_dict,
|
||||||
|
build_stage_match_brief,
|
||||||
|
exercise_passes_path_semantic_gate,
|
||||||
|
exercise_passes_stage_learning_goal_gate,
|
||||||
|
exercise_passes_technique_path_scope,
|
||||||
|
merge_stage_exclude_phrases,
|
||||||
|
resolve_path_anti_patterns,
|
||||||
|
resolve_path_primary_topic,
|
||||||
|
score_exercise_semantic_relevance,
|
||||||
|
score_exercise_stage_fit,
|
||||||
|
semantic_brief_for_stage,
|
||||||
|
step_phase_for_index,
|
||||||
|
technique_sibling_excludes,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.planning_exercise_path_qa")
|
||||||
|
|
||||||
|
_GAP_SKILL_THRESHOLD = 0.10
|
||||||
|
_GAP_SEMANTIC_THRESHOLD = 0.28
|
||||||
|
_LARGE_GAP_SCORE = 0.52
|
||||||
|
_MAX_BRIDGE_INSERTS = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||||
|
s = (text or "").strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
|
if s.endswith("```"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
start = s.find("{")
|
||||||
|
end = s.rfind("}")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||||
|
obj = json.loads(s[start : end + 1])
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_jaccard(a: Set[int], b: Set[int]) -> float:
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
inter = len(a & b)
|
||||||
|
union = len(a | b)
|
||||||
|
return inter / union if union else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_exercise_skill_ids(cur, exercise_id: int) -> Set[int]:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT skill_id FROM exercise_skills WHERE exercise_id = %s",
|
||||||
|
(int(exercise_id),),
|
||||||
|
)
|
||||||
|
return {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id") is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_exercise_text_bundle(cur, exercise_id: int) -> Dict[str, Any]:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, title, summary, goal FROM exercises WHERE id = %s",
|
||||||
|
(int(exercise_id),),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"title": "", "summary": "", "goal": "", "variant_names": []}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT variant_name FROM exercise_variants
|
||||||
|
WHERE exercise_id = %s
|
||||||
|
ORDER BY sequence_order ASC NULLS LAST, id ASC
|
||||||
|
LIMIT 8
|
||||||
|
""",
|
||||||
|
(int(exercise_id),),
|
||||||
|
)
|
||||||
|
variants = [str(r.get("variant_name") or "") for r in cur.fetchall()]
|
||||||
|
return {
|
||||||
|
"title": str(row.get("title") or ""),
|
||||||
|
"summary": str(row.get("summary") or ""),
|
||||||
|
"goal": str(row.get("goal") or ""),
|
||||||
|
"variant_names": variants,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def measure_step_transition_gap(
|
||||||
|
cur,
|
||||||
|
step_a: Mapping[str, Any],
|
||||||
|
step_b: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
segment_index: int,
|
||||||
|
total_segments: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
eid_a = int(step_a["exercise_id"])
|
||||||
|
eid_b = int(step_b["exercise_id"])
|
||||||
|
skills_a = _load_exercise_skill_ids(cur, eid_a)
|
||||||
|
skills_b = _load_exercise_skill_ids(cur, eid_b)
|
||||||
|
skill_sim = _skill_jaccard(skills_a, skills_b)
|
||||||
|
|
||||||
|
bundle_b = _load_exercise_text_bundle(cur, eid_b)
|
||||||
|
mid_phase = step_phase_for_index(brief, segment_index + 1, total_segments + 1)
|
||||||
|
sem_b, sem_reasons = score_exercise_semantic_relevance(
|
||||||
|
title=bundle_b["title"],
|
||||||
|
summary=bundle_b["summary"],
|
||||||
|
goal=bundle_b["goal"],
|
||||||
|
variant_names=bundle_b["variant_names"],
|
||||||
|
brief=brief,
|
||||||
|
step_phase=mid_phase,
|
||||||
|
)
|
||||||
|
|
||||||
|
gap_score = 0.0
|
||||||
|
if skill_sim < _GAP_SKILL_THRESHOLD:
|
||||||
|
gap_score += 0.45 * (1.0 - skill_sim / max(_GAP_SKILL_THRESHOLD, 0.01))
|
||||||
|
if sem_b < _GAP_SEMANTIC_THRESHOLD:
|
||||||
|
gap_score += 0.35 * (1.0 - sem_b / max(_GAP_SEMANTIC_THRESHOLD, 0.01))
|
||||||
|
if brief.semantic_strength >= 0.5 and sem_b < 0.15:
|
||||||
|
gap_score += 0.2
|
||||||
|
|
||||||
|
gap_score = min(1.0, round(gap_score, 4))
|
||||||
|
is_large = gap_score >= _LARGE_GAP_SCORE
|
||||||
|
|
||||||
|
return {
|
||||||
|
"from_exercise_id": eid_a,
|
||||||
|
"to_exercise_id": eid_b,
|
||||||
|
"from_title": step_a.get("title"),
|
||||||
|
"to_title": step_b.get("title"),
|
||||||
|
"skill_similarity": round(skill_sim, 4),
|
||||||
|
"semantic_score_to": sem_b,
|
||||||
|
"gap_score": gap_score,
|
||||||
|
"is_large_gap": is_large,
|
||||||
|
"expected_phase": mid_phase,
|
||||||
|
"reasons": sem_reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_roadmap_planned_neighbor_pair(
|
||||||
|
step_a: Mapping[str, Any],
|
||||||
|
step_b: Mapping[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Aufeinanderfolgende Major Steps aus roadmap_first — kein Skill-Übergangs-Lücke."""
|
||||||
|
if step_a.get("roadmap_match_source") != "stage_spec":
|
||||||
|
return False
|
||||||
|
if step_b.get("roadmap_match_source") != "stage_spec":
|
||||||
|
return False
|
||||||
|
idx_a = step_a.get("roadmap_major_step_index")
|
||||||
|
idx_b = step_b.get("roadmap_major_step_index")
|
||||||
|
if idx_a is None or idx_b is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(idx_b) == int(idx_a) + 1
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def detect_path_gaps(
|
||||||
|
cur,
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
roadmap_first: bool = False,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if len(steps) < 2:
|
||||||
|
return []
|
||||||
|
gaps: List[Dict[str, Any]] = []
|
||||||
|
total_segments = len(steps) - 1
|
||||||
|
for i in range(total_segments):
|
||||||
|
step_a = steps[i]
|
||||||
|
step_b = steps[i + 1]
|
||||||
|
if step_a.get("exercise_id") is None or step_b.get("exercise_id") is None:
|
||||||
|
continue
|
||||||
|
if roadmap_first and is_roadmap_planned_neighbor_pair(step_a, step_b):
|
||||||
|
continue
|
||||||
|
gap = measure_step_transition_gap(
|
||||||
|
cur,
|
||||||
|
step_a,
|
||||||
|
step_b,
|
||||||
|
brief=brief,
|
||||||
|
segment_index=i,
|
||||||
|
total_segments=total_segments,
|
||||||
|
)
|
||||||
|
if gap.get("is_large_gap"):
|
||||||
|
gaps.append(gap)
|
||||||
|
return gaps
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_bridge_hit(
|
||||||
|
hits: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
used_ids: Set[int],
|
||||||
|
step_a_id: int,
|
||||||
|
step_b_id: int,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
for hit in hits:
|
||||||
|
eid = int(hit["id"])
|
||||||
|
if eid in used_ids or eid in {step_a_id, step_b_id}:
|
||||||
|
continue
|
||||||
|
return dict(hit)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def insert_bridge_exercises(
|
||||||
|
cur,
|
||||||
|
steps: List[Dict[str, Any]],
|
||||||
|
gaps: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
bridge_search_fn: Callable[..., List[Dict[str, Any]]],
|
||||||
|
max_inserts: int = _MAX_BRIDGE_INSERTS,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Fügt zwischen großen Lücken Brücken-Übungen ein.
|
||||||
|
bridge_search_fn(from_step, to_step, gap) -> hits
|
||||||
|
Returns: (steps, bridge_inserts, unfilled_gaps)
|
||||||
|
"""
|
||||||
|
if not gaps:
|
||||||
|
return steps, [], []
|
||||||
|
|
||||||
|
used_ids = {int(s["exercise_id"]) for s in steps if s.get("exercise_id") is not None}
|
||||||
|
inserts: List[Dict[str, Any]] = []
|
||||||
|
unfilled: List[Dict[str, Any]] = []
|
||||||
|
out = list(steps)
|
||||||
|
|
||||||
|
gap_by_pair = {
|
||||||
|
(int(g["from_exercise_id"]), int(g["to_exercise_id"])): g for g in gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(out) - 1 and len(inserts) < max_inserts:
|
||||||
|
a = out[i]
|
||||||
|
b = out[i + 1]
|
||||||
|
if a.get("exercise_id") is None or b.get("exercise_id") is None:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
key = (int(a["exercise_id"]), int(b["exercise_id"]))
|
||||||
|
gap = gap_by_pair.get(key)
|
||||||
|
if not gap:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
hits = bridge_search_fn(a, b, gap)
|
||||||
|
bridge_hit = _pick_bridge_hit(
|
||||||
|
hits,
|
||||||
|
used_ids=used_ids,
|
||||||
|
step_a_id=int(a["exercise_id"]),
|
||||||
|
step_b_id=int(b["exercise_id"]),
|
||||||
|
)
|
||||||
|
if not bridge_hit:
|
||||||
|
unfilled.append(gap)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
bridge_sem = float(bridge_hit.get("semantic_score") or 0.0)
|
||||||
|
if brief.semantic_strength >= 0.55 and not exercise_passes_path_semantic_gate(
|
||||||
|
semantic_score=bridge_sem,
|
||||||
|
title=str(bridge_hit.get("title") or ""),
|
||||||
|
summary=str(bridge_hit.get("summary") or ""),
|
||||||
|
brief=brief,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
unfilled.append({**gap, "weak_bridge_rejected": True, "bridge_title": bridge_hit.get("title")})
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
bridge_step = {
|
||||||
|
"exercise_id": int(bridge_hit["id"]),
|
||||||
|
"variant_id": bridge_hit.get("suggested_variant_id"),
|
||||||
|
"title": bridge_hit.get("title"),
|
||||||
|
"summary": bridge_hit.get("summary"),
|
||||||
|
"score": bridge_hit.get("score"),
|
||||||
|
"reasons": list(bridge_hit.get("reasons") or []) + ["Brücken-Übung (Lückenfüller)"],
|
||||||
|
"variants": bridge_hit.get("variants") or [],
|
||||||
|
"suggested_variant_id": bridge_hit.get("suggested_variant_id"),
|
||||||
|
"suggested_variant_name": bridge_hit.get("suggested_variant_name"),
|
||||||
|
"is_bridge": True,
|
||||||
|
"bridge_for_gap": {
|
||||||
|
"from_exercise_id": int(a["exercise_id"]),
|
||||||
|
"to_exercise_id": int(b["exercise_id"]),
|
||||||
|
"gap_score": gap.get("gap_score"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out.insert(i + 1, bridge_step)
|
||||||
|
used_ids.add(int(bridge_step["exercise_id"]))
|
||||||
|
inserts.append(
|
||||||
|
{
|
||||||
|
"inserted_after_index": i,
|
||||||
|
"bridge_exercise_id": int(bridge_step["exercise_id"]),
|
||||||
|
"bridge_title": bridge_step.get("title"),
|
||||||
|
"gap": gap,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
i += 2
|
||||||
|
|
||||||
|
return out, inserts, unfilled
|
||||||
|
|
||||||
|
|
||||||
|
def try_llm_qa_progression_path(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
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:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
step_payload = []
|
||||||
|
for idx, step in enumerate(steps):
|
||||||
|
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
|
||||||
|
step_payload.append(
|
||||||
|
{
|
||||||
|
"index": idx + 1,
|
||||||
|
"proposal_key": step.get("proposal_key"),
|
||||||
|
"title": step.get("title"),
|
||||||
|
"summary": strip_html_to_plain(step.get("summary"), max_len=400),
|
||||||
|
"is_bridge": bool(step.get("is_bridge")),
|
||||||
|
"is_ai_proposal": True,
|
||||||
|
"reasons": list(step.get("reasons") or [])[:3],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
|
||||||
|
step_payload.append(
|
||||||
|
{
|
||||||
|
"index": idx + 1,
|
||||||
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
"proposal_key": step.get("proposal_key"),
|
||||||
|
"title": step.get("title") or bundle["title"],
|
||||||
|
"goal": strip_html_to_plain(bundle["goal"], max_len=400),
|
||||||
|
"is_bridge": bool(step.get("is_bridge")),
|
||||||
|
"is_ai_proposal": False,
|
||||||
|
"reasons": list(step.get("reasons") or [])[:3],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables)
|
||||||
|
model = effective_openrouter_model_for_prompt_row(prow)
|
||||||
|
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||||
|
obj = _extract_json_object(raw)
|
||||||
|
return obj, True
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
return None, False
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Pfad-QA-LLM fehlgeschlagen: %s", exc)
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def apply_llm_path_reorder(
|
||||||
|
steps: List[Dict[str, Any]],
|
||||||
|
llm_qa: Mapping[str, Any],
|
||||||
|
) -> Tuple[List[Dict[str, Any]], bool, List[str]]:
|
||||||
|
"""
|
||||||
|
Wendet LLM-Neuordnung an (ordered_step_indices = Permutation der aktuellen Indizes).
|
||||||
|
"""
|
||||||
|
raw = llm_qa.get("ordered_step_indices")
|
||||||
|
if not isinstance(raw, list) or len(raw) != len(steps):
|
||||||
|
return steps, False, []
|
||||||
|
|
||||||
|
try:
|
||||||
|
indices = [int(x) for x in raw]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return steps, False, ["Neuordnung: ungültige Indizes"]
|
||||||
|
|
||||||
|
if sorted(indices) != list(range(len(steps))):
|
||||||
|
return steps, False, ["Neuordnung: keine gültige Permutation — ignoriert"]
|
||||||
|
|
||||||
|
if indices == list(range(len(steps))):
|
||||||
|
return steps, False, []
|
||||||
|
|
||||||
|
notes = [str(n) for n in (llm_qa.get("sequence_notes") or []) if str(n).strip()]
|
||||||
|
return [steps[i] for i in indices], True, notes
|
||||||
|
|
||||||
|
|
||||||
|
_OFF_TOPIC_SEMANTIC_MAX = 0.10
|
||||||
|
|
||||||
|
|
||||||
|
def _with_roadmap_major_index(
|
||||||
|
step: Mapping[str, Any],
|
||||||
|
entry: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
midx = step.get("roadmap_major_step_index")
|
||||||
|
if midx is not None:
|
||||||
|
entry["roadmap_major_step_index"] = int(midx)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def detect_off_topic_steps(
|
||||||
|
cur,
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
goal_query: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri)."""
|
||||||
|
if len(steps) < 2:
|
||||||
|
return []
|
||||||
|
roadmap_stage_steps = any(
|
||||||
|
(step.get("roadmap_match_source") == "stage_spec")
|
||||||
|
or (step.get("roadmap_learning_goal") or "").strip()
|
||||||
|
for step in steps
|
||||||
|
)
|
||||||
|
if brief.semantic_strength < 0.55 and not roadmap_stage_steps:
|
||||||
|
return []
|
||||||
|
|
||||||
|
path_anti = resolve_path_anti_patterns(goal_query or "", semantic_brief=brief)
|
||||||
|
off_topic: List[Dict[str, Any]] = []
|
||||||
|
total = len(steps)
|
||||||
|
for idx, step in enumerate(steps):
|
||||||
|
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
|
||||||
|
continue
|
||||||
|
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
|
||||||
|
blob = _blob_from_fields(
|
||||||
|
bundle["title"],
|
||||||
|
bundle["summary"],
|
||||||
|
bundle["goal"],
|
||||||
|
bundle["variant_names"],
|
||||||
|
)
|
||||||
|
step_anti_raw = list(step.get("roadmap_anti_patterns") or [])
|
||||||
|
stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
|
||||||
|
exclude_phrases = merge_stage_exclude_phrases(
|
||||||
|
stage_goal_pre,
|
||||||
|
[*step_anti_raw, *path_anti],
|
||||||
|
)
|
||||||
|
if exclude_phrases and _blob_matches_stage_excludes(blob, exclude_phrases):
|
||||||
|
off_topic.append(
|
||||||
|
_with_roadmap_major_index(
|
||||||
|
step,
|
||||||
|
{
|
||||||
|
"step_index": idx,
|
||||||
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
"title": step.get("title") or bundle["title"],
|
||||||
|
"semantic_score": 0.0,
|
||||||
|
"expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
|
||||||
|
"issue": "path_exclude",
|
||||||
|
"reasons": ["Widerspricht Pfad-Ausschlüssen (z. B. Kumite)"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
primary = (
|
||||||
|
resolve_path_primary_topic(
|
||||||
|
goal_query or "",
|
||||||
|
brief,
|
||||||
|
stage_learning_goal=None,
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if primary and brief.topic_type == "technique":
|
||||||
|
siblings = technique_sibling_excludes(primary)
|
||||||
|
if not exercise_passes_technique_path_scope(
|
||||||
|
primary_topic=primary,
|
||||||
|
title=bundle["title"],
|
||||||
|
summary=bundle["summary"],
|
||||||
|
goal=bundle["goal"],
|
||||||
|
learning_goal=stage_goal_pre,
|
||||||
|
sibling_excludes=siblings,
|
||||||
|
relaxed=False,
|
||||||
|
):
|
||||||
|
off_topic.append(
|
||||||
|
_with_roadmap_major_index(
|
||||||
|
step,
|
||||||
|
{
|
||||||
|
"step_index": idx,
|
||||||
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
"title": step.get("title") or bundle["title"],
|
||||||
|
"semantic_score": 0.0,
|
||||||
|
"expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
|
||||||
|
"issue": "technique_scope",
|
||||||
|
"reasons": [f"Passt nicht zur Haupttechnik „{primary}“"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
stage_goal = (step.get("roadmap_learning_goal") or "").strip()
|
||||||
|
phase = (step.get("roadmap_phase") or "").strip().lower() or step_phase_for_index(
|
||||||
|
brief, idx, total
|
||||||
|
)
|
||||||
|
step_brief = (
|
||||||
|
semantic_brief_for_stage(brief, learning_goal=stage_goal, phase=phase or None)
|
||||||
|
if stage_goal
|
||||||
|
else brief
|
||||||
|
)
|
||||||
|
sem, sem_reasons = score_exercise_semantic_relevance(
|
||||||
|
title=bundle["title"],
|
||||||
|
summary=bundle["summary"],
|
||||||
|
goal=bundle["goal"],
|
||||||
|
variant_names=bundle["variant_names"],
|
||||||
|
brief=step_brief,
|
||||||
|
step_phase=phase,
|
||||||
|
)
|
||||||
|
stage_anti = list(step.get("roadmap_anti_patterns") or [])
|
||||||
|
stage_match_brief = (
|
||||||
|
build_stage_match_brief(
|
||||||
|
learning_goal=stage_goal,
|
||||||
|
anti_patterns=stage_anti or None,
|
||||||
|
phase=phase or None,
|
||||||
|
)
|
||||||
|
if stage_goal
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
stage_sem = 0.0
|
||||||
|
stage_reasons: List[str] = []
|
||||||
|
if stage_match_brief:
|
||||||
|
stage_sem, stage_reasons = score_exercise_stage_fit(
|
||||||
|
title=bundle["title"],
|
||||||
|
summary=bundle["summary"],
|
||||||
|
goal=bundle["goal"],
|
||||||
|
variant_names=bundle["variant_names"],
|
||||||
|
stage_brief=stage_match_brief,
|
||||||
|
step_phase=phase,
|
||||||
|
)
|
||||||
|
if stage_goal and not exercise_passes_stage_learning_goal_gate(
|
||||||
|
learning_goal=stage_goal,
|
||||||
|
title=bundle["title"],
|
||||||
|
summary=bundle["summary"],
|
||||||
|
goal=bundle["goal"],
|
||||||
|
semantic_score=sem,
|
||||||
|
anti_patterns=stage_anti or None,
|
||||||
|
):
|
||||||
|
reasons = [
|
||||||
|
r
|
||||||
|
for r in stage_reasons
|
||||||
|
if r and r != "Kern-Thema der Anfrage im Übungstext"
|
||||||
|
]
|
||||||
|
if not reasons:
|
||||||
|
reasons = [
|
||||||
|
f"Stufen-Fit zu schwach ({stage_sem:.2f}) für „{stage_goal[:80]}“"
|
||||||
|
]
|
||||||
|
off_topic.append(
|
||||||
|
_with_roadmap_major_index(
|
||||||
|
step,
|
||||||
|
{
|
||||||
|
"step_index": idx,
|
||||||
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
"title": step.get("title") or bundle["title"],
|
||||||
|
"semantic_score": round(stage_sem, 4),
|
||||||
|
"expected_phase": phase,
|
||||||
|
"issue": "stage_mismatch",
|
||||||
|
"roadmap_learning_goal": stage_goal,
|
||||||
|
"reasons": reasons[:3],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if exercise_passes_path_semantic_gate(
|
||||||
|
semantic_score=sem,
|
||||||
|
title=bundle["title"],
|
||||||
|
summary=bundle["summary"],
|
||||||
|
goal=bundle["goal"],
|
||||||
|
brief=step_brief,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if sem > _OFF_TOPIC_SEMANTIC_MAX:
|
||||||
|
continue
|
||||||
|
off_topic.append(
|
||||||
|
_with_roadmap_major_index(
|
||||||
|
step,
|
||||||
|
{
|
||||||
|
"step_index": idx,
|
||||||
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
"title": step.get("title") or bundle["title"],
|
||||||
|
"semantic_score": round(sem, 4),
|
||||||
|
"expected_phase": phase,
|
||||||
|
"issue": "off_topic",
|
||||||
|
"reasons": sem_reasons[:3],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return off_topic
|
||||||
|
|
||||||
|
|
||||||
|
def parse_llm_suggested_new_exercises(
|
||||||
|
llm_qa: Optional[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
step_count: int,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Strukturierte Neuanlage-Vorschläge aus LLM-Pfad-QS."""
|
||||||
|
if not llm_qa:
|
||||||
|
return []
|
||||||
|
raw = llm_qa.get("suggested_new_exercises")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for item in raw[:5]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
title_hint = str(item.get("title_hint") or item.get("title") or "").strip()
|
||||||
|
if len(title_hint) < 3:
|
||||||
|
title_hint = f"{topic} — Zwischenschritt"
|
||||||
|
sketch = str(item.get("sketch") or item.get("goal_hint") or item.get("rationale") or "").strip()
|
||||||
|
phase = str(item.get("phase") or item.get("expected_phase") or "vertiefung").strip()
|
||||||
|
rationale = str(item.get("rationale") or "").strip()
|
||||||
|
insert_after = item.get("insert_after_step_index")
|
||||||
|
if insert_after is None:
|
||||||
|
insert_after = item.get("insert_after_index")
|
||||||
|
try:
|
||||||
|
insert_idx = int(insert_after) if insert_after is not None else max(0, step_count // 2 - 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
insert_idx = max(0, step_count // 2 - 1)
|
||||||
|
insert_idx = max(0, min(step_count - 2, insert_idx))
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"source": "llm_suggested",
|
||||||
|
"insert_after_index": insert_idx,
|
||||||
|
"title_hint": title_hint[:280],
|
||||||
|
"sketch": sketch[:1200],
|
||||||
|
"phase": phase,
|
||||||
|
"rationale": rationale[:500],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def strip_off_topic_steps_from_path(
|
||||||
|
steps: List[Dict[str, Any]],
|
||||||
|
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
min_remaining: int = 2,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||||
|
"""Entfernt themenfremde Schritte aus dem Pfad (mindestens min_remaining bleiben)."""
|
||||||
|
if not off_topic_steps or len(steps) <= min_remaining:
|
||||||
|
return steps, []
|
||||||
|
|
||||||
|
by_index = {int(o["step_index"]): dict(o) for o in off_topic_steps if o.get("step_index") is not None}
|
||||||
|
max_remove = max(0, len(steps) - min_remaining)
|
||||||
|
if max_remove <= 0:
|
||||||
|
return steps, []
|
||||||
|
indices = sorted(by_index.keys(), reverse=True)[:max_remove]
|
||||||
|
|
||||||
|
out = list(steps)
|
||||||
|
removed: List[Dict[str, Any]] = []
|
||||||
|
for idx in indices:
|
||||||
|
if 0 <= idx < len(out):
|
||||||
|
entry = dict(by_index[idx])
|
||||||
|
entry["removed_title"] = out[idx].get("title")
|
||||||
|
entry["removed_exercise_id"] = out[idx].get("exercise_id")
|
||||||
|
removed.append(entry)
|
||||||
|
out.pop(idx)
|
||||||
|
return out, removed
|
||||||
|
|
||||||
|
|
||||||
|
def find_step_pair_index(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
from_exercise_id: int,
|
||||||
|
to_exercise_id: int,
|
||||||
|
) -> Optional[int]:
|
||||||
|
for i in range(len(steps) - 1):
|
||||||
|
a = steps[i]
|
||||||
|
b = steps[i + 1]
|
||||||
|
if a.get("exercise_id") is None or b.get("exercise_id") is None:
|
||||||
|
continue
|
||||||
|
if int(a["exercise_id"]) == int(from_exercise_id) and int(b["exercise_id"]) == int(to_exercise_id):
|
||||||
|
return i
|
||||||
|
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]],
|
||||||
|
bridge_inserts: Sequence[Mapping[str, Any]],
|
||||||
|
ai_proposals: Sequence[Mapping[str, Any]],
|
||||||
|
gap_fill_offers: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
stripped_off_topic: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
llm_qa: Optional[Mapping[str, Any]],
|
||||||
|
llm_applied: bool,
|
||||||
|
reorder_applied: bool = False,
|
||||||
|
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 [])
|
||||||
|
summary: Dict[str, Any] = {
|
||||||
|
"gap_count": len(gaps),
|
||||||
|
"large_gaps": list(gaps),
|
||||||
|
"bridge_insert_count": len(bridge_inserts),
|
||||||
|
"bridge_inserts": list(bridge_inserts),
|
||||||
|
"ai_proposal_count": len(ai_proposals),
|
||||||
|
"ai_proposals": list(ai_proposals),
|
||||||
|
"gap_fill_offer_count": len(offers),
|
||||||
|
"gap_fill_offers": offers,
|
||||||
|
"off_topic_count": len(off_topic),
|
||||||
|
"off_topic_steps": off_topic,
|
||||||
|
"stripped_off_topic_steps": list(stripped_off_topic or []),
|
||||||
|
"llm_qa_applied": llm_applied,
|
||||||
|
"reorder_applied": reorder_applied,
|
||||||
|
"reorder_notes": list(reorder_notes or []),
|
||||||
|
"roadmap_qa_mode": roadmap_qa_mode,
|
||||||
|
}
|
||||||
|
if multistage_qa:
|
||||||
|
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["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def compute_deterministic_path_quality_score(
|
||||||
|
*,
|
||||||
|
gaps: Sequence[Mapping[str, Any]],
|
||||||
|
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
__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",
|
||||||
|
"measure_step_transition_gap",
|
||||||
|
"parse_llm_suggested_new_exercises",
|
||||||
|
"try_llm_qa_progression_path",
|
||||||
|
]
|
||||||
530
backend/planning_exercise_profiles.py
Normal file
530
backend/planning_exercise_profiles.py
Normal file
|
|
@ -0,0 +1,530 @@
|
||||||
|
"""
|
||||||
|
ExerciseMatchProfile / PlanningTargetProfile — Phase-1-Vorselektion Planungs-Übungssuche.
|
||||||
|
|
||||||
|
Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §12–§14
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from planning_exercise_text_signals import (
|
||||||
|
load_framework_planning_text_parts,
|
||||||
|
resolve_planning_text_to_catalog_weights,
|
||||||
|
)
|
||||||
|
from skill_scoring import (
|
||||||
|
ExerciseOccurrence,
|
||||||
|
collect_unit_exercise_occurrences,
|
||||||
|
fetch_exercise_skills_bulk,
|
||||||
|
profile_for_occurrences,
|
||||||
|
_skill_link_multiplier,
|
||||||
|
DEFAULT_ITEM_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ids_to_weights(ids: Sequence[int], primary_id: Optional[int] = None) -> Dict[int, float]:
|
||||||
|
out: Dict[int, float] = {}
|
||||||
|
for raw in ids or []:
|
||||||
|
try:
|
||||||
|
fid = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if fid < 1:
|
||||||
|
continue
|
||||||
|
w = 1.0 if primary_id is not None and fid == int(primary_id) else 0.85
|
||||||
|
out[fid] = max(out.get(fid, 0.0), w)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_weight_maps(*maps: Optional[Dict[int, float]], scale: float = 1.0) -> Dict[int, float]:
|
||||||
|
out: Dict[int, float] = {}
|
||||||
|
for m in maps:
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
for k, v in m.items():
|
||||||
|
try:
|
||||||
|
kid = int(k)
|
||||||
|
val = float(v) * scale
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if kid < 1 or val <= 0:
|
||||||
|
continue
|
||||||
|
out[kid] = max(out.get(kid, 0.0), val)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_weight_map(m: Dict[int, float]) -> Dict[int, float]:
|
||||||
|
if not m:
|
||||||
|
return {}
|
||||||
|
mx = max(m.values())
|
||||||
|
if mx <= 0:
|
||||||
|
return {}
|
||||||
|
return {k: v / mx for k, v in m.items() if v > 0}
|
||||||
|
|
||||||
|
|
||||||
|
def weighted_overlap(a: Dict[int, float], b: Dict[int, float]) -> float:
|
||||||
|
"""Gewichtete Überlappung 0..1 (min-Summe / max-Summe)."""
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
keys = set(a) | set(b)
|
||||||
|
num = sum(min(a.get(k, 0.0), b.get(k, 0.0)) for k in keys)
|
||||||
|
den = sum(max(a.get(k, 0.0), b.get(k, 0.0)) for k in keys)
|
||||||
|
return num / den if den > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def gap_coverage(gap: Dict[int, float], candidate: Dict[int, float]) -> float:
|
||||||
|
"""Anteil der Skill-Lücke, den der Kandidat abdeckt (0..1)."""
|
||||||
|
if not gap:
|
||||||
|
return 0.0
|
||||||
|
total_gap = sum(gap.values())
|
||||||
|
if total_gap <= 0:
|
||||||
|
return 0.0
|
||||||
|
covered = sum(min(gap.get(k, 0.0), candidate.get(k, 0.0)) for k in gap)
|
||||||
|
return covered / total_gap
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExerciseMatchProfile:
|
||||||
|
exercise_id: int
|
||||||
|
focus_area_ids: Dict[int, float] = field(default_factory=dict)
|
||||||
|
style_direction_ids: Dict[int, float] = field(default_factory=dict)
|
||||||
|
training_type_ids: Dict[int, float] = field(default_factory=dict)
|
||||||
|
target_group_ids: Dict[int, float] = field(default_factory=dict)
|
||||||
|
skill_weights: Dict[int, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"exercise_id": self.exercise_id,
|
||||||
|
"focus_area_ids": self.focus_area_ids,
|
||||||
|
"style_direction_ids": self.style_direction_ids,
|
||||||
|
"training_type_ids": self.training_type_ids,
|
||||||
|
"target_group_ids": self.target_group_ids,
|
||||||
|
"skill_weights": self.skill_weights,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlanningTargetProfile:
|
||||||
|
focus_area_ids: Dict[int, float] = field(default_factory=dict)
|
||||||
|
style_direction_ids: Dict[int, float] = field(default_factory=dict)
|
||||||
|
training_type_ids: Dict[int, float] = field(default_factory=dict)
|
||||||
|
target_group_ids: Dict[int, float] = field(default_factory=dict)
|
||||||
|
skill_weights: Dict[int, float] = field(default_factory=dict)
|
||||||
|
skill_gap_weights: Dict[int, float] = field(default_factory=dict)
|
||||||
|
skill_plan_weights: Dict[int, float] = field(default_factory=dict)
|
||||||
|
sources: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_summary_dict(self, cur, limit_skills: int = 5) -> Dict[str, Any]:
|
||||||
|
focus_labels = _load_focus_labels(cur, list(self.focus_area_ids.keys())[:6])
|
||||||
|
top_skills = sorted(self.skill_weights.items(), key=lambda x: -x[1])[:limit_skills]
|
||||||
|
skill_names = _load_skill_names(cur, [s[0] for s in top_skills])
|
||||||
|
return {
|
||||||
|
"sources": list(self.sources),
|
||||||
|
"focus_areas": focus_labels,
|
||||||
|
"top_skills": [
|
||||||
|
{"skill_id": sid, "name": skill_names.get(sid, f"#{sid}"), "weight": round(w, 2)}
|
||||||
|
for sid, w in top_skills
|
||||||
|
],
|
||||||
|
"has_skill_gap": bool(self.skill_gap_weights),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_focus_labels(cur, ids: Sequence[int]) -> List[str]:
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
ph = ",".join(["%s"] * len(ids))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id, name FROM focus_areas WHERE id IN ({ph}) ORDER BY name",
|
||||||
|
list(ids),
|
||||||
|
)
|
||||||
|
return [f"{r['name'] or r['id']}" for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skill_names(cur, ids: Sequence[int]) -> Dict[int, str]:
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
ph = ",".join(["%s"] * len(ids))
|
||||||
|
cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", list(ids))
|
||||||
|
return {int(r["id"]): str(r["name"] or "") for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_weights_from_profile(skills_out: Sequence[Dict[str, Any]]) -> Dict[int, float]:
|
||||||
|
out: Dict[int, float] = {}
|
||||||
|
for row in skills_out or []:
|
||||||
|
sid = row.get("skill_id")
|
||||||
|
if sid is None:
|
||||||
|
continue
|
||||||
|
w = float(row.get("weight") or row.get("score") or 0)
|
||||||
|
if w > 0:
|
||||||
|
out[int(sid)] = w
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _single_exercise_skill_weights(
|
||||||
|
skill_rows: Sequence[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
minutes: float = DEFAULT_ITEM_MINUTES,
|
||||||
|
) -> Dict[int, float]:
|
||||||
|
out: Dict[int, float] = {}
|
||||||
|
for link in skill_rows or []:
|
||||||
|
sid = link.get("skill_id")
|
||||||
|
if sid is None:
|
||||||
|
continue
|
||||||
|
sid = int(sid)
|
||||||
|
mult = _skill_link_multiplier(
|
||||||
|
intensity=link.get("intensity"),
|
||||||
|
required_level=link.get("required_level"),
|
||||||
|
target_level=link.get("target_level"),
|
||||||
|
)
|
||||||
|
w = minutes * mult
|
||||||
|
if w > 0:
|
||||||
|
out[sid] = out.get(sid, 0.0) + w
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _load_relation_maps_bulk(
|
||||||
|
cur,
|
||||||
|
exercise_ids: Sequence[int],
|
||||||
|
table: str,
|
||||||
|
id_column: str,
|
||||||
|
) -> Dict[int, Dict[int, float]]:
|
||||||
|
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
ph = ",".join(["%s"] * len(ids))
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT exercise_id, {id_column} AS rel_id, is_primary
|
||||||
|
FROM {table}
|
||||||
|
WHERE exercise_id IN ({ph})
|
||||||
|
""",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
out: Dict[int, Dict[int, float]] = {eid: {} for eid in ids}
|
||||||
|
for row in cur.fetchall():
|
||||||
|
eid = int(row["exercise_id"])
|
||||||
|
rid = int(row["rel_id"])
|
||||||
|
w = 1.0 if row.get("is_primary") else 0.85
|
||||||
|
out.setdefault(eid, {})[rid] = max(out[eid].get(rid, 0.0), w)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_exercise_match_profiles_bulk(cur, exercise_ids: Sequence[int]) -> Dict[int, ExerciseMatchProfile]:
|
||||||
|
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
focus_map = _load_relation_maps_bulk(cur, ids, "exercise_focus_areas", "focus_area_id")
|
||||||
|
style_map = _load_relation_maps_bulk(cur, ids, "exercise_style_directions", "style_direction_id")
|
||||||
|
type_map = _load_relation_maps_bulk(cur, ids, "exercise_training_types", "training_type_id")
|
||||||
|
tg_map = _load_relation_maps_bulk(cur, ids, "exercise_target_groups", "target_group_id")
|
||||||
|
skills_bulk = fetch_exercise_skills_bulk(cur, ids)
|
||||||
|
|
||||||
|
profiles: Dict[int, ExerciseMatchProfile] = {}
|
||||||
|
for eid in ids:
|
||||||
|
profiles[eid] = ExerciseMatchProfile(
|
||||||
|
exercise_id=eid,
|
||||||
|
focus_area_ids=focus_map.get(eid, {}),
|
||||||
|
style_direction_ids=style_map.get(eid, {}),
|
||||||
|
training_type_ids=type_map.get(eid, {}),
|
||||||
|
target_group_ids=tg_map.get(eid, {}),
|
||||||
|
skill_weights=_single_exercise_skill_weights(skills_bulk.get(eid, [])),
|
||||||
|
)
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_framework_for_unit(cur, unit: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
slot_id = unit.get("framework_slot_id") or unit.get("origin_framework_slot_id")
|
||||||
|
if not slot_id:
|
||||||
|
return None
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.id AS slot_id, s.framework_program_id, s.sort_order, s.title AS slot_title,
|
||||||
|
fp.title AS framework_title, fp.focus_area_id AS header_focus_area_id
|
||||||
|
FROM training_framework_slots s
|
||||||
|
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
|
||||||
|
WHERE s.id = %s
|
||||||
|
""",
|
||||||
|
(int(slot_id),),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _framework_catalog_weights(cur, framework_id: int) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float]]:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT focus_area_id FROM training_framework_programs WHERE id = %s",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
hdr = cur.fetchone()
|
||||||
|
header_fa = int(hdr["focus_area_id"]) if hdr and hdr.get("focus_area_id") else None
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT focus_area_id FROM training_framework_program_focus_areas WHERE framework_program_id = %s",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
fa_ids = [int(r["focus_area_id"]) for r in cur.fetchall()]
|
||||||
|
if header_fa and header_fa not in fa_ids:
|
||||||
|
fa_ids.insert(0, header_fa)
|
||||||
|
focus = _ids_to_weights(fa_ids, primary_id=header_fa)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT style_direction_id FROM training_framework_program_style_directions WHERE framework_program_id = %s",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
style = _ids_to_weights([int(r["style_direction_id"]) for r in cur.fetchall()])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT training_type_id FROM training_framework_program_training_types WHERE framework_program_id = %s",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
tt = _ids_to_weights([int(r["training_type_id"]) for r in cur.fetchall()])
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT target_group_id FROM training_framework_program_target_groups WHERE framework_program_id = %s",
|
||||||
|
(framework_id,),
|
||||||
|
)
|
||||||
|
tg = _ids_to_weights([int(r["target_group_id"]) for r in cur.fetchall()])
|
||||||
|
|
||||||
|
return focus, style, tt, tg
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_from_unit_occurrences(cur, unit_id: int) -> Dict[int, float]:
|
||||||
|
occ = collect_unit_exercise_occurrences(cur, int(unit_id))
|
||||||
|
if not occ:
|
||||||
|
return {}
|
||||||
|
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||||
|
return _skill_weights_from_profile(prof.get("skills") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_from_exercise_ids(cur, exercise_ids: Sequence[int]) -> Dict[int, float]:
|
||||||
|
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids]
|
||||||
|
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||||
|
return _skill_weights_from_profile(prof.get("skills") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def skill_profile_summary_from_exercise_ids(
|
||||||
|
cur,
|
||||||
|
exercise_ids: Sequence[int],
|
||||||
|
*,
|
||||||
|
limit_skills: int = 8,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Kompaktes Fähigkeitenprofil für LLM-Kontext und UI."""
|
||||||
|
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||||
|
if not ids:
|
||||||
|
return {"exercise_count": 0, "skills": []}
|
||||||
|
occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids]
|
||||||
|
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||||
|
skills_out = prof.get("skills") or []
|
||||||
|
top = sorted(skills_out, key=lambda s: -float(s.get("weight") or s.get("score") or 0))[:limit_skills]
|
||||||
|
names = _load_skill_names(cur, [int(s["skill_id"]) for s in top if s.get("skill_id") is not None])
|
||||||
|
return {
|
||||||
|
"exercise_count": len(ids),
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"skill_id": int(s["skill_id"]),
|
||||||
|
"name": names.get(int(s["skill_id"]), f"#{s['skill_id']}"),
|
||||||
|
"weight": round(float(s.get("weight") or s.get("score") or 0), 3),
|
||||||
|
}
|
||||||
|
for s in top
|
||||||
|
if s.get("skill_id") is not None
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_planning_target_profile(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
unit: Dict[str, Any],
|
||||||
|
planned_exercise_ids: Sequence[int],
|
||||||
|
section_planned_exercise_ids: Optional[Sequence[int]] = None,
|
||||||
|
anchor_exercise_id: Optional[int],
|
||||||
|
intent: str,
|
||||||
|
section_guidance_notes: Optional[str] = None,
|
||||||
|
section_title: Optional[str] = None,
|
||||||
|
) -> PlanningTargetProfile:
|
||||||
|
sources: List[str] = []
|
||||||
|
focus: Dict[int, float] = {}
|
||||||
|
style: Dict[int, float] = {}
|
||||||
|
tt: Dict[int, float] = {}
|
||||||
|
tg: Dict[int, float] = {}
|
||||||
|
skill_target: Dict[int, float] = {}
|
||||||
|
skill_plan: Dict[int, float] = {}
|
||||||
|
|
||||||
|
fw = _resolve_framework_for_unit(cur, unit)
|
||||||
|
if fw:
|
||||||
|
fid = int(fw["framework_program_id"])
|
||||||
|
f_focus, f_style, f_tt, f_tg = _framework_catalog_weights(cur, fid)
|
||||||
|
focus = _merge_weight_maps(focus, f_focus)
|
||||||
|
style = _merge_weight_maps(style, f_style)
|
||||||
|
tt = _merge_weight_maps(tt, f_tt)
|
||||||
|
tg = _merge_weight_maps(tg, f_tg)
|
||||||
|
sources.append("framework_catalog")
|
||||||
|
|
||||||
|
slot_id = fw.get("slot_id")
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM training_units WHERE framework_slot_id = %s LIMIT 1",
|
||||||
|
(int(slot_id),),
|
||||||
|
)
|
||||||
|
bp = cur.fetchone()
|
||||||
|
if bp and bp.get("id"):
|
||||||
|
slot_skills = _profile_from_unit_occurrences(cur, int(bp["id"]))
|
||||||
|
if slot_skills:
|
||||||
|
skill_target = _merge_weight_maps(skill_target, slot_skills, scale=1.0)
|
||||||
|
sources.append("framework_slot_skill_profile")
|
||||||
|
if not skill_target:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT tu.id FROM training_framework_slots s
|
||||||
|
LEFT JOIN training_units tu ON tu.framework_slot_id = s.id
|
||||||
|
WHERE s.framework_program_id = %s AND tu.id IS NOT NULL
|
||||||
|
""",
|
||||||
|
(fid,),
|
||||||
|
)
|
||||||
|
all_occ: List[ExerciseOccurrence] = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
all_occ.extend(collect_unit_exercise_occurrences(cur, int(r["id"])))
|
||||||
|
if all_occ:
|
||||||
|
prof = profile_for_occurrences(cur, all_occ, reference_max_by_skill=None)
|
||||||
|
skill_target = _merge_weight_maps(
|
||||||
|
skill_target, _skill_weights_from_profile(prof.get("skills") or []), scale=0.85
|
||||||
|
)
|
||||||
|
sources.append("framework_overall_skill_profile")
|
||||||
|
|
||||||
|
if planned_exercise_ids:
|
||||||
|
occ = [ExerciseOccurrence(exercise_id=int(eid)) for eid in planned_exercise_ids]
|
||||||
|
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||||
|
skill_plan = _skill_weights_from_profile(prof.get("skills") or [])
|
||||||
|
if skill_plan:
|
||||||
|
sources.append("current_unit_plan")
|
||||||
|
|
||||||
|
section_ids = [int(x) for x in (section_planned_exercise_ids or []) if int(x) > 0]
|
||||||
|
if section_ids:
|
||||||
|
section_skills = _profile_from_exercise_ids(cur, section_ids)
|
||||||
|
if section_skills:
|
||||||
|
skill_target = _merge_weight_maps(skill_target, section_skills, scale=1.0)
|
||||||
|
sources.append("current_section_plan")
|
||||||
|
|
||||||
|
if anchor_exercise_id:
|
||||||
|
anchor_profiles = load_exercise_match_profiles_bulk(cur, [int(anchor_exercise_id)])
|
||||||
|
ap = anchor_profiles.get(int(anchor_exercise_id))
|
||||||
|
if ap:
|
||||||
|
if intent in ("deepen_exercise", "suggest_next", "progression_next", "continue_plan_goal"):
|
||||||
|
skill_target = _merge_weight_maps(skill_target, ap.skill_weights, scale=1.0)
|
||||||
|
focus = _merge_weight_maps(focus, ap.focus_area_ids, scale=0.9)
|
||||||
|
style = _merge_weight_maps(style, ap.style_direction_ids, scale=0.75)
|
||||||
|
tt = _merge_weight_maps(tt, ap.training_type_ids, scale=0.75)
|
||||||
|
tg = _merge_weight_maps(tg, ap.target_group_ids, scale=0.75)
|
||||||
|
sources.append("anchor_exercise")
|
||||||
|
|
||||||
|
text_parts: List[str] = []
|
||||||
|
if (section_title or "").strip():
|
||||||
|
text_parts.append(str(section_title).strip())
|
||||||
|
if (section_guidance_notes or "").strip():
|
||||||
|
text_parts.append(str(section_guidance_notes).strip())
|
||||||
|
if fw:
|
||||||
|
text_parts.extend(
|
||||||
|
load_framework_planning_text_parts(
|
||||||
|
cur,
|
||||||
|
int(fw["framework_program_id"]),
|
||||||
|
slot_id=int(fw["slot_id"]) if fw.get("slot_id") else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if text_parts:
|
||||||
|
blob = "\n".join(text_parts)
|
||||||
|
tf, ts, ttt, ttg, tsk = resolve_planning_text_to_catalog_weights(cur, blob)
|
||||||
|
if tf or ts or ttt or ttg or tsk:
|
||||||
|
focus = _merge_weight_maps(focus, tf, scale=0.88)
|
||||||
|
style = _merge_weight_maps(style, ts, scale=0.8)
|
||||||
|
tt = _merge_weight_maps(tt, ttt, scale=0.8)
|
||||||
|
tg = _merge_weight_maps(tg, ttg, scale=0.8)
|
||||||
|
skill_target = _merge_weight_maps(skill_target, tsk, scale=0.92)
|
||||||
|
sources.append("planning_text_signals")
|
||||||
|
|
||||||
|
skill_target = _normalize_weight_map(skill_target)
|
||||||
|
skill_plan_norm = _normalize_weight_map(skill_plan)
|
||||||
|
skill_gap: Dict[int, float] = {}
|
||||||
|
for sid, tw in skill_target.items():
|
||||||
|
pw = skill_plan_norm.get(sid, 0.0)
|
||||||
|
gap = tw - pw * 0.85
|
||||||
|
if gap > 0.08:
|
||||||
|
skill_gap[sid] = gap
|
||||||
|
if skill_gap:
|
||||||
|
sources.append("skill_gap_vs_plan")
|
||||||
|
|
||||||
|
return PlanningTargetProfile(
|
||||||
|
focus_area_ids=_normalize_weight_map(focus) if focus else focus,
|
||||||
|
style_direction_ids=_normalize_weight_map(style) if style else style,
|
||||||
|
training_type_ids=_normalize_weight_map(tt) if tt else tt,
|
||||||
|
target_group_ids=_normalize_weight_map(tg) if tg else tg,
|
||||||
|
skill_weights=skill_target,
|
||||||
|
skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else skill_gap,
|
||||||
|
skill_plan_weights=skill_plan_norm,
|
||||||
|
sources=sources,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def score_exercise_against_target(
|
||||||
|
exercise: ExerciseMatchProfile,
|
||||||
|
target: PlanningTargetProfile,
|
||||||
|
*,
|
||||||
|
intent: str,
|
||||||
|
) -> Tuple[float, List[str]]:
|
||||||
|
"""Profil-Match 0..1 + deutschsprachige Gründe."""
|
||||||
|
reasons: List[str] = []
|
||||||
|
|
||||||
|
focus_sim = weighted_overlap(exercise.focus_area_ids, target.focus_area_ids)
|
||||||
|
style_sim = weighted_overlap(exercise.style_direction_ids, target.style_direction_ids)
|
||||||
|
tt_sim = weighted_overlap(exercise.training_type_ids, target.training_type_ids)
|
||||||
|
tg_sim = weighted_overlap(exercise.target_group_ids, target.target_group_ids)
|
||||||
|
skill_sim = weighted_overlap(
|
||||||
|
_normalize_weight_map(exercise.skill_weights),
|
||||||
|
target.skill_weights,
|
||||||
|
)
|
||||||
|
gap_sim = gap_coverage(target.skill_gap_weights, _normalize_weight_map(exercise.skill_weights))
|
||||||
|
|
||||||
|
if focus_sim >= 0.5 and target.focus_area_ids:
|
||||||
|
reasons.append("Fokusbereich passend zum Planungsziel")
|
||||||
|
if style_sim >= 0.5 and target.style_direction_ids:
|
||||||
|
reasons.append("Stilrichtung passend")
|
||||||
|
if tt_sim >= 0.5 and target.training_type_ids:
|
||||||
|
reasons.append("Trainingsstil passend")
|
||||||
|
if tg_sim >= 0.5 and target.target_group_ids:
|
||||||
|
reasons.append("Zielgruppe passend")
|
||||||
|
if skill_sim >= 0.35 and target.skill_weights:
|
||||||
|
reasons.append("Fähigkeiten-Schwerpunkt passend (Profilmetrik)")
|
||||||
|
if gap_sim >= 0.25 and target.skill_gap_weights:
|
||||||
|
reasons.append("Deckt Skill-Lücke im bisherigen Plan")
|
||||||
|
if "query_intent" in (target.sources or []):
|
||||||
|
reasons.append("Passt zur KI-interpretierten Suchanfrage")
|
||||||
|
if "planning_text_signals" in (target.sources or []):
|
||||||
|
reasons.append("Passt zu Abschnitts- oder Rahmen-Zieltext")
|
||||||
|
|
||||||
|
# Intent-gewichtete Dimensionen (Summe = 1.0)
|
||||||
|
if intent == INTENT_FREE_SEARCH:
|
||||||
|
weights = {"focus": 0.15, "style": 0.10, "tt": 0.10, "tg": 0.10, "skill": 0.25, "gap": 0.30}
|
||||||
|
elif intent == INTENT_DEEPEN_EXERCISE:
|
||||||
|
weights = {"focus": 0.15, "style": 0.10, "tt": 0.10, "tg": 0.05, "skill": 0.45, "gap": 0.15}
|
||||||
|
elif intent == INTENT_PROGRESSION_NEXT:
|
||||||
|
weights = {"focus": 0.20, "style": 0.10, "tt": 0.10, "tg": 0.05, "skill": 0.35, "gap": 0.20}
|
||||||
|
else:
|
||||||
|
weights = {"focus": 0.20, "style": 0.10, "tt": 0.10, "tg": 0.10, "skill": 0.30, "gap": 0.20}
|
||||||
|
|
||||||
|
score = (
|
||||||
|
weights["focus"] * focus_sim
|
||||||
|
+ weights["style"] * style_sim
|
||||||
|
+ weights["tt"] * tt_sim
|
||||||
|
+ weights["tg"] * tg_sim
|
||||||
|
+ weights["skill"] * skill_sim
|
||||||
|
+ weights["gap"] * gap_sim
|
||||||
|
)
|
||||||
|
return max(0.0, min(1.0, score)), reasons
|
||||||
|
|
||||||
|
|
||||||
|
# Re-export intent constants for typing (avoid circular import at runtime in suggest module)
|
||||||
|
INTENT_FREE_SEARCH = "free_search"
|
||||||
|
INTENT_DEEPEN_EXERCISE = "deepen_exercise"
|
||||||
|
INTENT_PROGRESSION_NEXT = "progression_next"
|
||||||
210
backend/planning_exercise_progression.py
Normal file
210
backend/planning_exercise_progression.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""
|
||||||
|
Progressionsgraph-Auflösung für Planungs-KI (Phase C1).
|
||||||
|
|
||||||
|
Variantenbewusste Nachfolger-Kanten (Migration 034) und Auto-Match eines sichtbaren Graphen
|
||||||
|
anhand der Anker-Übung, wenn der Client keine graph_id sendet.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from tenant_context import TenantContext, library_content_visibility_sql
|
||||||
|
|
||||||
|
ProgressionSuccessorBundle = Tuple[Set[int], Dict[int, str], Dict[int, Optional[int]]]
|
||||||
|
|
||||||
|
|
||||||
|
def edge_matches_anchor_from(
|
||||||
|
edge: Mapping[str, Any],
|
||||||
|
from_variant_id: Optional[int],
|
||||||
|
) -> bool:
|
||||||
|
"""Kante gilt als Ausgang vom Anker: generische Kante oder passende Varianten-Kante."""
|
||||||
|
edge_var = edge.get("from_exercise_variant_id")
|
||||||
|
if edge_var is None:
|
||||||
|
return True
|
||||||
|
if from_variant_id is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(edge_var) == int(from_variant_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def filter_outgoing_progression_edges(
|
||||||
|
edges: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
from_variant_id: Optional[int],
|
||||||
|
) -> List[Mapping[str, Any]]:
|
||||||
|
return [e for e in edges if edge_matches_anchor_from(e, from_variant_id)]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_successors_from_edges(
|
||||||
|
edges: Sequence[Mapping[str, Any]],
|
||||||
|
) -> ProgressionSuccessorBundle:
|
||||||
|
ids: Set[int] = set()
|
||||||
|
notes: Dict[int, str] = {}
|
||||||
|
variants: Dict[int, Optional[int]] = {}
|
||||||
|
for row in edges:
|
||||||
|
tid = int(row["to_exercise_id"])
|
||||||
|
ids.add(tid)
|
||||||
|
n = (row.get("notes") or "").strip()
|
||||||
|
if n:
|
||||||
|
notes[tid] = n
|
||||||
|
raw_v = row.get("to_exercise_variant_id")
|
||||||
|
variants[tid] = int(raw_v) if raw_v is not None else None
|
||||||
|
return ids, notes, variants
|
||||||
|
|
||||||
|
|
||||||
|
def rank_progression_graph_rows(rows: Sequence[Mapping[str, Any]]) -> Optional[Mapping[str, Any]]:
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _key(row: Mapping[str, Any]) -> Tuple[int, int, int]:
|
||||||
|
var_match = int(row.get("variant_match_count") or 0)
|
||||||
|
out_count = int(row.get("outgoing_count") or 0)
|
||||||
|
gid = int(row.get("id") or 0)
|
||||||
|
return (var_match, out_count, gid)
|
||||||
|
|
||||||
|
return max(rows, key=_key)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_progression_graph_for_planning(
|
||||||
|
cur,
|
||||||
|
tenant: TenantContext,
|
||||||
|
*,
|
||||||
|
from_exercise_id: Optional[int],
|
||||||
|
from_variant_id: Optional[int],
|
||||||
|
explicit_graph_id: Optional[int],
|
||||||
|
) -> Tuple[Optional[int], Optional[str], bool]:
|
||||||
|
"""
|
||||||
|
Liefert (graph_id, graph_name, auto_resolved).
|
||||||
|
|
||||||
|
Bei explicit_graph_id: Sichtbarkeit prüfen, kein Auto-Match.
|
||||||
|
Sonst: sichtbarer Graph mit passenden Ausgangskanten vom Anker.
|
||||||
|
"""
|
||||||
|
profile_id = tenant.profile_id
|
||||||
|
role = tenant.global_role
|
||||||
|
vis_sql, vis_params = library_content_visibility_sql(
|
||||||
|
alias="g",
|
||||||
|
profile_id=profile_id,
|
||||||
|
role=role,
|
||||||
|
effective_club_id=tenant.effective_club_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if explicit_graph_id and int(explicit_graph_id) > 0:
|
||||||
|
gid = int(explicit_graph_id)
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT g.id, g.name
|
||||||
|
FROM exercise_progression_graphs g
|
||||||
|
WHERE g.id = %s AND ({vis_sql})
|
||||||
|
""",
|
||||||
|
[gid, *vis_params],
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None, None, False
|
||||||
|
name = (row.get("name") or "").strip() or None
|
||||||
|
return gid, name, False
|
||||||
|
|
||||||
|
if not from_exercise_id or int(from_exercise_id) < 1:
|
||||||
|
return None, None, False
|
||||||
|
|
||||||
|
anchor_var = int(from_variant_id) if from_variant_id is not None else None
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT g.id, g.name,
|
||||||
|
COUNT(*)::int AS outgoing_count,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE e.from_exercise_variant_id IS NOT NULL
|
||||||
|
AND (%s IS NOT NULL)
|
||||||
|
AND e.from_exercise_variant_id = %s
|
||||||
|
)::int AS variant_match_count
|
||||||
|
FROM exercise_progression_edges e
|
||||||
|
INNER JOIN exercise_progression_graphs g ON g.id = e.graph_id
|
||||||
|
WHERE e.from_exercise_id = %s
|
||||||
|
AND LOWER(TRIM(e.edge_type)) = 'next_exercise'
|
||||||
|
AND ({vis_sql})
|
||||||
|
AND (
|
||||||
|
e.from_exercise_variant_id IS NULL
|
||||||
|
OR (%s IS NULL)
|
||||||
|
OR e.from_exercise_variant_id = %s
|
||||||
|
)
|
||||||
|
GROUP BY g.id, g.name
|
||||||
|
""",
|
||||||
|
[anchor_var, anchor_var, int(from_exercise_id), *vis_params, anchor_var, anchor_var],
|
||||||
|
)
|
||||||
|
picked = rank_progression_graph_rows(cur.fetchall())
|
||||||
|
if not picked:
|
||||||
|
return None, None, False
|
||||||
|
gid = int(picked["id"])
|
||||||
|
name = (picked.get("name") or "").strip() or None
|
||||||
|
return gid, name, True
|
||||||
|
|
||||||
|
|
||||||
|
def load_progression_successors_for_anchor(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
graph_id: Optional[int],
|
||||||
|
from_exercise_id: Optional[int],
|
||||||
|
from_variant_id: Optional[int],
|
||||||
|
) -> ProgressionSuccessorBundle:
|
||||||
|
if not graph_id or not from_exercise_id:
|
||||||
|
return set(), {}, {}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT to_exercise_id, to_exercise_variant_id, notes, from_exercise_variant_id
|
||||||
|
FROM exercise_progression_edges
|
||||||
|
WHERE graph_id = %s AND from_exercise_id = %s
|
||||||
|
AND LOWER(TRIM(edge_type)) = 'next_exercise'
|
||||||
|
""",
|
||||||
|
(int(graph_id), int(from_exercise_id)),
|
||||||
|
)
|
||||||
|
rows = [dict(r) for r in cur.fetchall()]
|
||||||
|
filtered = filter_outgoing_progression_edges(rows, from_variant_id=from_variant_id)
|
||||||
|
return parse_successors_from_edges(filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_progression_context_to_pack(
|
||||||
|
cur,
|
||||||
|
tenant: TenantContext,
|
||||||
|
pack: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
explicit_graph_id: Optional[int],
|
||||||
|
anchor_variant_id: Optional[int],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Pack um aufgelösten Graph und Nachfolger anreichern."""
|
||||||
|
anchor_id = pack.get("anchor_exercise_id")
|
||||||
|
pack["anchor_exercise_variant_id"] = anchor_variant_id
|
||||||
|
|
||||||
|
graph_id, graph_name, auto_resolved = resolve_progression_graph_for_planning(
|
||||||
|
cur,
|
||||||
|
tenant,
|
||||||
|
from_exercise_id=anchor_id,
|
||||||
|
from_variant_id=anchor_variant_id,
|
||||||
|
explicit_graph_id=explicit_graph_id,
|
||||||
|
)
|
||||||
|
pack["progression_graph_id"] = graph_id
|
||||||
|
pack["progression_graph_name"] = graph_name
|
||||||
|
pack["progression_graph_auto_resolved"] = bool(auto_resolved)
|
||||||
|
|
||||||
|
succ_ids, notes, succ_variants = load_progression_successors_for_anchor(
|
||||||
|
cur,
|
||||||
|
graph_id=graph_id,
|
||||||
|
from_exercise_id=anchor_id,
|
||||||
|
from_variant_id=anchor_variant_id,
|
||||||
|
)
|
||||||
|
pack["progression_successor_ids"] = sorted(succ_ids)
|
||||||
|
pack["progression_edge_notes"] = notes
|
||||||
|
pack["progression_successor_variants"] = succ_variants
|
||||||
|
return pack
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"apply_progression_context_to_pack",
|
||||||
|
"edge_matches_anchor_from",
|
||||||
|
"filter_outgoing_progression_edges",
|
||||||
|
"load_progression_successors_for_anchor",
|
||||||
|
"parse_successors_from_edges",
|
||||||
|
"rank_progression_graph_rows",
|
||||||
|
"resolve_progression_graph_for_planning",
|
||||||
|
]
|
||||||
640
backend/planning_exercise_retrieval.py
Normal file
640
backend/planning_exercise_retrieval.py
Normal file
|
|
@ -0,0 +1,640 @@
|
||||||
|
"""
|
||||||
|
Mehrstufiges Retrieval für Planungs-Übungssuche (Phase A).
|
||||||
|
|
||||||
|
Stufen:
|
||||||
|
S1b-0 Gesamte sichtbare Bibliothek (Governance + Hard-Filter, kein Profil-OR-Pool)
|
||||||
|
S1b-1 Deterministischer Hybrid-Score auf allen Kandidaten → sortiert
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from planning_exercise_profiles import (
|
||||||
|
PlanningTargetProfile,
|
||||||
|
load_exercise_match_profiles_bulk,
|
||||||
|
score_exercise_against_target,
|
||||||
|
)
|
||||||
|
from exercise_ai import strip_html_to_plain
|
||||||
|
from planning_exercise_semantics import (
|
||||||
|
PlanningSemanticBrief,
|
||||||
|
build_stage_match_brief,
|
||||||
|
exercise_passes_path_semantic_gate,
|
||||||
|
exercise_passes_stage_fit,
|
||||||
|
score_exercise_semantic_relevance,
|
||||||
|
score_exercise_stage_fit,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MAX_LIBRARY_ROWS = 8000
|
||||||
|
_PROFILE_LOAD_BATCH = 400
|
||||||
|
_PARTNER_TEXT_MARKERS = ("partner", "paar", "paarweise", "zu zweit")
|
||||||
|
|
||||||
|
|
||||||
|
def _exercise_looks_partner_related(row: Mapping[str, Any]) -> bool:
|
||||||
|
parts = [
|
||||||
|
str(row.get("method_archetype") or ""),
|
||||||
|
str(row.get("title") or ""),
|
||||||
|
str(row.get("summary") or ""),
|
||||||
|
]
|
||||||
|
blob = " ".join(parts).lower()
|
||||||
|
return any(m in blob for m in _PARTNER_TEXT_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_jaccard(a: Set[int], b: Set[int]) -> float:
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
inter = len(a & b)
|
||||||
|
union = len(a | b)
|
||||||
|
return inter / union if union else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> List[str]:
|
||||||
|
out: List[str] = []
|
||||||
|
if not exercise_kind_any:
|
||||||
|
return out
|
||||||
|
for raw in exercise_kind_any:
|
||||||
|
s = str(raw or "").strip().lower()
|
||||||
|
if s in ("simple", "combination") and s not in out:
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_EXERCISE_ROW_SELECT = """
|
||||||
|
SELECT e.id, e.title, e.summary, e.method_archetype,
|
||||||
|
e.visibility, e.club_id, e.created_by,
|
||||||
|
(
|
||||||
|
SELECT fa.name FROM exercise_focus_areas efa
|
||||||
|
JOIN focus_areas fa ON fa.id = efa.focus_area_id
|
||||||
|
WHERE efa.exercise_id = e.id
|
||||||
|
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS primary_focus_name,
|
||||||
|
0.0::float AS ft_rank
|
||||||
|
FROM exercises e
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_exercise_rows_by_ids(
|
||||||
|
cur,
|
||||||
|
exercise_ids: Sequence[int],
|
||||||
|
*,
|
||||||
|
vis_sql: str,
|
||||||
|
vis_params: Sequence[Any],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Lädt konkrete Übungen nach, wenn sie im Graph/Slot verankert sind (Pin-Sicherheit)."""
|
||||||
|
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
ph = ",".join(["%s"] * len(ids))
|
||||||
|
sql = f"""
|
||||||
|
{_EXERCISE_ROW_SELECT.strip()}
|
||||||
|
WHERE e.id IN ({ph})
|
||||||
|
AND ({vis_sql})
|
||||||
|
AND COALESCE(e.status, '') <> %s
|
||||||
|
"""
|
||||||
|
params: List[Any] = list(ids) + list(vis_params) + ["archived"]
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_exercise_rows_by_ids_for_graph(
|
||||||
|
cur,
|
||||||
|
exercise_ids: Sequence[int],
|
||||||
|
*,
|
||||||
|
graph_visibility: str,
|
||||||
|
graph_club_id: Optional[int],
|
||||||
|
profile_id: int,
|
||||||
|
role: str,
|
||||||
|
exercise_allowed_fn,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Lädt Übungen nach ID mit Graph-Sichtbarkeitsregeln (nicht Library-vis_sql).
|
||||||
|
|
||||||
|
Ermöglicht Re-Match für im Graph verankerte private Übungen auf Club-Graphen
|
||||||
|
(eigene private) bzw. alle graph-konformen Übungen.
|
||||||
|
"""
|
||||||
|
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
ph = ",".join(["%s"] * len(ids))
|
||||||
|
sql = f"""
|
||||||
|
{_EXERCISE_ROW_SELECT.strip()}
|
||||||
|
WHERE e.id IN ({ph})
|
||||||
|
AND COALESCE(e.status, '') <> %s
|
||||||
|
"""
|
||||||
|
cur.execute(sql, [*ids, "archived"])
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for row in cur.fetchall() or []:
|
||||||
|
if exercise_allowed_fn(
|
||||||
|
row,
|
||||||
|
graph_visibility=graph_visibility,
|
||||||
|
graph_club_id=graph_club_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
role=role,
|
||||||
|
):
|
||||||
|
out.append(dict(row))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def trim_hits_preserving_priority_ids(
|
||||||
|
hits: Sequence[Mapping[str, Any]],
|
||||||
|
priority_ids: Optional[Sequence[int]],
|
||||||
|
*,
|
||||||
|
limit: int = 48,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Behält priorisierte Graph-/Slot-Übungen im Kandidatenpool (vor pick_best_path_hit)."""
|
||||||
|
priority_set = {int(x) for x in (priority_ids or []) if int(x) > 0}
|
||||||
|
if not priority_set:
|
||||||
|
return list(hits)[:limit]
|
||||||
|
by_id: Dict[int, Dict[str, Any]] = {}
|
||||||
|
for hit in hits:
|
||||||
|
try:
|
||||||
|
by_id[int(hit["id"])] = dict(hit)
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
continue
|
||||||
|
priority_hits = [by_id[eid] for eid in sorted(priority_set) if eid in by_id]
|
||||||
|
rest = [dict(h) for h in hits if int(h.get("id") or 0) not in priority_set]
|
||||||
|
merged = priority_hits + rest
|
||||||
|
return merged[: max(limit, len(priority_hits))]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_supplemental_exercise_rows(
|
||||||
|
rows: Sequence[Dict[str, Any]],
|
||||||
|
supplemental: Sequence[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
seen = {int(r["id"]) for r in rows if r.get("id") is not None}
|
||||||
|
out = list(rows)
|
||||||
|
for row in supplemental:
|
||||||
|
rid = int(row["id"])
|
||||||
|
if rid not in seen:
|
||||||
|
seen.add(rid)
|
||||||
|
out.append(dict(row))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_visible_exercise_rows(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
vis_sql: str,
|
||||||
|
vis_params: Sequence[Any],
|
||||||
|
query: str,
|
||||||
|
exercise_kind_any: Optional[List[str]],
|
||||||
|
max_rows: int = _MAX_LIBRARY_ROWS,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
S1b-0: Alle sichtbaren Übungen (ohne Profil-/Volltext-Pool-Vorselektion).
|
||||||
|
|
||||||
|
Hard-Filter: Governance, nicht archiviert, optional exercise_kind.
|
||||||
|
Volltext-Rank nur als Score-Signal in SELECT, nicht als WHERE-Filter.
|
||||||
|
"""
|
||||||
|
where = [vis_sql, "COALESCE(e.status, '') <> %s"]
|
||||||
|
params: List[Any] = []
|
||||||
|
|
||||||
|
if query:
|
||||||
|
ft_select = "ts_rank_cd(e.search_vector, plainto_tsquery('german', %s)) AS ft_rank"
|
||||||
|
params.append(query)
|
||||||
|
else:
|
||||||
|
ft_select = "0.0::float AS ft_rank"
|
||||||
|
|
||||||
|
params.extend(vis_params)
|
||||||
|
params.append("archived")
|
||||||
|
|
||||||
|
ek_filtered = _normalize_exercise_kind_filter(exercise_kind_any)
|
||||||
|
if ek_filtered:
|
||||||
|
ph = ",".join(["%s"] * len(ek_filtered))
|
||||||
|
where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))")
|
||||||
|
params.extend(ek_filtered)
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT e.id, e.title, e.summary, e.method_archetype,
|
||||||
|
(
|
||||||
|
SELECT fa.name FROM exercise_focus_areas efa
|
||||||
|
JOIN focus_areas fa ON fa.id = efa.focus_area_id
|
||||||
|
WHERE efa.exercise_id = e.id
|
||||||
|
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS primary_focus_name,
|
||||||
|
{ft_select}
|
||||||
|
FROM exercises e
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY e.id ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
params.append(int(max_rows))
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_match_profiles_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH):
|
||||||
|
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
out: Dict[int, Any] = {}
|
||||||
|
for i in range(0, len(ids), batch):
|
||||||
|
chunk = ids[i : i + batch]
|
||||||
|
out.update(load_exercise_match_profiles_bulk(cur, chunk))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skill_sets_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH) -> Dict[int, Set[int]]:
|
||||||
|
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||||
|
out: Dict[int, Set[int]] = {eid: set() for eid in ids}
|
||||||
|
if not ids:
|
||||||
|
return out
|
||||||
|
for i in range(0, len(ids), batch):
|
||||||
|
chunk = ids[i : i + batch]
|
||||||
|
ph = ",".join(["%s"] * len(chunk))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT exercise_id, skill_id FROM exercise_skills WHERE exercise_id IN ({ph})",
|
||||||
|
chunk,
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
eid = int(row["exercise_id"])
|
||||||
|
sid = row.get("skill_id")
|
||||||
|
if sid is not None:
|
||||||
|
out.setdefault(eid, set()).add(int(sid))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _load_exercise_goals_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH) -> Dict[int, str]:
|
||||||
|
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||||
|
out: Dict[int, str] = {}
|
||||||
|
if not ids:
|
||||||
|
return out
|
||||||
|
for i in range(0, len(ids), batch):
|
||||||
|
chunk = ids[i : i + batch]
|
||||||
|
ph = ",".join(["%s"] * len(chunk))
|
||||||
|
cur.execute(f"SELECT id, goal FROM exercises WHERE id IN ({ph})", chunk)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
out[int(row["id"])] = strip_html_to_plain(row.get("goal"), max_len=1200)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _load_variant_names_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH) -> Dict[int, List[str]]:
|
||||||
|
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||||
|
out: Dict[int, List[str]] = {eid: [] for eid in ids}
|
||||||
|
if not ids:
|
||||||
|
return out
|
||||||
|
for i in range(0, len(ids), batch):
|
||||||
|
chunk = ids[i : i + batch]
|
||||||
|
ph = ",".join(["%s"] * len(chunk))
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT exercise_id, variant_name FROM exercise_variants
|
||||||
|
WHERE exercise_id IN ({ph})
|
||||||
|
ORDER BY sequence_order ASC NULLS LAST, id ASC
|
||||||
|
""",
|
||||||
|
chunk,
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
eid = int(row["exercise_id"])
|
||||||
|
name = str(row.get("variant_name") or "").strip()
|
||||||
|
if name:
|
||||||
|
out.setdefault(eid, []).append(name[:80])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def rank_visible_library_hits(
|
||||||
|
cur,
|
||||||
|
rows: Sequence[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
intent: str,
|
||||||
|
intent_weights: Mapping[str, float],
|
||||||
|
target: PlanningTargetProfile,
|
||||||
|
pack: Mapping[str, Any],
|
||||||
|
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]]]:
|
||||||
|
"""S1b-1: Hybrid-Score auf der gesamten sichtbaren Bibliothek."""
|
||||||
|
planned_set = set(pack.get("planned_exercise_ids") or [])
|
||||||
|
group_recent_set = set(pack.get("group_recent_exercise_ids") or [])
|
||||||
|
progression_set = set(pack.get("progression_successor_ids") or [])
|
||||||
|
anchor_skills = set(pack.get("anchor_skill_ids") or [])
|
||||||
|
anchor_id = pack.get("anchor_exercise_id")
|
||||||
|
progression_notes = pack.get("progression_edge_notes") or {}
|
||||||
|
requires_partner = pack.get("requires_partner")
|
||||||
|
semantic_brief_raw = pack.get("semantic_brief")
|
||||||
|
semantic_brief: Optional[PlanningSemanticBrief] = None
|
||||||
|
if isinstance(semantic_brief_raw, PlanningSemanticBrief):
|
||||||
|
semantic_brief = semantic_brief_raw
|
||||||
|
step_phase = pack.get("path_step_phase")
|
||||||
|
path_mode = pack.get("context_mode") == "progression_path"
|
||||||
|
stage_learning_goal = (pack.get("stage_learning_goal") or "").strip()
|
||||||
|
roadmap_stage_match = bool(pack.get("roadmap_stage_match"))
|
||||||
|
stage_match_brief_raw = pack.get("stage_match_brief")
|
||||||
|
stage_match_brief: Optional[PlanningSemanticBrief] = None
|
||||||
|
if isinstance(stage_match_brief_raw, PlanningSemanticBrief):
|
||||||
|
stage_match_brief = stage_match_brief_raw
|
||||||
|
elif roadmap_stage_match and stage_learning_goal:
|
||||||
|
stage_match_brief = build_stage_match_brief(
|
||||||
|
learning_goal=stage_learning_goal,
|
||||||
|
anti_patterns=pack.get("stage_anti_patterns"),
|
||||||
|
success_criteria=pack.get("stage_success_criteria"),
|
||||||
|
load_profile=pack.get("stage_load_profile"),
|
||||||
|
phase=step_phase,
|
||||||
|
path_context_note=pack.get("path_context_note"),
|
||||||
|
)
|
||||||
|
|
||||||
|
last_planned_skills: Set[int] = set()
|
||||||
|
planned_ids = pack.get("planned_exercise_ids") or []
|
||||||
|
if planned_ids:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT skill_id FROM exercise_skills WHERE exercise_id = %s",
|
||||||
|
(int(planned_ids[-1]),),
|
||||||
|
)
|
||||||
|
last_planned_skills = {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")}
|
||||||
|
|
||||||
|
cand_rows: List[Dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
eid = int(row["id"])
|
||||||
|
if anchor_id and eid == int(anchor_id):
|
||||||
|
continue
|
||||||
|
if requires_partner is True and not _exercise_looks_partner_related(row):
|
||||||
|
continue
|
||||||
|
if requires_partner is False and _exercise_looks_partner_related(row):
|
||||||
|
continue
|
||||||
|
cand_rows.append(row)
|
||||||
|
|
||||||
|
cand_ids = [int(r["id"]) for r in cand_rows]
|
||||||
|
match_profiles = _load_match_profiles_chunked(cur, cand_ids)
|
||||||
|
skills_by_ex = _load_skill_sets_chunked(cur, cand_ids)
|
||||||
|
goals_by_ex: Dict[int, str] = {}
|
||||||
|
variants_by_ex: Dict[int, List[str]] = {}
|
||||||
|
need_exercise_semantic_text = (
|
||||||
|
(semantic_brief and semantic_brief.semantic_strength > 0.05)
|
||||||
|
or (stage_match_brief and stage_match_brief.semantic_strength > 0.05)
|
||||||
|
)
|
||||||
|
if need_exercise_semantic_text:
|
||||||
|
goals_by_ex = _load_exercise_goals_chunked(cur, cand_ids)
|
||||||
|
variants_by_ex = _load_variant_names_chunked(cur, cand_ids)
|
||||||
|
|
||||||
|
max_ft = 0.0
|
||||||
|
scored_items: List[Dict[str, Any]] = []
|
||||||
|
for row in cand_rows:
|
||||||
|
eid = int(row["id"])
|
||||||
|
ft = float(row.get("ft_rank") or 0.0)
|
||||||
|
if ft > max_ft:
|
||||||
|
max_ft = ft
|
||||||
|
scored_items.append(
|
||||||
|
{
|
||||||
|
"row": row,
|
||||||
|
"eid": eid,
|
||||||
|
"ft": ft,
|
||||||
|
"skills": skills_by_ex.get(eid, set()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
weights = dict(intent_weights)
|
||||||
|
hits: List[Dict[str, Any]] = []
|
||||||
|
for item in scored_items:
|
||||||
|
eid = item["eid"]
|
||||||
|
row = item["row"]
|
||||||
|
ft_norm = (item["ft"] / max_ft) if max_ft > 0 else 0.0
|
||||||
|
prog_hit = 1.0 if eid in progression_set else 0.0
|
||||||
|
skill_sim = _skill_jaccard(anchor_skills, item["skills"]) if anchor_skills else 0.0
|
||||||
|
plan_aff = 0.0
|
||||||
|
if last_planned_skills and item["skills"]:
|
||||||
|
plan_aff = _skill_jaccard(last_planned_skills, item["skills"])
|
||||||
|
repeat_unit = 1.0 if eid in planned_set else 0.0
|
||||||
|
repeat_group = 1.0 if eid in group_recent_set else 0.0
|
||||||
|
profile_score = 0.0
|
||||||
|
profile_reasons: List[str] = []
|
||||||
|
emp = match_profiles.get(eid)
|
||||||
|
if emp:
|
||||||
|
profile_score, profile_reasons = score_exercise_against_target(
|
||||||
|
emp, target, intent=intent
|
||||||
|
)
|
||||||
|
|
||||||
|
title_s = str(row.get("title") or "")
|
||||||
|
summary_s = str(row.get("summary") or "")
|
||||||
|
goal_s = goals_by_ex.get(eid, "")
|
||||||
|
|
||||||
|
semantic_score = 0.0
|
||||||
|
semantic_reasons: List[str] = []
|
||||||
|
if semantic_brief and semantic_brief.semantic_strength > 0.05:
|
||||||
|
semantic_score, semantic_reasons = score_exercise_semantic_relevance(
|
||||||
|
title=title_s,
|
||||||
|
summary=summary_s,
|
||||||
|
goal=goal_s,
|
||||||
|
variant_names=variants_by_ex.get(eid, []),
|
||||||
|
brief=semantic_brief,
|
||||||
|
step_phase=step_phase,
|
||||||
|
)
|
||||||
|
|
||||||
|
stage_semantic_score = 0.0
|
||||||
|
stage_semantic_reasons: List[str] = []
|
||||||
|
if stage_match_brief and stage_match_brief.semantic_strength > 0.05:
|
||||||
|
stage_semantic_score, stage_semantic_reasons = score_exercise_stage_fit(
|
||||||
|
title=title_s,
|
||||||
|
summary=summary_s,
|
||||||
|
goal=goal_s,
|
||||||
|
variant_names=variants_by_ex.get(eid, []),
|
||||||
|
stage_brief=stage_match_brief,
|
||||||
|
step_phase=step_phase,
|
||||||
|
)
|
||||||
|
|
||||||
|
rank_stage_sem = stage_semantic_score
|
||||||
|
stage_lg = (stage_learning_goal or "").strip()
|
||||||
|
if roadmap_stage_match and stage_lg:
|
||||||
|
raw_brief = build_stage_match_brief(
|
||||||
|
learning_goal=stage_lg,
|
||||||
|
anti_patterns=pack.get("stage_anti_patterns"),
|
||||||
|
phase=step_phase,
|
||||||
|
)
|
||||||
|
raw_sem, raw_reasons = score_exercise_stage_fit(
|
||||||
|
title=title_s,
|
||||||
|
summary=summary_s,
|
||||||
|
goal=goal_s,
|
||||||
|
variant_names=variants_by_ex.get(eid, []),
|
||||||
|
stage_brief=raw_brief,
|
||||||
|
step_phase=step_phase,
|
||||||
|
)
|
||||||
|
rank_stage_sem = max(stage_semantic_score, raw_sem)
|
||||||
|
if raw_sem > stage_semantic_score and raw_reasons:
|
||||||
|
for rr in raw_reasons:
|
||||||
|
if rr not in stage_semantic_reasons:
|
||||||
|
stage_semantic_reasons.append(rr)
|
||||||
|
|
||||||
|
effective_semantic = (
|
||||||
|
rank_stage_sem
|
||||||
|
if roadmap_stage_match and stage_match_brief
|
||||||
|
else semantic_score
|
||||||
|
)
|
||||||
|
|
||||||
|
score_penalty = 0.0
|
||||||
|
stage_match_reason: Optional[str] = None
|
||||||
|
if (
|
||||||
|
path_mode
|
||||||
|
and not roadmap_stage_match
|
||||||
|
and semantic_brief
|
||||||
|
and semantic_brief.semantic_strength >= 0.55
|
||||||
|
and not exercise_passes_path_semantic_gate(
|
||||||
|
semantic_score=semantic_score,
|
||||||
|
title=title_s,
|
||||||
|
summary=summary_s,
|
||||||
|
goal=goal_s,
|
||||||
|
brief=semantic_brief,
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
score_penalty = 0.42
|
||||||
|
if roadmap_stage_match and stage_learning_goal:
|
||||||
|
if exercise_passes_stage_fit(
|
||||||
|
learning_goal=stage_learning_goal,
|
||||||
|
title=title_s,
|
||||||
|
summary=summary_s,
|
||||||
|
goal=goal_s,
|
||||||
|
stage_brief=stage_match_brief,
|
||||||
|
stage_semantic_score=rank_stage_sem,
|
||||||
|
anti_patterns=pack.get("stage_anti_patterns"),
|
||||||
|
step_phase=step_phase,
|
||||||
|
path_primary_topic=pack.get("path_primary_topic"),
|
||||||
|
path_technique_excludes=pack.get("path_technique_excludes"),
|
||||||
|
):
|
||||||
|
score_penalty = max(0.0, score_penalty - 0.10)
|
||||||
|
stage_match_reason = "Passt zum Stufen-Lernziel"
|
||||||
|
else:
|
||||||
|
score_penalty += 0.48
|
||||||
|
|
||||||
|
score = (
|
||||||
|
weights.get("semantic", 0.0) * effective_semantic
|
||||||
|
+ weights["fulltext"] * ft_norm
|
||||||
|
+ weights["progression"] * prog_hit
|
||||||
|
+ weights["skill"] * skill_sim
|
||||||
|
+ weights["plan"] * plan_aff
|
||||||
|
+ weights["profile"] * profile_score
|
||||||
|
+ weights["repeat_unit"] * repeat_unit
|
||||||
|
+ weights["repeat_group"] * repeat_group
|
||||||
|
- score_penalty
|
||||||
|
)
|
||||||
|
|
||||||
|
reasons: List[str] = []
|
||||||
|
if stage_match_reason:
|
||||||
|
reasons.append(stage_match_reason)
|
||||||
|
if roadmap_stage_match and stage_semantic_score >= 0.30 and stage_semantic_reasons:
|
||||||
|
for sr in stage_semantic_reasons:
|
||||||
|
if sr not in reasons:
|
||||||
|
reasons.append(sr)
|
||||||
|
elif semantic_score >= 0.35 and semantic_reasons:
|
||||||
|
for sr in semantic_reasons:
|
||||||
|
if sr not in reasons:
|
||||||
|
reasons.append(sr)
|
||||||
|
if query and ft_norm >= 0.35:
|
||||||
|
reasons.append("Volltext-Treffer")
|
||||||
|
if prog_hit > 0:
|
||||||
|
note = progression_notes.get(eid)
|
||||||
|
reasons.append(
|
||||||
|
f"Nachfolger im Progressionsgraph{f': {note}' if note else ''}"
|
||||||
|
)
|
||||||
|
if skill_sim >= 0.2 and anchor_id:
|
||||||
|
reasons.append("Fähigkeiten passen zur Anker-Übung")
|
||||||
|
if plan_aff >= 0.25:
|
||||||
|
reasons.append("Schließt an Skills der letzten geplanten Übung an")
|
||||||
|
if repeat_unit > 0:
|
||||||
|
reasons.append("Bereits in dieser Einheit eingeplant")
|
||||||
|
if repeat_group > 0 and repeat_unit <= 0:
|
||||||
|
reasons.append("Kürzlich in der Gruppe verwendet")
|
||||||
|
for pr in profile_reasons:
|
||||||
|
if pr not in reasons:
|
||||||
|
reasons.append(pr)
|
||||||
|
|
||||||
|
if score <= 0 and not reasons and not query:
|
||||||
|
if prog_hit or skill_sim or plan_aff or profile_score:
|
||||||
|
score = 0.05 + prog_hit * 0.3 + skill_sim * 0.2 + profile_score * 0.25
|
||||||
|
|
||||||
|
hits.append(
|
||||||
|
{
|
||||||
|
"id": eid,
|
||||||
|
"title": row.get("title"),
|
||||||
|
"summary": row.get("summary"),
|
||||||
|
"focus_area": row.get("primary_focus_name"),
|
||||||
|
"score": round(max(0.0, min(1.0, score)), 4),
|
||||||
|
"reasons": reasons,
|
||||||
|
"semantic_score": round(semantic_score, 4),
|
||||||
|
"stage_semantic_score": round(stage_semantic_score, 4),
|
||||||
|
"stage_rank_semantic": round(rank_stage_sem, 4),
|
||||||
|
"goal": goal_s,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
succ_variants = pack.get("progression_successor_variants") or {}
|
||||||
|
suggested_vid = succ_variants.get(eid)
|
||||||
|
if suggested_vid:
|
||||||
|
hits[-1]["suggested_variant_id"] = int(suggested_vid)
|
||||||
|
|
||||||
|
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
|
||||||
|
return hits, skills_by_ex
|
||||||
|
|
||||||
|
|
||||||
|
def run_multistage_planning_retrieval(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
vis_sql: str,
|
||||||
|
vis_params: Sequence[Any],
|
||||||
|
query: str,
|
||||||
|
exercise_kind_any: Optional[List[str]],
|
||||||
|
target: PlanningTargetProfile,
|
||||||
|
intent: str,
|
||||||
|
intent_weights: Mapping[str, float],
|
||||||
|
pack: Mapping[str, Any],
|
||||||
|
supplemental_exercise_ids: Optional[Sequence[int]] = None,
|
||||||
|
supplemental_rows_preloaded: Optional[Sequence[Dict[str, Any]]] = None,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]:
|
||||||
|
"""Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking)."""
|
||||||
|
rows = fetch_all_visible_exercise_rows(
|
||||||
|
cur,
|
||||||
|
vis_sql=vis_sql,
|
||||||
|
vis_params=vis_params,
|
||||||
|
query=pack.get("retrieval_query") or query,
|
||||||
|
exercise_kind_any=exercise_kind_any,
|
||||||
|
)
|
||||||
|
if supplemental_rows_preloaded:
|
||||||
|
rows = merge_supplemental_exercise_rows(rows, supplemental_rows_preloaded)
|
||||||
|
elif supplemental_exercise_ids:
|
||||||
|
extra = fetch_exercise_rows_by_ids(
|
||||||
|
cur,
|
||||||
|
supplemental_exercise_ids,
|
||||||
|
vis_sql=vis_sql,
|
||||||
|
vis_params=vis_params,
|
||||||
|
)
|
||||||
|
rows = merge_supplemental_exercise_rows(rows, extra)
|
||||||
|
hits, skills_by_ex = rank_visible_library_hits(
|
||||||
|
cur,
|
||||||
|
rows,
|
||||||
|
query=query,
|
||||||
|
intent=intent,
|
||||||
|
intent_weights=intent_weights,
|
||||||
|
target=target,
|
||||||
|
pack=pack,
|
||||||
|
)
|
||||||
|
full_library_ranked = len(rows) > 0
|
||||||
|
return hits, skills_by_ex, full_library_ranked
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy-Alias für Tests / externe Imports
|
||||||
|
fetch_retrieval_candidate_rows = fetch_all_visible_exercise_rows
|
||||||
|
hybrid_score_planning_hits = rank_visible_library_hits
|
||||||
|
|
||||||
|
|
||||||
|
def profile_preselect_rows(
|
||||||
|
cur,
|
||||||
|
rows: Sequence[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
target: PlanningTargetProfile,
|
||||||
|
intent: str,
|
||||||
|
progression_successor_ids: Set[int],
|
||||||
|
query: str,
|
||||||
|
preselect_limit: int = 160,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||||
|
"""Deprecated: Phase A rankt die volle Library — keine separate Vorselektion."""
|
||||||
|
_ = (cur, target, intent, progression_successor_ids, query, preselect_limit)
|
||||||
|
return list(rows), False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"fetch_all_visible_exercise_rows",
|
||||||
|
"fetch_exercise_rows_by_ids",
|
||||||
|
"fetch_retrieval_candidate_rows",
|
||||||
|
"hybrid_score_planning_hits",
|
||||||
|
"merge_supplemental_exercise_rows",
|
||||||
|
"profile_preselect_rows",
|
||||||
|
"rank_visible_library_hits",
|
||||||
|
"run_multistage_planning_retrieval",
|
||||||
|
]
|
||||||
1643
backend/planning_exercise_semantics.py
Normal file
1643
backend/planning_exercise_semantics.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user