Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea7de64061 | |||
| 7265cd5a01 | |||
| 5e5f4ca8d4 | |||
| f0e581a9f5 | |||
| cd457e3ea0 | |||
| e9bf5bd1a5 | |||
| 3468b2066e | |||
| a1e4ad66df | |||
| 85fccdd093 | |||
| 19bbcdaf50 | |||
| cec96ae473 | |||
| 53f1c7161f | |||
| 89c6780294 | |||
| 3f130aa8ad | |||
| 69ce3f6975 | |||
| dccb065181 | |||
| e828a5da32 | |||
| 5bca5ef9eb | |||
| 5ed06002d9 | |||
| b8f65e04c5 | |||
| f3710ac0a1 | |||
| dbc2dfacb9 | |||
| 6ab2f20f08 | |||
| a4e73c830f | |||
| 63c99b0ec5 | |||
| d448c3191f | |||
| 8a4be795f4 | |||
| a49987408b | |||
| f36a747efa | |||
| de9fdf3ac0 | |||
| 9b4d091637 | |||
| df93da9a03 | |||
| de939481ba | |||
| 6d130a7e09 | |||
| b2fbf6b4af | |||
| ca2adbd55e | |||
| ad051c015f | |||
| b464047c3a | |||
| 7203c871fc | |||
| 480890d0c6 | |||
| 8f1dad53ab | |||
| 044ce2ee60 | |||
| f63b09fc9c | |||
| 713a344d17 | |||
| 1d94c2ebf1 | |||
| a152218c45 | |||
| 4ef3f00e6b | |||
| 3c12363b8f | |||
| 07e147bc76 | |||
| 18547613ea | |||
| 48d51c07c5 | |||
| 3b483346de | |||
| e0ddfa6ce5 | |||
| ee22b22970 | |||
| c1bf9279ad | |||
| 97efe66306 | |||
| 8d5f0b533c | |||
| 800189ff8f | |||
| 3be7606d90 | |||
| ca3a9c6fa4 | |||
| 5692931d07 | |||
| 98b279fa89 | |||
| 1e7941f57b | |||
| 0adf20c9e1 | |||
| 4724da28b1 | |||
| d4b1780193 | |||
| f2650dac57 | |||
| fad1058d54 | |||
| 9dd44ce3ca | |||
| 87f258be38 | |||
| 779e2477ba | |||
| f074a8bef0 | |||
| 0677663268 | |||
| d4e9bded23 | |||
| 7411543a97 | |||
| dd0fae4bf5 | |||
| a9a6153ed5 | |||
| 4130a63dfe | |||
| 9d52aeab67 | |||
| b68185842e | |||
| 40641594ac | |||
| e4cb491d46 | |||
| 8404a42b6c | |||
| fa10450315 | |||
| 37785135b1 | |||
| 8ee8f52e0f | |||
| 8718cf5c70 | |||
| 91dae7b614 | |||
| 20927a5969 | |||
| 7db77f4738 | |||
| 3e87f7515a | |||
| a2f60d3f46 | |||
| 30dc30c7aa | |||
| 7cfbca40bb | |||
| c294c27de8 | |||
| 50c9beb4b3 | |||
| bd5a409fa7 | |||
| 3450a9296a | |||
| 29a5db63e0 | |||
| 8d1dd59c3c | |||
| 5b73d1a1f5 | |||
| c2c736dafc | |||
| c6b8c396ad | |||
| a19ed02300 | |||
| 6db31e7312 | |||
| a34e748be5 | |||
| 16187fbbd0 | |||
| b2157d8a40 | |||
| 50aff849d8 | |||
| a0a891e550 | |||
| 9ba35dc022 | |||
| 46fae3da33 | |||
| f4196c3580 | |||
| d1d8539b42 | |||
| a8633235f2 | |||
| 5c882985e0 | |||
| 04cc77d501 | |||
| 8e68261bc1 | |||
| b0611b9f7f | |||
| 614c2dcfaa | |||
| f5c886fc13 | |||
| d019c20338 | |||
| 905bce198f | |||
| 45e3b5f4f6 | |||
| 207817376d | |||
| 128a9d752e | |||
| d7d45a8927 | |||
| fc5748bef1 | |||
| 9d880e2346 | |||
| c816e50c68 | |||
| 294740b780 | |||
| 675cfa85f0 | |||
| 4725eaa90b | |||
| 9f4678f418 | |||
| 5331eab39c | |||
| 93b8d09d05 | |||
| 0551bb3d80 | |||
| 3bf012a8f4 | |||
| e22266a18c | |||
| d58db3d5dd | |||
| cdeddc7cec | |||
| 2148d0aa7f | |||
| 69f238d9b8 | |||
| f9e295bce0 | |||
| 888d0bd009 | |||
| 1942585546 | |||
| a28a9d399a | |||
| 9be69ace5c | |||
| 286c36e9d7 | |||
| 294b09a5d9 | |||
| e5291256d0 | |||
| 4d36bbf634 | |||
| e4451e1362 |
|
|
@ -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).
|
||||
|
||||
**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**): 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)
|
||||
|
||||
|
|
@ -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] **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] 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] 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 ✓
|
||||
- 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)
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
**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)
|
||||
|
||||
**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 |
|
||||
| [**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 |
|
||||
| [**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`**.
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
|||
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
||||
|
||||
- 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.
|
||||
- **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.
|
||||
|
|
@ -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**.
|
||||
- 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`)
|
||||
|
|
@ -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 |
|
||||
|--------|----------|
|
||||
|
|
|
|||
|
|
@ -79,16 +79,18 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
|||
|
||||
### 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.
|
||||
|
||||
### Stufe F – Community (eigenes Epic)
|
||||
|
||||
- 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
|
||||
|
||||
- **`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/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
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
**Version:** 1.1
|
||||
**Datum:** 2026-05-30
|
||||
**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
|
||||
**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
|
||||
|
|
@ -28,6 +37,7 @@ steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.
|
|||
|-------------|-----------|
|
||||
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
|
||||
| `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 |
|
||||
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
|
||||
| `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)
|
||||
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
|
||||
- intensity: Trainingsintensität (niedrig|mittel|hoch)
|
||||
- is_primary: true wenn Hauptfähigkeit
|
||||
|
||||
Antworte NUR als JSON-Array:
|
||||
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
||||
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch"}]
|
||||
|
||||
Wenn keine Fähigkeit passt, antworte mit [].$$,
|
||||
'exercise', 'json', true, NULL, 2),
|
||||
|
|
@ -597,6 +606,19 @@ AI_PROMPT_SYSTEM_SPEC: ai_service.run_ai_prompt("exercise_summary", ...)
|
|||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
## 8. Prompt-Kaskaden (geplant — nicht implementiert)
|
||||
|
||||
**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
|
||||
|
||||
**Version:** 0.1
|
||||
**Datum:** 2026-05-16
|
||||
**Version:** 0.3
|
||||
**Datum:** 2026-05-22
|
||||
**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:**
|
||||
`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`).
|
||||
- **Governance-first:** Nur Übungen, die die API bereits für den Mandanten/Kontext **sichtbar** freigibt, dürfen in Kandidatenlisten landen – **vor** Retrieval und **vor** jedem Prompt.
|
||||
|
||||
### 1.1 Abgleich: aktueller Code- und Schema-Stand (Stand Review 2026-05-22)
|
||||
|
||||
| Thema | Ist im Repo | Konsequenz für dieses Konzept |
|
||||
|--------|-------------|-------------------------------|
|
||||
| **OpenRouter / LLM im Backend** | Produktiver Aufruf für Übungs‑Suggest in `openrouter_chat.py`, `exercise_ai.py`; Endpunkte **`POST …/exercises/ai/suggest`** und **`POST …/{id}/ai/regenerate`**; Migration **067** (`ai_prompts`, `summary_ai_generated`). **`db.py`**-Bootstrap nutzt **`display_name`**. | **Übungs-Assistent (P0)** vorhanden; generalisierter Service + **Planungs-KI** folgen. |
|
||||
| **Übungs-KI laut Spec** | P0: Kurzfassung + Skill‑Vorschläge (`include_summary` / `include_skills`); **kein** Auto-KI beim Speichern (S5 im Umsetzungsplan). | Feinspez: `summary_ai_generated` bei manueller Kurzfassung zurücksetzen; Rate-Limits; Prompt-Admin-UI. |
|
||||
| **Fähigkeiten-Stammdaten** | Migration **`065_skills_wiki_karate_relevance`:** `skills.karate_relevance` (Text), `skills.relevance_level` (1–3, optional); dazu weiterhin `description`, `focus_areas`, Kategorien, `skill_level_definitions` (Level 1–5 je Skill). | Diese Felder sind **expliziter Prompt-Kontext** für Skill-Vorschläge (Disambiguierung Karate vs. universal) – siehe §6. |
|
||||
| **Skill-Scoring & Discovery (ohne LLM)** | Router `skill_profiles.py` + Modul `skill_scoring.py`: u. a. `GET …/skill-profile` für **Rahmenprogramm**, **Trainingsmodul**, **Progressionsgraph**; `POST /skill-profiles/batch-summaries`; **`GET /api/skill-discovery/suggestions`** (Match Bibliotheksartefakte ⇄ `skill_ids`, mit `library_content_visibility_sql`). | Ergänzt §3 **Stufe 3**: deterministische **Skill-Abdeckung / Artefakt-Discovery** ist **bereits vorhanden** und kann später die **Planungs-KI** speisen (Ziel-Skill-Mengen, Vergleich „Profil des Rahmens“) – ersetzt aber **nicht** die Top‑K-Selektion aus dem **Übungskatalog** für eine konkrete Session. |
|
||||
| **Profil / Planungs-Präferenzen** | `profiles.training_planning_prefs` (JSONB, vgl. `MODULE_VERSIONS` → `profiles`), Planungsmodul mit u. a. **Vorlagen inkl. Split-Sessions** (`planning`), `training_units` mit **Publish in Rahmen-Slot-Blueprint**. | Zukünftige KI-Planung kann **Prefs** und **Vorlagen-Struktur** als weiche Constraints einbeziehen; Rahmen↔Einheit-Fluss ist produktiv erweitert – für KI nur relevant, sobald Planungs-Endpunkte angebunden werden. |
|
||||
| **Übungsliste API** | Keyset-Pagination u. a. `cursor_updated_at` + Tie-break `id` (`exercises`-Modul laut `MODULE_VERSIONS`). | Retrieval-Pipelines sollten **cursorbasiert** paginieren, nicht „alle IDs auf einmal“ laden. |
|
||||
|
||||
**Nächster produktiver Fokus:** Prompt-/Admin‑UI zur Pflege von `ai_prompts`, **Rate-Limits**, optional **Auto-KI beim Speichern**; danach Übergang zur **Planungs-KI** laut diesem Dokument.
|
||||
|
||||
**Architektur-Vorschau (Planungs-KI):** Damit die **kleinere, starre** Übungs-Pipeline nicht zur stillen Vorlage für Planung wird, sind **eigenes Modul**, **stufenweise Outputs mit Validierung** und ein **kompaktes Kontext-DTO** vorgesehen — siehe **`working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md`**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kernproblem: Skalierung des Kontextes
|
||||
|
||||
Aus einer **großen Übungssammlung** („>1000 Übungen“) können weder alle **Felder** (Ziele, Ablauf, Skills, Varianten …) noch alle **Zeilen** sinnvoll in einen LLM-Prompt.
|
||||
|
||||
**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 |
|
||||
|-----------|--------|
|
||||
|
|
@ -69,7 +88,8 @@ Mindestens **eine** der folgenden Optionen – kombinierbar:
|
|||
1. **Skill-/Facet-Overlap:** Punktezahl, wenn Übungs-Skills mit Ziel-/Matrix-Schwerpunkten übereinstimmen (bereits Daten in `exercise_skills`).
|
||||
2. **Diversitäts-/Wiederholungsstrafe:** häufig in letzten Wochen geübte Übungen abwerten.
|
||||
3. **Textsuche:** PostgreSQL **`tsvector`/Volltext** auf `title`, `summary`, ggf. gekürzte `goal` – für Trainer-Stichwort „Koordination Sprung“.
|
||||
4. **Semantische Suche:** Embeddings + **Ähnlichkeitsuche** auf Kurztexte (siehe §5).
|
||||
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.
|
||||
|
||||
|
|
@ -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:
|
||||
|
||||
- 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;
|
||||
- **Progressionsgraph** dort, wo pädagogische Ketten gefestigt sind;
|
||||
- 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)
|
||||
|
||||
Priorität **jetzt**: **Übungsanlage**, danach **Planung**.
|
||||
|
||||
| Stufe | Nutzen | Technik-Schwerpunkt |
|
||||
|-------|--------|---------------------|
|
||||
| A | Backend-KI-Service + Prompt-Slugs unter `technical/AI_PROMPT_SYSTEM_SPEC.md` | OpenRouter, Timeouts, 503 ohne Key |
|
||||
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 1–3 + Prompt mit Top‑K |
|
||||
| **A0** | **Zentraler KI-Service** (ein Modul/Hilfslayer), Prompts aus `ai_prompts` | OpenRouter oder vereinbarter Provider, Timeouts, `503` ohne Key, Parsing/Validation |
|
||||
| **A1** | **Übungsanlage** (vgl. `KI_FEATURES_SPEC`): `summary`, Skill-Vorschläge inkl. Stufen/Intensität, optional Textglättung | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate`; Prompt-Kontext: Skills mit `description`, `karate_relevance`, `relevance_level`, optional `skill_level_definitions` für Kurzliste; DB: `summary_ai_generated`, `exercise_skills.ai_suggested` |
|
||||
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 1–3 + Prompt mit Top‑K (Übungsliste **keyset-pagination** beachten) |
|
||||
| C | Reihenfolge / Zeitslots innerhalb bestehender Sektion | Graph + LLM Ranking |
|
||||
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertere JSON-Ausgabe, strikte Schema-Validation |
|
||||
| E | Mehreinheiten / Rahmen‑Alignment | Ziele aus Rahmenprogramm, Serie von Slots |
|
||||
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertes JSON + strikte Schema-Validation gegen bestehende `PUT`-Payloads |
|
||||
| E | Mehreinheiten / Rahmen‑Alignment | Ziele aus Rahmenprogramm, Serie von Slots; **Skill-Profile** (`…/skill-profile`) als Kontextuelle Verstärker |
|
||||
|
||||
Die **Selektions‑Pipeline §3 bleibt** ü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.
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
# Exercises API Specification
|
||||
|
||||
**Version:** 1.5
|
||||
**Datum:** 2026-05-08
|
||||
**Version:** 1.6
|
||||
**Datum:** 2026-05-20
|
||||
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe 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.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.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
|
||||
|
|
@ -185,11 +186,11 @@ Lightweight-Liste; bei `include_variants=true` zusätzlich z. B.:
|
|||
"skill_id": 10,
|
||||
"skill_name": "Distanzgefühl",
|
||||
"skill_category": "Kumite",
|
||||
"is_primary": true,
|
||||
"intensity": "hoch",
|
||||
"required_level": "grundlagen",
|
||||
"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": [
|
||||
{
|
||||
"skill_id": 10,
|
||||
"is_primary": true,
|
||||
"intensity": "hoch",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau"
|
||||
|
|
@ -578,7 +578,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
|||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"intensity": "hoch",
|
||||
"is_primary": true,
|
||||
"confidence": 0.92
|
||||
},
|
||||
{
|
||||
|
|
@ -588,7 +587,6 @@ Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
|||
"required_level": "einsteiger",
|
||||
"target_level": "grundlagen",
|
||||
"intensity": "mittel",
|
||||
"is_primary": false,
|
||||
"confidence": 0.74
|
||||
}
|
||||
]
|
||||
|
|
@ -621,6 +619,38 @@ Trainer muss im Frontend aktiv übernehmen.
|
|||
|
||||
## 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
|
||||
|
||||
| Von → Nach | Wer darf das? |
|
||||
|
|
@ -638,11 +668,12 @@ Trainer muss im Frontend aktiv übernehmen.
|
|||
| `club → official` | Club-Admin, Super-Admin |
|
||||
| `official → club` | Super-Admin |
|
||||
|
||||
### Owner-Checks
|
||||
### Owner-Checks (veraltet — siehe Tabellen oben)
|
||||
|
||||
- **Bearbeiten** (PUT): Nur Ersteller oder Club-Admin
|
||||
- **Löschen** (DELETE): Nur Ersteller oder Super-Admin
|
||||
- **Lesen** (`private`): Nur Ersteller
|
||||
Die folgenden Kurzregeln sind durch die Ist-Implementierung ersetzt; nur zur historischen Einordnung:
|
||||
|
||||
- ~~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:**
|
||||
```json
|
||||
|
|
@ -904,7 +935,8 @@ Trainer muss im Frontend aktiv übernehmen.
|
|||
### Exercise Skills
|
||||
- `required_level`: enum – `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
|
||||
- `target_level`: enum – gleiche Werte (optional/nullable)
|
||||
- `intensity`: enum – `niedrig | mittel | hoch` (optional/nullable)
|
||||
- `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)
|
||||
|
||||
### Exercise Block Item
|
||||
|
|
|
|||
|
|
@ -99,20 +99,21 @@ Exercise Block ──── (N) Block Items ──── (1) Exercise
|
|||
|
||||
### 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_styles` (Übung ↔ Trainingsstile)
|
||||
- `exercise_styles` / `exercise_style_directions` (Übung ↔ Stilrichtungen)
|
||||
- `exercise_training_types` (Übung ↔ Trainingsstile)
|
||||
- `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
|
||||
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
|
||||
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension
|
||||
- **UI:** Primary wird visuell hervorgehoben (z.B. fett, farbig)
|
||||
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension (wo UI das noch anbietet)
|
||||
- **UI:** Primary wird visuell hervorgehoben (z. B. fett, farbig) — Fähigkeiten: Intensitäts-Segmente statt Primary
|
||||
|
||||
**Legacy-Felder (DEPRECATED):**
|
||||
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
# Frontend Routing & Navigation Specification
|
||||
|
||||
**Version:** 1.2
|
||||
**Datum:** 2026-04-30
|
||||
**Version:** 1.3
|
||||
**Datum:** 2026-05-20
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
**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.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
|
||||
|
||||
|
|
@ -17,7 +18,7 @@
|
|||
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
||||
/exercises/new → ExerciseFormPage (Create)
|
||||
/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/new → ExerciseBlockFormPage (Create)
|
||||
|
|
@ -35,6 +36,25 @@
|
|||
- Pagination: `/exercises?limit=50&offset=100`
|
||||
- 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
|
||||
|
|
@ -673,7 +693,7 @@ function App() {
|
|||
|
||||
---
|
||||
|
||||
**Version:** 1.2
|
||||
**Letzte Änderung:** 2026-04-30
|
||||
**Version:** 1.3
|
||||
**Letzte Änderung:** 2026-05-20
|
||||
**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)
|
||||
**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
|
||||
|
||||
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 |
|
||||
|---------|------|
|
||||
|
|
@ -155,7 +160,38 @@ KI gibt Vorschläge
|
|||
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
|
||||
Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||
|
||||
**Request Body:**
|
||||
**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
|
||||
{
|
||||
"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`
|
||||
```json
|
||||
{
|
||||
|
|
@ -182,7 +216,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
|||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"intensity": "hoch",
|
||||
"is_primary": true,
|
||||
"confidence": 0.92
|
||||
},
|
||||
{
|
||||
|
|
@ -192,7 +225,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
|||
"required_level": "einsteiger",
|
||||
"target_level": "grundlagen",
|
||||
"intensity": "mittel",
|
||||
"is_primary": false,
|
||||
"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
|
||||
|
||||
- **`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
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
||||
| exercises | `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 | 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 |
|
||||
| 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) |
|
||||
|
|
@ -32,18 +33,28 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
|
||||
| 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_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 |
|
||||
| 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.
|
||||
|
||||
**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)
|
||||
|
||||
- **2026-05-23:** Superadmin-API `exercise_enrichment_admin` (Batch-Übungs-Anreicherung KI) dokumentiert.
|
||||
- **2026-05-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert.
|
||||
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
||||
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||
|
||||
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
|
||||
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||
- **2026-05-07 (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.
|
||||
81
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
81
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Progressionsgraph — Slot-Editor (Phase B)
|
||||
|
||||
**Stand:** 2026-06-10 · **Status:** In Umsetzung
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Progressionsgraph = **ein linearer Hauptpfad** (Roadmap = strukturgebend). Jeder **Major Step** ist ein **Slot** mit:
|
||||
|
||||
- **primary** — Hauptübung des Slots (Pfadknoten)
|
||||
- **siblings** — 0..n Schwestern (gleiche Stufe, `edge_type: sibling`)
|
||||
|
||||
KI-Entwürfe und Bibliotheksübungen leben **im selben Slot-Modell**, ohne sofortige Übungsanlage.
|
||||
|
||||
## Slot-Zustände (`kind`)
|
||||
|
||||
| kind | Bedeutung |
|
||||
|------|-----------|
|
||||
| `empty` | Noch keine Übung |
|
||||
| `library` | `exercise_id` (+ optional `variant_id`) |
|
||||
| `proposal` | KI-Entwurf (`ai_suggestion`, kein `exercise_id`) |
|
||||
|
||||
## Kanten
|
||||
|
||||
- `primary(n) → primary(n+1)` — `next_exercise` (nur befüllte Primärkette, lückenlos verbunden)
|
||||
- `primary ↔ sibling` — `sibling` (pro Slot)
|
||||
|
||||
Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgenden befüllten Primär-Slots.
|
||||
|
||||
## Editor-Zustand (`ProgressionGraphDraft`)
|
||||
|
||||
```ts
|
||||
{
|
||||
goalQuery, startSituation, targetState, roadmapNotes, maxSteps,
|
||||
majorSteps: MajorStep[],
|
||||
slots: Slot[], // index = major_step_index
|
||||
pathSkillExpectations?,
|
||||
lastFindings?, // path_qa-Snapshot
|
||||
dirty: boolean,
|
||||
}
|
||||
```
|
||||
|
||||
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
|
||||
|
||||
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`.
|
||||
|
||||
## Findings-Panel
|
||||
|
||||
Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …).
|
||||
|
||||
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
|
||||
|
||||
Persistenz: `planning_roadmap.last_findings`.
|
||||
|
||||
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
|
||||
|
||||
Zusätzlich optional:
|
||||
|
||||
- `slot_contents[]` — `{ major_step_index, primary, siblings[] }`
|
||||
- `last_findings` — letzter `path_qa`-Snapshot
|
||||
|
||||
## UI (konsolidiert)
|
||||
|
||||
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
|
||||
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
|
||||
- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
|
||||
- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
|
||||
|
||||
## Ersetzt (Legacy, nicht mehr im Panel)
|
||||
|
||||
- `ExerciseProgressionPathBuilder` · `ProgressionChainEditor` — Code bleibt vorerst, nicht eingebunden
|
||||
|
||||
## Implementierungsreihenfolge
|
||||
|
||||
| ID | Inhalt |
|
||||
|----|--------|
|
||||
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten |
|
||||
| B.1 | Slot-Karten, Bibliothek + Entwurf |
|
||||
| B.2 | Findings-Panel + `evaluate_only` |
|
||||
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ |
|
||||
| B.4 | Route + Panel vereinfachen |
|
||||
| B.5 | `last_findings` + Phase-C-Vorbereitung |
|
||||
10
.env.example
10
.env.example
|
|
@ -35,6 +35,16 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
|||
OPENROUTER_API_KEY=your_api_key_here
|
||||
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_PORT=587
|
||||
SMTP_USER=noreply@jinkendo.de
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ jobs:
|
|||
docker compose -f docker-compose.dev-env.yml build --no-cache
|
||||
docker compose -f docker-compose.dev-env.yml up -d
|
||||
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"
|
||||
echo "=== Shinkan DEV Deploy complete ==="
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
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:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
branches: [develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
branches: [develop]
|
||||
workflow_run:
|
||||
workflows: ["Deploy Development", "Deploy Production"]
|
||||
types: [completed]
|
||||
|
|
@ -17,8 +20,10 @@ jobs:
|
|||
steps:
|
||||
- name: Backend pytest im deployten Container
|
||||
run: |
|
||||
set -e
|
||||
EVENT_NAME="${{ github.event_name }}"
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
BASE_REF="${{ github.base_ref }}"
|
||||
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||
APP_DIR="/home/lars/docker/shinkan"
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
|
|
@ -28,12 +33,27 @@ jobs:
|
|||
APP_DIR="/home/lars/docker/shinkan-dev"
|
||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
||||
fi
|
||||
elif [ "$REF_NAME" = "develop" ]; then
|
||||
elif [ "$REF_NAME" = "develop" ] || [ "$BASE_REF" = "develop" ]; then
|
||||
APP_DIR="/home/lars/docker/shinkan-dev"
|
||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
||||
fi
|
||||
|
||||
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 "
|
||||
pip install -r /app/requirements-dev.txt &&
|
||||
cd /app &&
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
||||
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
|
||||
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
|
||||
> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
|
||||
> | Planungs-KI Progressionsgraph (Ist-Stand) | **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** · Spec **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
|
||||
|
||||
## Projekt-Übersicht
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ FROM python:3.12-slim
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# Install system dependencies (tzdata für zoneinfo/ZoneInfo unter Linux)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-client \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
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 . .
|
||||
|
|
|
|||
77
backend/account_lifecycle.py
Normal file
77
backend/account_lifecycle.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""
|
||||
Account-Lifecycle (CAPABILITY_CATALOG.v1.md §3, M3 C0).
|
||||
|
||||
Zustände: unverified → verified_pending_club → active_member; platform_admin separat.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from club_tenancy import is_platform_admin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
_ACCOUNT_STATE_RANK = {
|
||||
"unverified": 1,
|
||||
"verified_pending_club": 2,
|
||||
"active_member": 3,
|
||||
"platform_admin": 4,
|
||||
}
|
||||
|
||||
|
||||
def resolve_account_state(
|
||||
*,
|
||||
email_verified: bool,
|
||||
global_role: str,
|
||||
has_active_membership: bool,
|
||||
) -> str:
|
||||
"""Ermittelt account_state für ein Profil."""
|
||||
if is_platform_admin(global_role):
|
||||
return "platform_admin"
|
||||
if not email_verified:
|
||||
return "unverified"
|
||||
if not has_active_membership:
|
||||
return "verified_pending_club"
|
||||
return "active_member"
|
||||
|
||||
|
||||
def account_state_satisfies(current: str, required: str) -> bool:
|
||||
"""True wenn current mindestens required ist."""
|
||||
cur = _ACCOUNT_STATE_RANK.get(current, 0)
|
||||
req = _ACCOUNT_STATE_RANK.get(required, 99)
|
||||
if current == "platform_admin":
|
||||
return True
|
||||
return cur >= req
|
||||
|
||||
|
||||
def account_gate_enforcement_enabled() -> bool:
|
||||
"""Account-Gates aktiv (Default an — nur wenige Endpoints in M3)."""
|
||||
return os.getenv("ACCOUNT_GATE_ENFORCE", "1").strip() == "1"
|
||||
|
||||
|
||||
def assert_min_account_state(
|
||||
tenant: "TenantContext",
|
||||
min_state: str,
|
||||
*,
|
||||
endpoint: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Prüft Mindest-Account-Status. Wirft 403 wenn ACCOUNT_GATE_ENFORCE=1 (Default).
|
||||
"""
|
||||
current = getattr(tenant, "account_state", "active_member")
|
||||
ok = account_state_satisfies(current, min_state)
|
||||
if ok:
|
||||
return
|
||||
if not account_gate_enforcement_enabled():
|
||||
return
|
||||
detail = (
|
||||
f"Account-Status „{current}“ reicht nicht für diese Aktion "
|
||||
f"(erforderlich: {min_state})."
|
||||
)
|
||||
if endpoint:
|
||||
detail = f"{detail} ({endpoint})"
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
178
backend/account_onboarding_gate.py
Normal file
178
backend/account_onboarding_gate.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""
|
||||
API-Gates für Onboarding (Phase A — MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1).
|
||||
|
||||
Blockiert Domänen-APIs für unverified / verified_pending_club vor dem Router.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from account_lifecycle import resolve_account_state
|
||||
from club_tenancy import memberships_with_roles
|
||||
|
||||
# Öffentlich ohne Session
|
||||
PUBLIC_API_PREFIXES = (
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/verify/",
|
||||
"/api/legal-documents/",
|
||||
"/api/clubs/public-directory",
|
||||
"/api/version",
|
||||
"/api/health/",
|
||||
"/health",
|
||||
)
|
||||
|
||||
# Mit Session, unabhängig vom account_state (Logout, Profil lesen, …)
|
||||
AUTH_INFRA_PREFIXES = (
|
||||
"/api/auth/logout",
|
||||
"/api/auth/me",
|
||||
"/api/auth/status",
|
||||
"/api/auth/pin",
|
||||
"/api/auth/resend-verification",
|
||||
"/api/profiles/me",
|
||||
"/api/me/entitlements",
|
||||
)
|
||||
|
||||
# Zusätzlich für verified_pending_club (Verein bewerben)
|
||||
PENDING_CLUB_PREFIXES = (
|
||||
"/api/me/club-join-requests",
|
||||
"/api/me/club-creation-requests",
|
||||
)
|
||||
|
||||
_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$")
|
||||
|
||||
|
||||
def api_onboarding_gate_enabled() -> bool:
|
||||
"""Produktions-Gate aktiv (ACCOUNT_GATE_API_ENFORCE=0 zum Abschalten)."""
|
||||
return os.getenv("ACCOUNT_GATE_API_ENFORCE", "1").strip() == "1"
|
||||
|
||||
|
||||
def _middleware_db_lookup_enabled() -> bool:
|
||||
"""
|
||||
Middleware-Session-Lookup nur mit echter DB (nicht in pytest TestClient ohne Postgres).
|
||||
"""
|
||||
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
|
||||
return False
|
||||
if os.getenv("PYTEST_CURRENT_TEST"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def normalize_api_path(path: str) -> str:
|
||||
p = (path or "").split("?", 1)[0].strip()
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
if len(p) > 1 and p.endswith("/"):
|
||||
p = p[:-1]
|
||||
return p
|
||||
|
||||
|
||||
def is_public_api_path(path: str) -> bool:
|
||||
p = normalize_api_path(path)
|
||||
return any(p == pref or p.startswith(pref) for pref in PUBLIC_API_PREFIXES)
|
||||
|
||||
|
||||
def _path_allowed_for_state(path: str, method: str, account_state: str, profile_id: int) -> bool:
|
||||
p = normalize_api_path(path)
|
||||
m = (method or "GET").upper()
|
||||
|
||||
for pref in AUTH_INFRA_PREFIXES:
|
||||
if p == pref or p.startswith(pref + "/"):
|
||||
return True
|
||||
|
||||
match = _PROFILE_MUTATION_RE.match(p)
|
||||
if match and m in ("PUT", "PATCH") and int(match.group(1)) == int(profile_id):
|
||||
return True
|
||||
|
||||
if account_state == "unverified":
|
||||
return False
|
||||
|
||||
if account_state == "verified_pending_club":
|
||||
for pref in PENDING_CLUB_PREFIXES:
|
||||
if p == pref or p.startswith(pref + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def resolve_account_state_for_token(cur, session_row: dict) -> str:
|
||||
profile_id = int(session_row["profile_id"])
|
||||
role = (session_row.get("role") or "").lower()
|
||||
cur.execute(
|
||||
"SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
prof = cur.fetchone()
|
||||
email_verified = bool(prof.get("email_verified")) if prof else False
|
||||
memberships = memberships_with_roles(cur, profile_id, active_only=True)
|
||||
has_active = len(memberships) > 0
|
||||
return resolve_account_state(
|
||||
email_verified=email_verified,
|
||||
global_role=role,
|
||||
has_active_membership=has_active,
|
||||
)
|
||||
|
||||
|
||||
def check_api_onboarding_gate(
|
||||
*,
|
||||
path: str,
|
||||
method: str,
|
||||
profile_id: int,
|
||||
account_state: str,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Returns (allowed, reason).
|
||||
active_member / platform_admin → immer erlaubt (Domain).
|
||||
"""
|
||||
if not api_onboarding_gate_enabled():
|
||||
return True, None
|
||||
|
||||
if account_state in ("active_member", "platform_admin"):
|
||||
return True, None
|
||||
|
||||
if _path_allowed_for_state(path, method, account_state, profile_id):
|
||||
return True, None
|
||||
|
||||
return False, f"account_state_{account_state}"
|
||||
|
||||
|
||||
def evaluate_request_gate(token: Optional[str], path: str, method: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Vollständige Prüfung inkl. Session-Lookup.
|
||||
Returns: allowed, reason, account_state (für Logging)
|
||||
"""
|
||||
if not api_onboarding_gate_enabled() or not _middleware_db_lookup_enabled():
|
||||
return True, None, None
|
||||
|
||||
p = normalize_api_path(path)
|
||||
if not p.startswith("/api/"):
|
||||
return True, None, None
|
||||
if is_public_api_path(p):
|
||||
return True, None, None
|
||||
if not token:
|
||||
return True, None, None
|
||||
|
||||
from auth import get_session
|
||||
from db import get_db, get_cursor
|
||||
|
||||
session = get_session(token)
|
||||
if not session:
|
||||
return True, None, None
|
||||
|
||||
profile_id = int(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
account_state = resolve_account_state_for_token(cur, session)
|
||||
|
||||
allowed, reason = check_api_onboarding_gate(
|
||||
path=p,
|
||||
method=method,
|
||||
profile_id=profile_id,
|
||||
account_state=account_state,
|
||||
)
|
||||
return allowed, reason, account_state
|
||||
108
backend/ai_prompt_context.py
Normal file
108
backend/ai_prompt_context.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""
|
||||
Gemeinsame Pydantic-Modelle fuer Uebungs-KI-Kontext (Formularfelder → Prompt-Platzhalter).
|
||||
|
||||
Keine Imports aus exercise_ai — vermeidet Zirkelimporte mit ai_prompt_job / exercise_ai.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExerciseFormAiFocusRow(BaseModel):
|
||||
"""Fokusbereich fuer Skill-Retrieval (ai_skill_retrieval_profiles)."""
|
||||
|
||||
focus_area_id: int = Field(..., ge=1)
|
||||
is_primary: Optional[bool] = False
|
||||
|
||||
|
||||
class ExerciseFormAiPromptContext(BaseModel):
|
||||
"""
|
||||
Inhaltliche Eingabe fuer Uebungs-Prompts (Kurzfassung / Skills / Anleitung).
|
||||
|
||||
Wird genutzt von Admin-Prompt-Vorschau und POST /exercises/ai/suggest (via Mapping).
|
||||
"""
|
||||
|
||||
title: Optional[str] = ""
|
||||
goal: Optional[str] = None
|
||||
execution: Optional[str] = None
|
||||
preparation: Optional[str] = None
|
||||
trainer_notes: Optional[str] = None
|
||||
focus_hint: Optional[str] = None
|
||||
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None
|
||||
planning_context: Optional[Dict[str, Any]] = None
|
||||
|
||||
def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]:
|
||||
if not self.focus_areas_context:
|
||||
return None
|
||||
return [(int(x.focus_area_id), bool(x.is_primary)) for x in self.focus_areas_context]
|
||||
|
||||
def has_instruction_source_text(self) -> bool:
|
||||
"""Mindestens ein Anleitungsfeld oder Titel fuer instruction_rewrite."""
|
||||
if (self.title or "").strip():
|
||||
return True
|
||||
for val in (self.goal, self.execution, self.preparation, self.trainer_notes):
|
||||
if val and str(val).strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def from_api_suggest(
|
||||
cls,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
goal: Optional[str] = None,
|
||||
execution: Optional[str] = None,
|
||||
preparation: Optional[str] = None,
|
||||
trainer_notes: Optional[str] = None,
|
||||
focus_area_hint: Optional[str] = None,
|
||||
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
|
||||
planning_context: Optional[Dict[str, Any]] = None,
|
||||
) -> ExerciseFormAiPromptContext:
|
||||
"""Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint)."""
|
||||
hint = (focus_area_hint or "").strip() or None
|
||||
return cls(
|
||||
title=(title or "").strip(),
|
||||
goal=goal,
|
||||
execution=execution,
|
||||
preparation=preparation,
|
||||
trainer_notes=trainer_notes,
|
||||
focus_hint=hint,
|
||||
focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
|
||||
planning_context=dict(planning_context) if planning_context else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_focus_tuples(
|
||||
cls,
|
||||
*,
|
||||
title: str = "",
|
||||
goal: Optional[str] = None,
|
||||
execution: Optional[str] = None,
|
||||
preparation: Optional[str] = None,
|
||||
trainer_notes: Optional[str] = None,
|
||||
focus_hint: Optional[str] = None,
|
||||
focus_tuples: Optional[Sequence[Tuple[int, bool]]] = None,
|
||||
) -> ExerciseFormAiPromptContext:
|
||||
rows = None
|
||||
if focus_tuples:
|
||||
rows = [
|
||||
ExerciseFormAiFocusRow(focus_area_id=int(fid), is_primary=bool(prim))
|
||||
for fid, prim in focus_tuples
|
||||
]
|
||||
return cls(
|
||||
title=(title or "").strip(),
|
||||
goal=goal,
|
||||
execution=execution,
|
||||
preparation=preparation,
|
||||
trainer_notes=trainer_notes,
|
||||
focus_hint=(focus_hint or "").strip() or None,
|
||||
focus_areas_context=rows,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExerciseFormAiFocusRow",
|
||||
"ExerciseFormAiPromptContext",
|
||||
]
|
||||
59
backend/ai_prompt_job.py
Normal file
59
backend/ai_prompt_job.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
KI-Prompt Jobs: Resolver + oeffentliche Fassade fuer Uebungs-KI-Aufrufe.
|
||||
|
||||
Importiert exercise_ai fuer Platzhalter-Builder und OpenRouter-Orchestrierung.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
|
||||
from exercise_ai import build_exercise_placeholder_variables
|
||||
|
||||
|
||||
def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptContext) -> Dict[str, str]:
|
||||
"""Baut die Mustache-Map fuer exercise_summary / exercise_skill_suggestions."""
|
||||
return build_exercise_placeholder_variables(
|
||||
cur,
|
||||
slug=slug,
|
||||
title=(ctx.title or "").strip(),
|
||||
goal=ctx.goal,
|
||||
execution=ctx.execution,
|
||||
focus_area_hint=ctx.focus_hint,
|
||||
focus_areas_context=ctx.focus_area_tuples(),
|
||||
preparation=ctx.preparation,
|
||||
trainer_notes=ctx.trainer_notes,
|
||||
planning_context=ctx.planning_context,
|
||||
)
|
||||
|
||||
|
||||
def run_exercise_form_ai_suggestion(
|
||||
cur,
|
||||
ctx: ExerciseFormAiPromptContext,
|
||||
*,
|
||||
want_summary: bool,
|
||||
want_skills: bool,
|
||||
want_instructions: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fuehrt Uebungs-KI aus (OpenRouter) — ein Einstieg fuer Router und kuenftige Jobs.
|
||||
|
||||
``ctx`` = Formularinhalt; ``want_*`` = welche Prompt-Slugs angefragt werden.
|
||||
"""
|
||||
from exercise_ai import run_exercise_ai_suggestion
|
||||
|
||||
return run_exercise_ai_suggestion(
|
||||
cur,
|
||||
form_ctx=ctx,
|
||||
want_summary=want_summary,
|
||||
want_skills=want_skills,
|
||||
want_instructions=want_instructions,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExerciseFormAiFocusRow",
|
||||
"ExerciseFormAiPromptContext",
|
||||
"resolve_exercise_form_variables",
|
||||
"run_exercise_form_ai_suggestion",
|
||||
]
|
||||
125
backend/ai_prompt_runtime.py
Normal file
125
backend/ai_prompt_runtime.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""
|
||||
Gemeinsame KI-Prompt-Laufzeit (Shinkan): DB-Lesezugriff ai_prompts + Kontext-Arten.
|
||||
|
||||
Bleibt ohne Import von exercise_ai (kein Zirkel). Domänen wie exercise_ai nutzen
|
||||
load_ai_prompt_row und die Enum; Platzhalter bauen sie selbst oder über geteilte Builder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
from prompt_resolver import MustacheRenderResult, render_mustache_template
|
||||
|
||||
_PLANNING_AI_SLUGS = frozenset(
|
||||
{
|
||||
"planning_exercise_search_rank",
|
||||
"planning_exercise_search_intent",
|
||||
"planning_exercise_expectation_profile",
|
||||
}
|
||||
)
|
||||
|
||||
_EXERCISE_AI_SLUGS = frozenset(
|
||||
{
|
||||
"exercise_summary",
|
||||
"exercise_skill_suggestions",
|
||||
"exercise_instruction_rewrite",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AiPromptContextKind(str, Enum):
|
||||
"""
|
||||
Logischer Kontext fuer Platzhalter/Builder — erweiterbar fuer Planung/Rahmen
|
||||
ohne bestehende Slugs zu invalidieren.
|
||||
"""
|
||||
|
||||
PLANNING_EXERCISE_SEARCH = "planning_exercise_search"
|
||||
EXERCISE_FORM_AI = "exercise_form_ai"
|
||||
|
||||
|
||||
def context_kind_for_slug(slug: str) -> Optional[AiPromptContextKind]:
|
||||
"""Ordnet einen DB-Slug einer Kontext-Art zu, sofern registriert."""
|
||||
s = (slug or "").strip().lower()
|
||||
if s in _PLANNING_AI_SLUGS:
|
||||
return AiPromptContextKind.PLANNING_EXERCISE_SEARCH
|
||||
if s in _EXERCISE_AI_SLUGS:
|
||||
return AiPromptContextKind.EXERCISE_FORM_AI
|
||||
return None
|
||||
|
||||
|
||||
def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Laedt eine Zeile ai_prompts fuer Laufzeit-Orchestrierung.
|
||||
|
||||
active_only=True: inaktive Prompts werden wie fehlend behandelt (503 im Aufrufer).
|
||||
"""
|
||||
if active_only:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||
FROM ai_prompts
|
||||
WHERE slug = %s AND active = true
|
||||
""",
|
||||
(slug,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||
FROM ai_prompts
|
||||
WHERE slug = %s
|
||||
""",
|
||||
(slug,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
if active_only and not d.get("active", True):
|
||||
return None
|
||||
return d
|
||||
|
||||
|
||||
class AiPromptUnavailableError(LookupError):
|
||||
"""Kein aktiver Prompt fuer slug (oder Zeile fehlt)."""
|
||||
|
||||
def __init__(self, slug: str) -> None:
|
||||
self.slug = (slug or "").strip()
|
||||
super().__init__(self.slug)
|
||||
|
||||
|
||||
def render_ai_prompt_template_for_row(
|
||||
row: Mapping[str, Any],
|
||||
variables: Mapping[str, str],
|
||||
) -> MustacheRenderResult:
|
||||
"""Ersetzt Platzhalter anhand einer bereits geladenen ai_prompts-Zeile (z. B. Admin-Vorschauch, inkl. inaktiv)."""
|
||||
return render_mustache_template(str(row.get("template") or ""), variables)
|
||||
|
||||
|
||||
def load_and_render_ai_prompt(
|
||||
cur,
|
||||
slug: str,
|
||||
variables: Mapping[str, str],
|
||||
*,
|
||||
active_only: bool = True,
|
||||
) -> Tuple[Dict[str, Any], MustacheRenderResult]:
|
||||
"""
|
||||
Laedt einen aktiven Prompt und wendet Mustache-Variablen an.
|
||||
Wirft AiPromptUnavailableError, wenn die Zeile fehlt oder (bei active_only) inaktiv ist.
|
||||
"""
|
||||
row = load_ai_prompt_row(cur, slug, active_only=active_only)
|
||||
if not row:
|
||||
raise AiPromptUnavailableError(slug)
|
||||
rr = render_ai_prompt_template_for_row(row, variables)
|
||||
return dict(row), rr
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AiPromptContextKind",
|
||||
"AiPromptUnavailableError",
|
||||
"context_kind_for_slug",
|
||||
"load_ai_prompt_row",
|
||||
"load_and_render_ai_prompt",
|
||||
"render_ai_prompt_template_for_row",
|
||||
]
|
||||
|
|
@ -170,6 +170,10 @@ def get_effective_tier(profile_id: str, conn=None) -> str:
|
|||
|
||||
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
||||
"""
|
||||
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.
|
||||
|
||||
Access hierarchy:
|
||||
|
|
@ -315,6 +319,8 @@ def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
|
|||
|
||||
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
||||
"""
|
||||
DEPRECATED für Shinkan — siehe club_features.increment_club_feature_usage.
|
||||
|
||||
Increment usage counter for a feature.
|
||||
|
||||
Creates usage record if it doesn't exist, with reset_at based on
|
||||
|
|
|
|||
285
backend/capabilities.py
Normal file
285
backend/capabilities.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""
|
||||
Capability-Auflösung (CAPABILITY_CATALOG.v1.md, M3 C1).
|
||||
|
||||
Phase 2: probe_capability — JSON-Log, kein Block (CAPABILITY_ENFORCE=0).
|
||||
Phase 3+: CAPABILITY_ENFORCE=1 — HTTP 403 bei fehlender Capability.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from account_lifecycle import account_state_satisfies
|
||||
from club_tenancy import is_platform_admin
|
||||
from db import get_db, get_cursor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def capability_enforcement_enabled() -> bool:
|
||||
v = os.getenv("CAPABILITY_ENFORCE", "0").strip().lower()
|
||||
return v in ("1", "true", "yes")
|
||||
|
||||
|
||||
def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]:
|
||||
if club_id is None:
|
||||
return []
|
||||
for m in tenant.memberships or []:
|
||||
if int(m.get("id") or 0) == int(club_id):
|
||||
roles = m.get("roles") or []
|
||||
if hasattr(roles, "tolist"):
|
||||
roles = roles.tolist()
|
||||
return list(roles)
|
||||
return []
|
||||
|
||||
|
||||
def check_capability(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
capability_id: str,
|
||||
*,
|
||||
club_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Prüft eine Capability für Tenant + optionalen Vereinskontext.
|
||||
|
||||
Returns: allowed, reason, account_state, club_roles, linked_feature_id
|
||||
"""
|
||||
account_state = getattr(tenant, "account_state", "active_member")
|
||||
eff_club = club_id if club_id is not None else tenant.effective_club_id
|
||||
club_roles = club_roles_in_club(tenant, eff_club) if eff_club is not None else []
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, min_account_state, linked_feature_id, active, domain
|
||||
FROM capabilities
|
||||
WHERE id = %s
|
||||
""",
|
||||
(capability_id,),
|
||||
)
|
||||
cap = cur.fetchone()
|
||||
if not cap or not cap.get("active"):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "capability_not_found",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": None,
|
||||
}
|
||||
|
||||
min_state = cap.get("min_account_state") or "active_member"
|
||||
if not account_state_satisfies(account_state, min_state):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "account_state_insufficient",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
domain = (cap.get("domain") or "").strip().lower()
|
||||
|
||||
# Kontingent-Bypass (konfigurierbar per portal_role / profile grants, ohne Plattform-Admin-Pflicht)
|
||||
if domain == "quota_bypass":
|
||||
role_lc = (tenant.global_role or "").lower()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(role_lc, capability_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "quota_bypass_portal_grant",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM profile_capability_grants
|
||||
WHERE profile_id = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(tenant.profile_id, capability_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "quota_bypass_profile_grant",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "quota_bypass_denied",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
# Plattform-Capabilities
|
||||
if domain == "platform" or capability_id.startswith("platform."):
|
||||
role_lc = (tenant.global_role or "").lower()
|
||||
if not is_platform_admin(role_lc):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "portal_role_required",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(role_lc, capability_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM profile_capability_grants
|
||||
WHERE profile_id = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(tenant.profile_id, capability_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "portal_capability_denied",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "portal_granted",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
# Plattform-Admin-Bypass für Mandanten-Funktionen (Audit-Pflicht, s. Katalog §9)
|
||||
if is_platform_admin(tenant.global_role):
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "platform_admin_bypass",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
# Vereins-Capabilities: aktive Mitgliedschaft im Zielverein
|
||||
if min_state == "active_member":
|
||||
if eff_club is None:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "no_club_context",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
if eff_club not in tenant.club_ids:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "not_club_member",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT role_code FROM club_role_capability_grants
|
||||
WHERE capability_id = %s
|
||||
""",
|
||||
(capability_id,),
|
||||
)
|
||||
required_roles = [r["role_code"] for r in cur.fetchall()]
|
||||
|
||||
if required_roles:
|
||||
if not any(r in required_roles for r in club_roles):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "club_role_denied",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
elif min_state == "active_member" and eff_club is not None:
|
||||
# Offene Capability für alle aktiven Mitglieder — Mitgliedschaft reicht
|
||||
pass
|
||||
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "granted",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_capabilities_map(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
*,
|
||||
club_id: Optional[int] = None,
|
||||
) -> Dict[str, bool]:
|
||||
"""Alle aktiven Capabilities → bool (für späteres /me/entitlements)."""
|
||||
cur.execute("SELECT id FROM capabilities WHERE active = true ORDER BY id")
|
||||
ids = [r["id"] for r in cur.fetchall()]
|
||||
out: Dict[str, bool] = {}
|
||||
for cid in ids:
|
||||
res = check_capability(cur, tenant, cid, club_id=club_id)
|
||||
out[cid] = bool(res.get("allowed"))
|
||||
return out
|
||||
|
||||
|
||||
def probe_capability(
|
||||
tenant: "TenantContext",
|
||||
capability_id: str,
|
||||
*,
|
||||
action: str,
|
||||
club_id: Optional[int] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Phase 2: Capability prüfen + JSON-Log; blockiert nur bei CAPABILITY_ENFORCE=1."""
|
||||
from capability_logger import log_capability_check
|
||||
|
||||
def _run(c):
|
||||
cur = get_cursor(c)
|
||||
result = check_capability(cur, tenant, capability_id, club_id=club_id)
|
||||
log_capability_check(
|
||||
club_id=club_id if club_id is not None else tenant.effective_club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
capability_id=capability_id,
|
||||
action=action,
|
||||
result=result,
|
||||
endpoint=endpoint,
|
||||
phase="enforce" if capability_enforcement_enabled() else "probe",
|
||||
)
|
||||
if capability_enforcement_enabled() and not result.get("allowed"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
f"Keine Berechtigung für {capability_id} "
|
||||
f"({result.get('reason', 'denied')})."
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
if conn is not None:
|
||||
return _run(conn)
|
||||
with get_db() as c:
|
||||
return _run(c)
|
||||
94
backend/capability_enforcement_audit.py
Normal file
94
backend/capability_enforcement_audit.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""
|
||||
Audit: Welche Capabilities sind an Endpoints angebunden?
|
||||
|
||||
Für Admin-Matrix (Rollen & Rechte) und Roadmap — bei neuem probe_capability hier eintragen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
# Endpoints rufen probe_capability auf (Log; Block nur bei CAPABILITY_ENFORCE=1)
|
||||
WIRED_PROBE = frozenset(
|
||||
{
|
||||
"exercises.ai.suggest",
|
||||
"exercises.ai.regenerate",
|
||||
"exercises.create",
|
||||
"exercises.media.upload",
|
||||
"planning.ai.suggest",
|
||||
"planning.ai.progression_path",
|
||||
"club.creation_request.read_own",
|
||||
"club.creation_request.create",
|
||||
"club.creation_request.withdraw",
|
||||
"platform.club_creation.approve",
|
||||
}
|
||||
)
|
||||
|
||||
# Kontingent-Verbrauch nach Erfolg (consume_club_feature_with_usage)
|
||||
FEATURE_CONSUME_WIRED = frozenset(
|
||||
{
|
||||
"ai_calls",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def enforcement_status_for_capability(capability_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Anzeige-Status für Superadmin-Matrix.
|
||||
|
||||
level: probe | legacy | platform | open | none
|
||||
"""
|
||||
cid = (capability_id or "").strip()
|
||||
if cid in WIRED_PROBE:
|
||||
return {
|
||||
"level": "probe",
|
||||
"label": "API vorbereitet (Log)",
|
||||
"detail": "probe_capability am Endpoint; Hard-Block erst mit CAPABILITY_ENFORCE=1",
|
||||
"implemented": True,
|
||||
}
|
||||
if cid.startswith("platform."):
|
||||
if cid == "platform.admin.access":
|
||||
return {
|
||||
"level": "platform",
|
||||
"label": "Plattform (Router-Guard)",
|
||||
"detail": "RequireAdmin / Superadmin-Checks",
|
||||
"implemented": True,
|
||||
}
|
||||
if cid in WIRED_PROBE:
|
||||
pass
|
||||
return {
|
||||
"level": "platform",
|
||||
"label": "Plattform (teilweise)",
|
||||
"detail": "Meist Router-Guard; Capability-Probe nur wo eingetragen",
|
||||
"implemented": cid in WIRED_PROBE,
|
||||
}
|
||||
if cid.startswith("club."):
|
||||
return {
|
||||
"level": "open",
|
||||
"label": "Onboarding",
|
||||
"detail": "Account-State / eigene Flows",
|
||||
"implemented": cid in WIRED_PROBE,
|
||||
}
|
||||
# Vereins-Capabilities ohne Probe: Legacy club_tenancy (can_plan_in_club, has_club_role, …)
|
||||
return {
|
||||
"level": "legacy",
|
||||
"label": "Nur Legacy-Rollen",
|
||||
"detail": "Noch kein probe_capability — prüft can_plan_in_club / club_admin im Code",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def feature_consume_status(feature_id: str) -> Dict[str, Any]:
|
||||
fid = (feature_id or "").strip()
|
||||
if fid in FEATURE_CONSUME_WIRED:
|
||||
return {
|
||||
"level": "consume",
|
||||
"label": "Verbrauch aktiv",
|
||||
"detail": "consume_club_feature_with_usage + feature_usage in Response",
|
||||
"implemented": True,
|
||||
}
|
||||
return {
|
||||
"level": "inventory",
|
||||
"label": "Bestand / Probe",
|
||||
"detail": "Probe oder Live-Zählung; kein Consume nach Aktion",
|
||||
"implemented": False,
|
||||
}
|
||||
64
backend/capability_logger.py
Normal file
64
backend/capability_logger.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
JSON-Log für Capability-Checks (M3 Phase 2 — analog club_feature_logger).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _log_dir() -> Path:
|
||||
custom = (os.getenv("CAPABILITY_LOG_DIR") or "").strip()
|
||||
if custom:
|
||||
return Path(custom)
|
||||
return Path("/app/logs")
|
||||
|
||||
|
||||
capability_logger = logging.getLogger("shinkan.capability_usage")
|
||||
capability_logger.setLevel(logging.INFO)
|
||||
capability_logger.propagate = False
|
||||
|
||||
if not capability_logger.handlers:
|
||||
log_dir = _log_dir()
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "capability-usage.log"
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
capability_logger.addHandler(file_handler)
|
||||
except OSError:
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(logging.Formatter("[capability-usage] %(message)s"))
|
||||
capability_logger.addHandler(stream_handler)
|
||||
|
||||
|
||||
def log_capability_check(
|
||||
*,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int],
|
||||
capability_id: str,
|
||||
action: str,
|
||||
result: Dict[str, Any],
|
||||
endpoint: Optional[str] = None,
|
||||
phase: str = "probe",
|
||||
) -> None:
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"club_id": club_id,
|
||||
"profile_id": profile_id,
|
||||
"capability": capability_id,
|
||||
"action": action,
|
||||
"endpoint": endpoint,
|
||||
"phase": phase,
|
||||
"allowed": result.get("allowed", True),
|
||||
"reason": result.get("reason", "unknown"),
|
||||
"account_state": result.get("account_state"),
|
||||
"club_roles": result.get("club_roles"),
|
||||
"enforcement": os.getenv("CAPABILITY_ENFORCE", "0") == "1",
|
||||
}
|
||||
capability_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||
74
backend/club_feature_logger.py
Normal file
74
backend/club_feature_logger.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""
|
||||
JSON-Log für Vereins-Feature-Zugriffe (Phase 2: nur Monitoring, kein Block).
|
||||
|
||||
Spez: CLUB_MEMBERSHIP_AND_FEATURES.v1.md §9 Phase 2 — analog Mitai feature_logger.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _log_dir() -> Path:
|
||||
custom = (os.getenv("CLUB_FEATURE_LOG_DIR") or "").strip()
|
||||
if custom:
|
||||
return Path(custom)
|
||||
return Path("/app/logs")
|
||||
|
||||
|
||||
feature_usage_logger = logging.getLogger("shinkan.club_feature_usage")
|
||||
feature_usage_logger.setLevel(logging.INFO)
|
||||
feature_usage_logger.propagate = False
|
||||
|
||||
if not feature_usage_logger.handlers:
|
||||
log_dir = _log_dir()
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "club-feature-usage.log"
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
feature_usage_logger.addHandler(file_handler)
|
||||
except OSError:
|
||||
# Dev ohne /app/logs: Fallback stderr
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(logging.Formatter("[club-feature-usage] %(message)s"))
|
||||
feature_usage_logger.addHandler(stream_handler)
|
||||
|
||||
|
||||
def log_club_feature_usage(
|
||||
*,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int],
|
||||
feature_id: str,
|
||||
action: str,
|
||||
access: Dict[str, Any],
|
||||
endpoint: Optional[str] = None,
|
||||
phase: str = "probe",
|
||||
) -> None:
|
||||
"""
|
||||
Strukturiertes JSON-Log eines Feature-Checks.
|
||||
|
||||
phase: probe (Phase 2, non-blocking) | enforce (Phase 4, nach Block-Entscheid)
|
||||
"""
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"club_id": club_id,
|
||||
"profile_id": profile_id,
|
||||
"feature": feature_id,
|
||||
"action": action,
|
||||
"endpoint": endpoint,
|
||||
"phase": phase,
|
||||
"plan_id": access.get("plan_id"),
|
||||
"used": access.get("used", 0),
|
||||
"limit": access.get("limit"),
|
||||
"remaining": access.get("remaining"),
|
||||
"allowed": access.get("allowed", True),
|
||||
"reason": access.get("reason", "unknown"),
|
||||
"enforcement": os.getenv("CLUB_FEATURE_ENFORCE", "0") == "1",
|
||||
}
|
||||
feature_usage_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||
713
backend/club_features.py
Normal file
713
backend/club_features.py
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
"""
|
||||
Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id).
|
||||
|
||||
Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
|
||||
Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block.
|
||||
Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment.
|
||||
|
||||
Verbrauch-Standard für Router:
|
||||
probe_club_feature_access → Business-Logik → consume_club_feature_with_usage → merge_feature_usage_into_response
|
||||
|
||||
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from db import get_db, get_cursor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
# Bestands-Features: Verbrauch = Live-Zählung in DB (nicht club_feature_usage)
|
||||
_INVENTORY_FEATURES = frozenset(
|
||||
{"exercises", "training_groups", "active_members", "training_programs"}
|
||||
)
|
||||
|
||||
|
||||
def _calculate_next_reset(reset_period: str, *, now: Optional[datetime] = None) -> Optional[datetime]:
|
||||
"""Nächster Reset-Zeitpunkt; None bei 'never'."""
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
if reset_period == "never":
|
||||
return None
|
||||
if reset_period == "daily":
|
||||
tomorrow = ref.date() + timedelta(days=1)
|
||||
return datetime.combine(tomorrow, datetime.min.time(), tzinfo=timezone.utc)
|
||||
if reset_period == "monthly":
|
||||
if ref.month == 12:
|
||||
return datetime(ref.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||
return datetime(ref.year, ref.month + 1, 1, tzinfo=timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_limit(raw: Any) -> Optional[int]:
|
||||
"""NULL = unbegrenzt; -1 (Legacy 001) wird als unbegrenzt behandelt."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
v = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if v < 0:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def get_effective_club_plan(cur, club_id: int) -> str:
|
||||
"""
|
||||
Effektiver Plan für einen Verein.
|
||||
|
||||
1. Aktiver club_access_grants mit plan_id (Zeitfenster, neueste ends_at)
|
||||
2. club_subscriptions.status = 'active' → plan_id
|
||||
3. Fallback 'free'
|
||||
"""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT plan_id
|
||||
FROM club_access_grants
|
||||
WHERE club_id = %s
|
||||
AND plan_id IS NOT NULL
|
||||
AND starts_at <= NOW()
|
||||
AND ends_at > NOW()
|
||||
ORDER BY ends_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
grant = cur.fetchone()
|
||||
if grant and grant.get("plan_id"):
|
||||
return str(grant["plan_id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT plan_id
|
||||
FROM club_subscriptions
|
||||
WHERE club_id = %s AND status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
sub = cur.fetchone()
|
||||
if sub and sub.get("plan_id"):
|
||||
return str(sub["plan_id"])
|
||||
|
||||
return "free"
|
||||
|
||||
|
||||
def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) -> Optional[int]:
|
||||
"""Limit-Wert: Override > Plan > Feature-Default."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT limit_value
|
||||
FROM club_feature_overrides
|
||||
WHERE club_id = %s AND feature_id = %s
|
||||
""",
|
||||
(club_id, feature_id),
|
||||
)
|
||||
override = cur.fetchone()
|
||||
if override is not None:
|
||||
return _normalize_limit(override.get("limit_value"))
|
||||
|
||||
plan_id = get_effective_club_plan(cur, club_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT limit_value
|
||||
FROM club_plan_limits
|
||||
WHERE plan_id = %s AND feature_id = %s
|
||||
""",
|
||||
(plan_id, feature_id),
|
||||
)
|
||||
plan_lim = cur.fetchone()
|
||||
if plan_lim is not None:
|
||||
return _normalize_limit(plan_lim.get("limit_value"))
|
||||
|
||||
return _normalize_limit(feature_row.get("default_limit"))
|
||||
|
||||
|
||||
def _live_inventory_count(cur, club_id: int, feature_id: str) -> Optional[int]:
|
||||
"""Aktueller Bestand für reset_period=never Features."""
|
||||
if feature_id == "exercises":
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::int AS c
|
||||
FROM exercises
|
||||
WHERE club_id = %s AND status != 'archived'
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
elif feature_id == "training_groups":
|
||||
cur.execute(
|
||||
"SELECT COUNT(*)::int AS c FROM training_groups WHERE club_id = %s",
|
||||
(club_id,),
|
||||
)
|
||||
elif feature_id == "active_members":
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::int AS c
|
||||
FROM club_members
|
||||
WHERE club_id = %s AND status = 'active'
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
elif feature_id == "training_programs":
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::int AS c FROM (
|
||||
SELECT id FROM training_framework_programs WHERE club_id = %s
|
||||
UNION ALL
|
||||
SELECT id FROM training_modules WHERE club_id = %s
|
||||
) t
|
||||
""",
|
||||
(club_id, club_id),
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
row = cur.fetchone()
|
||||
return int(row["c"] or 0) if row else 0
|
||||
|
||||
|
||||
def resolve_club_id_for_probe(
|
||||
tenant: "TenantContext",
|
||||
*,
|
||||
object_club_id: Optional[int] = None,
|
||||
) -> Optional[int]:
|
||||
"""Verein für Feature-Probe: explizites Objekt > effective_club_id."""
|
||||
if object_club_id is not None:
|
||||
return int(object_club_id)
|
||||
eff = getattr(tenant, "effective_club_id", None)
|
||||
return int(eff) if eff is not None else None
|
||||
|
||||
|
||||
def _maybe_reset_usage(cur, conn, club_id: int, feature_id: str, feature_row: dict, usage_row: Optional[dict]) -> int:
|
||||
"""Setzt Zähler zurück wenn reset_at überschritten; gibt aktuellen used zurück."""
|
||||
used = int(usage_row.get("usage_count") or 0) if usage_row else 0
|
||||
reset_at = usage_row.get("reset_at") if usage_row else None
|
||||
period = (feature_row.get("reset_period") or "never").strip().lower()
|
||||
|
||||
if not usage_row or not reset_at or period == "never":
|
||||
return used
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
ra = reset_at
|
||||
if hasattr(ra, "tzinfo") and ra.tzinfo is None:
|
||||
ra = ra.replace(tzinfo=timezone.utc)
|
||||
|
||||
if ra and now > ra:
|
||||
next_reset = _calculate_next_reset(period, now=now)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_feature_usage
|
||||
SET usage_count = 0, reset_at = %s, updated_at = NOW()
|
||||
WHERE club_id = %s AND feature_id = %s
|
||||
""",
|
||||
(next_reset, club_id, feature_id),
|
||||
)
|
||||
conn.commit()
|
||||
return 0
|
||||
|
||||
return used
|
||||
|
||||
|
||||
def check_club_feature_access(
|
||||
club_id: int,
|
||||
feature_id: str,
|
||||
*,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Prüft Vereins-Kontingent für ein Feature.
|
||||
|
||||
Returns:
|
||||
allowed, limit, used, remaining, reason, plan_id, reset_at (optional)
|
||||
"""
|
||||
if conn is not None:
|
||||
return _check_club_impl(club_id, feature_id, conn)
|
||||
|
||||
with get_db() as c:
|
||||
return _check_club_impl(club_id, feature_id, c)
|
||||
|
||||
|
||||
def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, limit_type, reset_period, default_limit, active, enforcement_subject
|
||||
FROM features
|
||||
WHERE id = %s AND app = 'shinkan'
|
||||
""",
|
||||
(feature_id,),
|
||||
)
|
||||
feature = cur.fetchone()
|
||||
if not feature or not feature.get("active"):
|
||||
return {
|
||||
"allowed": False,
|
||||
"limit": None,
|
||||
"used": 0,
|
||||
"remaining": None,
|
||||
"reason": "feature_not_found",
|
||||
"plan_id": get_effective_club_plan(cur, club_id),
|
||||
}
|
||||
|
||||
plan_id = get_effective_club_plan(cur, club_id)
|
||||
limit = _resolve_club_limit(cur, club_id, feature_id, feature)
|
||||
limit_type = (feature.get("limit_type") or "count").strip().lower()
|
||||
|
||||
if limit_type == "boolean":
|
||||
allowed = limit == 1
|
||||
return {
|
||||
"allowed": allowed,
|
||||
"limit": limit,
|
||||
"used": 0,
|
||||
"remaining": None,
|
||||
"reason": "enabled" if allowed else "feature_disabled",
|
||||
"plan_id": plan_id,
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT usage_count, reset_at
|
||||
FROM club_feature_usage
|
||||
WHERE club_id = %s AND feature_id = %s
|
||||
""",
|
||||
(club_id, feature_id),
|
||||
)
|
||||
usage = cur.fetchone()
|
||||
used = _maybe_reset_usage(cur, conn, club_id, feature_id, feature, usage)
|
||||
|
||||
period = (feature.get("reset_period") or "never").strip().lower()
|
||||
if period == "never" and feature_id in _INVENTORY_FEATURES:
|
||||
inv = _live_inventory_count(cur, club_id, feature_id)
|
||||
if inv is not None:
|
||||
used = inv
|
||||
|
||||
if limit is None:
|
||||
return {
|
||||
"allowed": True,
|
||||
"limit": None,
|
||||
"used": used,
|
||||
"remaining": None,
|
||||
"reason": "unlimited",
|
||||
"plan_id": plan_id,
|
||||
"reset_at": usage.get("reset_at") if usage else None,
|
||||
}
|
||||
|
||||
if limit == 0:
|
||||
return {
|
||||
"allowed": False,
|
||||
"limit": 0,
|
||||
"used": used,
|
||||
"remaining": 0,
|
||||
"reason": "feature_disabled",
|
||||
"plan_id": plan_id,
|
||||
"reset_at": usage.get("reset_at") if usage else None,
|
||||
}
|
||||
|
||||
allowed = used < limit
|
||||
return {
|
||||
"allowed": allowed,
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": max(0, limit - used),
|
||||
"reason": "within_limit" if allowed else "limit_exceeded",
|
||||
"plan_id": plan_id,
|
||||
"reset_at": usage.get("reset_at") if usage else None,
|
||||
}
|
||||
|
||||
|
||||
def club_feature_enforcement_enabled() -> bool:
|
||||
"""Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1|true|yes)."""
|
||||
v = os.getenv("CLUB_FEATURE_ENFORCE", "0").strip().lower()
|
||||
return v in ("1", "true", "yes")
|
||||
|
||||
|
||||
def probe_club_feature_access(
|
||||
*,
|
||||
feature_id: str,
|
||||
action: str,
|
||||
club_id: Optional[int] = None,
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Phase 2: Prüft Vereins-Kontingent, schreibt JSON-Log, blockiert standardmäßig nicht.
|
||||
|
||||
Bei CLUB_FEATURE_ENFORCE=1: HTTP 403 wenn nicht allowed.
|
||||
"""
|
||||
from club_feature_logger import log_club_feature_usage
|
||||
|
||||
if club_id is None:
|
||||
access = {
|
||||
"allowed": not club_feature_enforcement_enabled(),
|
||||
"limit": None,
|
||||
"used": 0,
|
||||
"remaining": None,
|
||||
"reason": "no_club_context",
|
||||
"plan_id": None,
|
||||
}
|
||||
log_club_feature_usage(
|
||||
club_id=None,
|
||||
profile_id=profile_id,
|
||||
feature_id=feature_id,
|
||||
action=action,
|
||||
access=access,
|
||||
endpoint=endpoint,
|
||||
phase="enforce" if club_feature_enforcement_enabled() else "probe",
|
||||
)
|
||||
if club_feature_enforcement_enabled() and not access.get("allowed"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
f"Kein Vereinskontext für {feature_id} — "
|
||||
"aktiven Verein wählen (X-Active-Club-Id)."
|
||||
),
|
||||
)
|
||||
return access
|
||||
|
||||
def _resolve_access(connection):
|
||||
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||
|
||||
cur = get_cursor(connection)
|
||||
if is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
feature_id=feature_id,
|
||||
tenant=tenant,
|
||||
):
|
||||
plan_id = get_effective_club_plan(cur, int(club_id))
|
||||
return quota_bypass_access(
|
||||
feature_id=feature_id,
|
||||
club_id=int(club_id),
|
||||
plan_id=plan_id,
|
||||
)
|
||||
return check_club_feature_access(club_id, feature_id, conn=connection)
|
||||
|
||||
if conn is not None:
|
||||
access = _resolve_access(conn)
|
||||
else:
|
||||
with get_db() as c:
|
||||
access = _resolve_access(c)
|
||||
|
||||
log_club_feature_usage(
|
||||
club_id=club_id,
|
||||
profile_id=profile_id,
|
||||
feature_id=feature_id,
|
||||
action=action,
|
||||
access=access,
|
||||
endpoint=endpoint,
|
||||
phase="enforce" if club_feature_enforcement_enabled() else "probe",
|
||||
)
|
||||
|
||||
if club_feature_enforcement_enabled() and not access.get("allowed"):
|
||||
limit = access.get("limit")
|
||||
used = access.get("used", 0)
|
||||
detail = (
|
||||
f"Kontingent überschritten für {feature_id} "
|
||||
f"({used}/{limit if limit is not None else '∞'}). "
|
||||
f"Grund: {access.get('reason', 'limit_exceeded')}."
|
||||
)
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
|
||||
return access
|
||||
|
||||
|
||||
def consume_club_feature(
|
||||
*,
|
||||
feature_id: str,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
amount: int = 1,
|
||||
conn=None,
|
||||
) -> None:
|
||||
"""
|
||||
Phase 4 (M5): Zähler nach erfolgreichem Verbrauch erhöhen.
|
||||
Nur wenn club_id gesetzt (Vereins-Kontingent); amount = Anzahl LLM/API-Verbrauchseinheiten.
|
||||
Plattform-Ausnahmen (superadmin, konfigurierte Rollen/Profile) werden nicht gezählt.
|
||||
"""
|
||||
if club_id is None:
|
||||
return
|
||||
|
||||
def _is_exempt(connection) -> bool:
|
||||
from club_quota_bypass import is_club_feature_quota_bypassed
|
||||
|
||||
cur = get_cursor(connection)
|
||||
return is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
feature_id=feature_id,
|
||||
)
|
||||
|
||||
if conn is not None:
|
||||
if _is_exempt(conn):
|
||||
return
|
||||
else:
|
||||
with get_db() as c:
|
||||
if _is_exempt(c):
|
||||
return
|
||||
try:
|
||||
n = int(amount)
|
||||
except (TypeError, ValueError):
|
||||
n = 1
|
||||
if n < 1:
|
||||
return
|
||||
for _ in range(n):
|
||||
increment_club_feature_usage(
|
||||
int(club_id),
|
||||
feature_id,
|
||||
profile_id=profile_id,
|
||||
action=action,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
def _log_consume(connection) -> None:
|
||||
from club_feature_logger import log_club_feature_usage
|
||||
|
||||
access = check_club_feature_access(int(club_id), feature_id, conn=connection)
|
||||
log_club_feature_usage(
|
||||
club_id=int(club_id),
|
||||
profile_id=profile_id,
|
||||
feature_id=feature_id,
|
||||
action=action or "consume",
|
||||
access=access,
|
||||
phase="consume",
|
||||
)
|
||||
|
||||
if conn is not None:
|
||||
_log_consume(conn)
|
||||
else:
|
||||
with get_db() as c:
|
||||
_log_consume(c)
|
||||
|
||||
|
||||
def consume_club_feature_with_usage(
|
||||
*,
|
||||
feature_id: str,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
amount: int = 1,
|
||||
cur,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
conn=None,
|
||||
) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||
"""
|
||||
Standard nach erfolgreichem Verbrauch: zählen, protokollieren, Snapshot für Response.
|
||||
|
||||
Alle Endpoints mit Vereins-Kontingent-Verbrauch nutzen diese Funktion und
|
||||
``merge_feature_usage_into_response`` — kein duplizierter Einzelcode pro Route.
|
||||
"""
|
||||
consume_club_feature(
|
||||
feature_id=feature_id,
|
||||
club_id=club_id,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
action=action,
|
||||
amount=amount,
|
||||
conn=conn,
|
||||
)
|
||||
if club_id is None:
|
||||
return None
|
||||
return {
|
||||
feature_id: club_feature_usage_for_api(
|
||||
cur,
|
||||
club_id=int(club_id),
|
||||
feature_id=feature_id,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def merge_feature_usage_into_response(
|
||||
payload: Any,
|
||||
feature_usage: Optional[Dict[str, Dict[str, Any]]],
|
||||
) -> Any:
|
||||
"""Standard-Einbettung ``feature_usage`` in JSON-Responses."""
|
||||
if not feature_usage or not isinstance(payload, dict):
|
||||
return payload
|
||||
return {**payload, "feature_usage": feature_usage}
|
||||
|
||||
|
||||
def club_feature_usage_for_api(
|
||||
cur,
|
||||
*,
|
||||
club_id: int,
|
||||
feature_id: str,
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Feature-Zustand wie GET /me/entitlements → features[feature_id] (nach Verbrauch)."""
|
||||
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||
|
||||
db_conn = conn if conn is not None else cur.connection
|
||||
access = check_club_feature_access(int(club_id), feature_id, conn=db_conn)
|
||||
plan_id = access.get("plan_id") or get_effective_club_plan(cur, int(club_id))
|
||||
|
||||
if is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
feature_id=feature_id,
|
||||
tenant=tenant,
|
||||
):
|
||||
ex = quota_bypass_access(
|
||||
feature_id=feature_id,
|
||||
club_id=int(club_id),
|
||||
plan_id=plan_id,
|
||||
)
|
||||
reset_at = access.get("reset_at")
|
||||
return {
|
||||
"allowed": True,
|
||||
"used": access.get("used"),
|
||||
"limit": None,
|
||||
"remaining": None,
|
||||
"reason": ex.get("reason"),
|
||||
"platform_exempt": True,
|
||||
"reset_at": reset_at.isoformat() if hasattr(reset_at, "isoformat") else reset_at,
|
||||
}
|
||||
|
||||
return {
|
||||
"allowed": access.get("allowed"),
|
||||
"used": access.get("used"),
|
||||
"limit": access.get("limit"),
|
||||
"remaining": access.get("remaining"),
|
||||
"reason": access.get("reason"),
|
||||
"platform_exempt": False,
|
||||
"reset_at": access.get("reset_at").isoformat()
|
||||
if access.get("reset_at") is not None and hasattr(access.get("reset_at"), "isoformat")
|
||||
else access.get("reset_at"),
|
||||
}
|
||||
|
||||
|
||||
def increment_club_feature_usage(
|
||||
club_id: int,
|
||||
feature_id: str,
|
||||
*,
|
||||
profile_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
conn=None,
|
||||
) -> None:
|
||||
"""Erhöht Vereins-Zähler (nur bei neuem Verbrauch / INSERT-Pfad aufrufen)."""
|
||||
def _run(c):
|
||||
cur = get_cursor(c)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT reset_period, limit_type
|
||||
FROM features
|
||||
WHERE id = %s AND app = 'shinkan' AND active = true
|
||||
""",
|
||||
(feature_id,),
|
||||
)
|
||||
feature = cur.fetchone()
|
||||
if not feature:
|
||||
return
|
||||
if (feature.get("limit_type") or "count").strip().lower() == "boolean":
|
||||
return
|
||||
|
||||
period = (feature.get("reset_period") or "never").strip().lower()
|
||||
next_reset = _calculate_next_reset(period)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_feature_usage (club_id, feature_id, usage_count, reset_at, last_used_at)
|
||||
VALUES (%s, %s, 1, %s, NOW())
|
||||
ON CONFLICT (club_id, feature_id)
|
||||
DO UPDATE SET
|
||||
usage_count = club_feature_usage.usage_count + 1,
|
||||
last_used_at = NOW(),
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(club_id, feature_id, next_reset),
|
||||
)
|
||||
|
||||
if profile_id is not None or action:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_feature_usage_events (club_id, feature_id, profile_id, action)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(club_id, feature_id, profile_id, action or feature_id),
|
||||
)
|
||||
|
||||
if conn is not None:
|
||||
_run(conn)
|
||||
else:
|
||||
with get_db() as c:
|
||||
_run(c)
|
||||
|
||||
|
||||
def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern)."""
|
||||
db_conn = conn if conn is not None else cur.connection
|
||||
plan_id = get_effective_club_plan(cur, club_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, category, limit_type, reset_period
|
||||
FROM features
|
||||
WHERE app = 'shinkan' AND active = true
|
||||
ORDER BY category, id
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
features_out = []
|
||||
for row in rows:
|
||||
fid = row["id"]
|
||||
access = _check_club_impl(club_id, fid, db_conn)
|
||||
features_out.append(
|
||||
{
|
||||
"id": fid,
|
||||
"name": row.get("name"),
|
||||
"category": row.get("category"),
|
||||
"limit_type": row.get("limit_type"),
|
||||
"reset_period": row.get("reset_period"),
|
||||
"allowed": access.get("allowed"),
|
||||
"limit": access.get("limit"),
|
||||
"used": access.get("used"),
|
||||
"remaining": access.get("remaining"),
|
||||
"reason": access.get("reason"),
|
||||
"reset_at": access.get("reset_at"),
|
||||
}
|
||||
)
|
||||
return {"club_id": club_id, "plan_id": plan_id, "features": features_out}
|
||||
|
||||
|
||||
def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||
"""Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements)."""
|
||||
raw = list_club_entitlements(cur, club_id, conn=conn)
|
||||
features_dict: Dict[str, Any] = {}
|
||||
for row in raw.get("features") or []:
|
||||
fid = row["id"]
|
||||
features_dict[fid] = {
|
||||
"name": row.get("name"),
|
||||
"category": row.get("category"),
|
||||
"limit_type": row.get("limit_type"),
|
||||
"reset_period": row.get("reset_period"),
|
||||
"allowed": row.get("allowed"),
|
||||
"limit": row.get("limit"),
|
||||
"used": row.get("used"),
|
||||
"remaining": row.get("remaining"),
|
||||
"reason": row.get("reason"),
|
||||
"reset_at": row.get("reset_at"),
|
||||
}
|
||||
return {
|
||||
"club_id": raw.get("club_id"),
|
||||
"plan_id": raw.get("plan_id"),
|
||||
"features": features_dict,
|
||||
}
|
||||
180
backend/club_quota_bypass.py
Normal file
180
backend/club_quota_bypass.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
Vereins-Kontingent-Bypass über das Capability-System (kein Parallel-Rechtemodell).
|
||||
|
||||
Capabilities:
|
||||
- platform.club_quota.bypass — alle Vereins-Features (Portal-Admin, Grant via portal_role)
|
||||
- platform.club_quota.bypass.{feature_id} — ein Feature (domain quota_bypass, auch für Nicht-Admins per Grant)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
QUOTA_BYPASS_ALL = "platform.club_quota.bypass"
|
||||
QUOTA_BYPASS_FEATURE_PREFIX = "platform.club_quota.bypass."
|
||||
|
||||
|
||||
def quota_bypass_capability_id_for_feature(feature_id: str) -> str:
|
||||
return f"{QUOTA_BYPASS_FEATURE_PREFIX}{feature_id}"
|
||||
|
||||
|
||||
def ensure_quota_bypass_capability(cur, feature_id: str) -> str:
|
||||
"""Legt feature-spezifische Bypass-Capability an falls nötig."""
|
||||
cap_id = quota_bypass_capability_id_for_feature(feature_id)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||
VALUES (%s, %s, 'quota_bypass', 'active_member', %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
(cap_id, f"Vereins-Kontingent umgehen: {feature_id}", feature_id),
|
||||
)
|
||||
return cap_id
|
||||
|
||||
|
||||
def _bypass_capability_ids(cur, feature_id: str) -> List[str]:
|
||||
ids: List[str] = [QUOTA_BYPASS_ALL, quota_bypass_capability_id_for_feature(feature_id)]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM capabilities
|
||||
WHERE active = true
|
||||
AND domain = 'quota_bypass'
|
||||
AND linked_feature_id = %s
|
||||
AND id <> %s
|
||||
""",
|
||||
(feature_id, quota_bypass_capability_id_for_feature(feature_id)),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
cid = row.get("id")
|
||||
if cid and cid not in ids:
|
||||
ids.append(str(cid))
|
||||
return ids
|
||||
|
||||
|
||||
def _portal_role_has_grant(cur, portal_role: str, capability_id: str) -> bool:
|
||||
role = (portal_role or "").strip().lower()
|
||||
if not role:
|
||||
return False
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(role, capability_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _profile_has_grant(cur, profile_id: int, capability_id: str) -> bool:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM profile_capability_grants
|
||||
WHERE profile_id = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(int(profile_id), capability_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
*,
|
||||
profile_id: Optional[int],
|
||||
portal_role: Optional[str],
|
||||
feature_id: str,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
True wenn ein konfigurierter Capability-Grant das Vereins-Kontingent für feature_id umgeht.
|
||||
"""
|
||||
if tenant is not None:
|
||||
from capabilities import check_capability
|
||||
|
||||
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||
if check_capability(cur, tenant, cap_id).get("allowed"):
|
||||
return True
|
||||
return False
|
||||
|
||||
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||
if _portal_role_has_grant(cur, portal_role or "", cap_id):
|
||||
return True
|
||||
if profile_id is not None and _profile_has_grant(cur, int(profile_id), cap_id):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def quota_bypass_access(
|
||||
*,
|
||||
feature_id: str,
|
||||
club_id: Optional[int] = None,
|
||||
plan_id: Optional[str] = None,
|
||||
capability_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"allowed": True,
|
||||
"limit": None,
|
||||
"used": 0,
|
||||
"remaining": None,
|
||||
"reason": "capability_quota_bypass",
|
||||
"platform_exempt": True,
|
||||
"quota_bypass_capability": capability_id,
|
||||
"plan_id": plan_id,
|
||||
"club_id": club_id,
|
||||
"feature_id": feature_id,
|
||||
}
|
||||
|
||||
|
||||
def list_quota_bypass_grants(cur) -> Dict[str, Any]:
|
||||
"""Admin: alle Grants zu Kontingent-Bypass-Capabilities."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT g.portal_role, g.capability_id, c.name AS capability_name,
|
||||
c.linked_feature_id, c.domain
|
||||
FROM portal_role_capability_grants g
|
||||
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||
WHERE g.capability_id = %s
|
||||
OR g.capability_id LIKE %s
|
||||
OR c.domain = 'quota_bypass'
|
||||
ORDER BY g.portal_role, g.capability_id
|
||||
""",
|
||||
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||
)
|
||||
portal_grants = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT g.profile_id, p.email, p.name AS profile_name,
|
||||
g.capability_id, c.name AS capability_name, c.linked_feature_id,
|
||||
g.reason, g.granted_by_profile_id, g.created_at
|
||||
FROM profile_capability_grants g
|
||||
INNER JOIN profiles p ON p.id = g.profile_id
|
||||
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||
WHERE g.capability_id = %s
|
||||
OR g.capability_id LIKE %s
|
||||
OR c.domain = 'quota_bypass'
|
||||
ORDER BY g.profile_id, g.capability_id
|
||||
""",
|
||||
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||
)
|
||||
profile_grants = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, domain, linked_feature_id
|
||||
FROM capabilities
|
||||
WHERE id = %s OR id LIKE %s OR domain = 'quota_bypass'
|
||||
ORDER BY id
|
||||
""",
|
||||
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||
)
|
||||
capabilities = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"capabilities": capabilities,
|
||||
"portal_role_grants": portal_grants,
|
||||
"profile_grants": profile_grants,
|
||||
}
|
||||
|
|
@ -180,12 +180,17 @@ def init_db():
|
|||
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
|
||||
if cur.fetchone()['count'] == 0:
|
||||
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 (
|
||||
'pipeline',
|
||||
'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',
|
||||
'admin',
|
||||
'text',
|
||||
true,
|
||||
-10
|
||||
)
|
||||
|
|
|
|||
113
backend/entitlements.py
Normal file
113
backend/entitlements.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""
|
||||
Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4).
|
||||
|
||||
Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from capabilities import club_roles_in_club, resolve_capabilities_map
|
||||
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||
from club_features import club_features_map
|
||||
from club_tenancy import is_platform_admin
|
||||
from tenant_context import _club_exists
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def _serialize_reset_at(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=None).isoformat() + "Z"
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _resolve_target_club_id(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
club_id: Optional[int],
|
||||
) -> Optional[int]:
|
||||
"""Effektiver Verein für Entitlements (Query > Tenant)."""
|
||||
target = int(club_id) if club_id is not None else tenant.effective_club_id
|
||||
if target is None:
|
||||
return None
|
||||
|
||||
if is_platform_admin(tenant.global_role):
|
||||
if not _club_exists(cur, target):
|
||||
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
|
||||
return target
|
||||
|
||||
if target not in tenant.club_ids:
|
||||
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
|
||||
return target
|
||||
|
||||
|
||||
def build_me_entitlements(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
*,
|
||||
club_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Kombiniert Account-Status, Capabilities und Feature-Kontingente.
|
||||
"""
|
||||
target_club = _resolve_target_club_id(cur, tenant, club_id)
|
||||
club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else []
|
||||
|
||||
capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club)
|
||||
|
||||
features: Dict[str, Any] = {}
|
||||
plan_id = None
|
||||
if target_club is not None:
|
||||
raw = club_features_map(cur, target_club)
|
||||
plan_id = raw.get("plan_id")
|
||||
for fid, row in (raw.get("features") or {}).items():
|
||||
if is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
feature_id=fid,
|
||||
tenant=tenant,
|
||||
):
|
||||
ex = quota_bypass_access(
|
||||
feature_id=fid,
|
||||
club_id=target_club,
|
||||
plan_id=plan_id,
|
||||
)
|
||||
features[fid] = {
|
||||
"allowed": True,
|
||||
"used": row.get("used"),
|
||||
"limit": None,
|
||||
"remaining": None,
|
||||
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||
"reason": ex.get("reason"),
|
||||
"platform_exempt": True,
|
||||
}
|
||||
else:
|
||||
features[fid] = {
|
||||
"allowed": row.get("allowed"),
|
||||
"used": row.get("used"),
|
||||
"limit": row.get("limit"),
|
||||
"remaining": row.get("remaining"),
|
||||
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||
"reason": row.get("reason"),
|
||||
"platform_exempt": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"account_state": tenant.account_state,
|
||||
"portal_role": tenant.global_role,
|
||||
"club_id": target_club,
|
||||
"plan_id": plan_id,
|
||||
"club_roles": club_roles,
|
||||
"capabilities": capabilities,
|
||||
"features": features,
|
||||
}
|
||||
1122
backend/exercise_ai.py
Normal file
1122
backend/exercise_ai.py
Normal file
File diff suppressed because it is too large
Load Diff
536
backend/exercise_enrichment.py
Normal file
536
backend/exercise_enrichment.py
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
"""
|
||||
Superadmin-Werkzeug: Übungs-Anreicherung per KI (Skills + optional Metadaten).
|
||||
|
||||
Wiederverwendet run_exercise_form_ai_suggestion / exercise_ai — keine neue OpenRouter-Pipeline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||
from exercise_ai import strip_html_to_plain
|
||||
from exercise_rich_text import normalize_inline_exercise_media_markup
|
||||
|
||||
from routers.exercises import (
|
||||
enrich_exercise_detail,
|
||||
normalize_exercise_skill_intensity,
|
||||
normalize_exercise_skill_level,
|
||||
)
|
||||
|
||||
SkillMergeMode = Literal["additive", "replace_ai_only", "replace_all"]
|
||||
|
||||
SKILL_MERGE_MODES = frozenset({"additive", "replace_ai_only", "replace_all"})
|
||||
DEFAULT_SET_STATUS = "in_review"
|
||||
# Max. IDs pro Apply-HTTP-Anfrage (kein LLM).
|
||||
MAX_BATCH_EXERCISES = 50
|
||||
# Preview: pro Request nur wenige Übungen — sonst Gateway-504 (Fritz!Box o.ä. ~60s).
|
||||
MAX_PREVIEW_BATCH_EXERCISES = 3
|
||||
|
||||
_INSTRUCTION_FIELDS = ("goal", "execution", "preparation", "trainer_notes")
|
||||
_SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary")
|
||||
|
||||
|
||||
def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]:
|
||||
rows: list[tuple[int, bool]] = []
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
fid = int(row.get("focus_area_id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if fid < 1:
|
||||
continue
|
||||
rows.append((fid, bool(row.get("is_primary"))))
|
||||
rows.sort(key=lambda x: (not x[1], x[0]))
|
||||
return rows
|
||||
|
||||
|
||||
def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str:
|
||||
parts: List[str] = []
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if isinstance(row, dict):
|
||||
nm = (row.get("name") or "").strip()
|
||||
if nm:
|
||||
parts.append(nm)
|
||||
txt = ", ".join(parts).strip()
|
||||
if len(txt) > 900:
|
||||
return txt[:899] + "…"
|
||||
return txt
|
||||
|
||||
|
||||
def build_form_context_from_exercise(exercise: Dict[str, Any]) -> ExerciseFormAiPromptContext:
|
||||
focus = _focus_area_hint_from_detail(exercise)
|
||||
fctx = _focus_areas_ai_ctx_from_detail(exercise)
|
||||
return ExerciseFormAiPromptContext.from_focus_tuples(
|
||||
title=str(exercise.get("title") or "").strip(),
|
||||
goal=exercise.get("goal"),
|
||||
execution=exercise.get("execution"),
|
||||
preparation=exercise.get("preparation"),
|
||||
trainer_notes=exercise.get("trainer_notes"),
|
||||
focus_hint=focus or None,
|
||||
focus_tuples=fctx or None,
|
||||
)
|
||||
|
||||
|
||||
def validate_exercise_for_enrichment(
|
||||
exercise: Dict[str, Any],
|
||||
*,
|
||||
want_skills: bool = False,
|
||||
want_summary: bool = False,
|
||||
want_instructions: bool = False,
|
||||
) -> Optional[str]:
|
||||
title = str(exercise.get("title") or "").strip()
|
||||
if not title:
|
||||
return "Titel fehlt"
|
||||
|
||||
ctx = build_form_context_from_exercise(exercise)
|
||||
g_plain = strip_html_to_plain(exercise.get("goal"))
|
||||
e_plain = strip_html_to_plain(exercise.get("execution"))
|
||||
|
||||
if want_skills or want_summary:
|
||||
if not (g_plain.strip() or e_plain.strip()):
|
||||
return "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)"
|
||||
|
||||
if want_instructions and not ctx.has_instruction_source_text():
|
||||
return "Für Anleitungs-Überarbeitung fehlt Ausgangstext (Titel oder Anleitungsfeld)"
|
||||
|
||||
if not (want_skills or want_summary or want_instructions):
|
||||
return "Kein Anreicherungsmodus aktiv"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_skill_row(raw: Dict[str, Any], *, ai_suggested: bool) -> Dict[str, Any]:
|
||||
return {
|
||||
"skill_id": int(raw["skill_id"]),
|
||||
"skill_name": (raw.get("skill_name") or "").strip() or f"Skill #{raw['skill_id']}",
|
||||
"skill_category": raw.get("skill_category"),
|
||||
"is_primary": bool(raw.get("is_primary")),
|
||||
"intensity": normalize_exercise_skill_intensity(raw.get("intensity")),
|
||||
"required_level": normalize_exercise_skill_level(raw.get("required_level")),
|
||||
"target_level": normalize_exercise_skill_level(raw.get("target_level")),
|
||||
"ai_suggested": ai_suggested,
|
||||
}
|
||||
|
||||
|
||||
def _skill_meta_differs(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
||||
for k in _SKILL_COMPARE_KEYS:
|
||||
av = a.get(k)
|
||||
bv = b.get(k)
|
||||
if k in ("required_level", "target_level"):
|
||||
av = normalize_exercise_skill_level(av)
|
||||
bv = normalize_exercise_skill_level(bv)
|
||||
elif k == "intensity":
|
||||
av = normalize_exercise_skill_intensity(av)
|
||||
bv = normalize_exercise_skill_intensity(bv)
|
||||
elif k == "is_primary":
|
||||
av = bool(av)
|
||||
bv = bool(bv)
|
||||
if av != bv:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def merge_skills(
|
||||
existing: List[Dict[str, Any]],
|
||||
suggested: List[Dict[str, Any]],
|
||||
mode: SkillMergeMode,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Merge-Modi: additive | replace_ai_only | replace_all (alle KI-Skills mit ai_suggested=true)."""
|
||||
existing_norm = [_normalize_skill_row(s, ai_suggested=bool(s.get("ai_suggested"))) for s in existing]
|
||||
suggested_norm = [_normalize_skill_row(s, ai_suggested=True) for s in suggested]
|
||||
|
||||
suggested_by_id = {int(s["skill_id"]): s for s in suggested_norm}
|
||||
|
||||
if mode == "replace_all":
|
||||
return list(suggested_norm)
|
||||
|
||||
if mode == "replace_ai_only":
|
||||
manual = [s for s in existing_norm if not s.get("ai_suggested")]
|
||||
manual_ids = {int(s["skill_id"]) for s in manual}
|
||||
result = list(manual)
|
||||
for s in suggested_norm:
|
||||
sid = int(s["skill_id"])
|
||||
if sid in manual_ids:
|
||||
continue
|
||||
result.append(s)
|
||||
return result
|
||||
|
||||
# additive
|
||||
result: List[Dict[str, Any]] = []
|
||||
seen: set[int] = set()
|
||||
for s in existing_norm:
|
||||
sid = int(s["skill_id"])
|
||||
seen.add(sid)
|
||||
if sid in suggested_by_id and s.get("ai_suggested"):
|
||||
merged = {**s, **suggested_by_id[sid], "ai_suggested": True}
|
||||
result.append(merged)
|
||||
else:
|
||||
result.append(dict(s))
|
||||
for s in suggested_norm:
|
||||
sid = int(s["skill_id"])
|
||||
if sid not in seen:
|
||||
result.append(s)
|
||||
seen.add(sid)
|
||||
return result
|
||||
|
||||
|
||||
def compute_skill_diff(
|
||||
before: List[Dict[str, Any]],
|
||||
after: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
before_ids = {int(s["skill_id"]): s for s in before}
|
||||
after_ids = {int(s["skill_id"]): s for s in after}
|
||||
added = [after_ids[i] for i in sorted(after_ids) if i not in before_ids]
|
||||
removed = [before_ids[i] for i in sorted(before_ids) if i not in after_ids]
|
||||
changed: List[Dict[str, Any]] = []
|
||||
for sid in before_ids:
|
||||
if sid in after_ids and _skill_meta_differs(before_ids[sid], after_ids[sid]):
|
||||
changed.append(
|
||||
{
|
||||
"skill_id": sid,
|
||||
"skill_name": after_ids[sid].get("skill_name") or before_ids[sid].get("skill_name"),
|
||||
"before": before_ids[sid],
|
||||
"after": after_ids[sid],
|
||||
}
|
||||
)
|
||||
kept = [
|
||||
before_ids[i]
|
||||
for i in sorted(before_ids)
|
||||
if i in after_ids and i not in {c["skill_id"] for c in changed}
|
||||
]
|
||||
return {"added": added, "removed": removed, "changed": changed, "kept": kept}
|
||||
|
||||
|
||||
def _skills_from_ai_payload(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
rows = payload.get("skills")
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [_normalize_skill_row(r, ai_suggested=True) for r in rows if isinstance(r, dict) and r.get("skill_id")]
|
||||
|
||||
|
||||
def _summary_from_ai_payload(payload: Dict[str, Any]) -> Optional[str]:
|
||||
block = payload.get("summary")
|
||||
if isinstance(block, dict):
|
||||
text = (block.get("text") or "").strip()
|
||||
return text or None
|
||||
if isinstance(block, str) and block.strip():
|
||||
return block.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _instructions_from_ai_payload(payload: Dict[str, Any]) -> Dict[str, str]:
|
||||
block = payload.get("instructions")
|
||||
if not isinstance(block, dict):
|
||||
return {}
|
||||
fields = block.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
out: Dict[str, str] = {}
|
||||
for key in _INSTRUCTION_FIELDS:
|
||||
val = fields.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
out[key] = str(val).strip()
|
||||
return out
|
||||
|
||||
|
||||
def _instruction_snapshot(exercise: Dict[str, Any]) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for key in _INSTRUCTION_FIELDS:
|
||||
raw = exercise.get(key)
|
||||
plain = strip_html_to_plain(raw, max_len=400) if raw else ""
|
||||
if plain.strip():
|
||||
out[key] = plain.strip()
|
||||
return out
|
||||
|
||||
|
||||
def compute_instruction_diff(
|
||||
before: Dict[str, str],
|
||||
after: Dict[str, str],
|
||||
) -> Dict[str, Any]:
|
||||
changed: List[Dict[str, Any]] = []
|
||||
added: List[str] = []
|
||||
for key in _INSTRUCTION_FIELDS:
|
||||
b = (before.get(key) or "").strip()
|
||||
a = (after.get(key) or "").strip()
|
||||
if not a:
|
||||
continue
|
||||
if not b:
|
||||
added.append(key)
|
||||
elif b != strip_html_to_plain(a, max_len=400).strip() and b != a:
|
||||
changed.append({"field": key, "before_plain": b, "after_html": a})
|
||||
return {"changed_fields": changed, "added_fields": added}
|
||||
|
||||
|
||||
def preview_exercise_enrichment(
|
||||
cur,
|
||||
exercise_id: int,
|
||||
*,
|
||||
want_skills: bool = True,
|
||||
want_summary: bool = False,
|
||||
want_instructions: bool = False,
|
||||
merge_mode: SkillMergeMode = "additive",
|
||||
) -> Dict[str, Any]:
|
||||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||
if not exercise:
|
||||
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
||||
|
||||
skip_reason = validate_exercise_for_enrichment(
|
||||
exercise,
|
||||
want_skills=want_skills,
|
||||
want_summary=want_summary,
|
||||
want_instructions=want_instructions,
|
||||
)
|
||||
if skip_reason:
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": False,
|
||||
"skipped": True,
|
||||
"error": skip_reason,
|
||||
"title": exercise.get("title"),
|
||||
"status": exercise.get("status"),
|
||||
}
|
||||
|
||||
existing = exercise.get("skills") or []
|
||||
suggested: List[Dict[str, Any]] = []
|
||||
ai_meta: Dict[str, Any] = {}
|
||||
payload: Dict[str, Any] = {}
|
||||
suggested_summary: Optional[str] = None
|
||||
suggested_instructions: Dict[str, str] = {}
|
||||
|
||||
if want_skills or want_summary or want_instructions:
|
||||
ctx = build_form_context_from_exercise(exercise)
|
||||
payload = run_exercise_form_ai_suggestion(
|
||||
cur,
|
||||
ctx,
|
||||
want_summary=want_summary,
|
||||
want_skills=want_skills,
|
||||
want_instructions=want_instructions,
|
||||
)
|
||||
if want_skills:
|
||||
suggested = _skills_from_ai_payload(payload)
|
||||
if want_summary:
|
||||
suggested_summary = _summary_from_ai_payload(payload)
|
||||
if want_instructions:
|
||||
suggested_instructions = _instructions_from_ai_payload(payload)
|
||||
ai_meta = {
|
||||
"models": payload.get("models_by_slug") or {},
|
||||
"llm_calls": sum([want_skills, want_summary, want_instructions]),
|
||||
}
|
||||
|
||||
merged = merge_skills(existing, suggested, merge_mode) if want_skills else list(existing)
|
||||
diff = compute_skill_diff(existing, merged) if want_skills else None
|
||||
|
||||
existing_summary = (exercise.get("summary") or "").strip() or None
|
||||
instr_before = _instruction_snapshot(exercise)
|
||||
instr_after_plain = {
|
||||
k: strip_html_to_plain(v, max_len=400) for k, v in suggested_instructions.items()
|
||||
}
|
||||
instruction_diff = (
|
||||
compute_instruction_diff(instr_before, instr_after_plain) if want_instructions else None
|
||||
)
|
||||
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": True,
|
||||
"title": exercise.get("title"),
|
||||
"status": exercise.get("status"),
|
||||
"visibility": exercise.get("visibility"),
|
||||
"primary_focus_name": _primary_focus_from_exercise(exercise),
|
||||
"existing_skills": existing,
|
||||
"suggested_skills": suggested,
|
||||
"merged_skills": merged,
|
||||
"diff": diff,
|
||||
"existing_summary": existing_summary,
|
||||
"suggested_summary": suggested_summary,
|
||||
"existing_instructions": instr_before,
|
||||
"suggested_instructions": suggested_instructions,
|
||||
"instruction_diff": instruction_diff,
|
||||
"ai_meta": ai_meta,
|
||||
}
|
||||
|
||||
|
||||
def _primary_focus_from_exercise(exercise: Dict[str, Any]) -> Optional[str]:
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if isinstance(row, dict) and row.get("is_primary"):
|
||||
return (row.get("name") or "").strip() or None
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if isinstance(row, dict):
|
||||
nm = (row.get("name") or "").strip()
|
||||
if nm:
|
||||
return nm
|
||||
return None
|
||||
|
||||
|
||||
def persist_merged_skills(cur, exercise_id: int, merged: List[Dict[str, Any]], merge_mode: SkillMergeMode) -> None:
|
||||
if merge_mode == "replace_all":
|
||||
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
|
||||
elif merge_mode == "replace_ai_only":
|
||||
cur.execute(
|
||||
"DELETE FROM exercise_skills WHERE exercise_id = %s AND ai_suggested = true",
|
||||
(exercise_id,),
|
||||
)
|
||||
|
||||
for sk in merged:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO exercise_skills
|
||||
(exercise_id, skill_id, is_primary, intensity, required_level, target_level, ai_suggested)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
|
||||
intensity = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.intensity ELSE EXCLUDED.intensity END,
|
||||
required_level = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.required_level ELSE EXCLUDED.required_level END,
|
||||
target_level = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.target_level ELSE EXCLUDED.target_level END,
|
||||
is_primary = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.is_primary ELSE EXCLUDED.is_primary END,
|
||||
ai_suggested = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.ai_suggested ELSE EXCLUDED.ai_suggested END
|
||||
""",
|
||||
(
|
||||
exercise_id,
|
||||
int(sk["skill_id"]),
|
||||
bool(sk.get("is_primary")),
|
||||
normalize_exercise_skill_intensity(sk.get("intensity")),
|
||||
normalize_exercise_skill_level(sk.get("required_level")),
|
||||
normalize_exercise_skill_level(sk.get("target_level")),
|
||||
bool(sk.get("ai_suggested")),
|
||||
merge_mode,
|
||||
merge_mode,
|
||||
merge_mode,
|
||||
merge_mode,
|
||||
merge_mode,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_instruction_fields(fields: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||
if not fields:
|
||||
return {}
|
||||
out: Dict[str, str] = {}
|
||||
for key in _INSTRUCTION_FIELDS:
|
||||
if key not in fields:
|
||||
continue
|
||||
raw = fields.get(key)
|
||||
if raw is None or not str(raw).strip():
|
||||
continue
|
||||
out[key] = normalize_inline_exercise_media_markup(str(raw).strip())
|
||||
return out
|
||||
|
||||
|
||||
def apply_exercise_enrichment(
|
||||
cur,
|
||||
exercise_id: int,
|
||||
*,
|
||||
merged_skills: Optional[List[Dict[str, Any]]] = None,
|
||||
merge_mode: SkillMergeMode = "additive",
|
||||
set_status: Optional[str] = DEFAULT_SET_STATUS,
|
||||
apply_skills: bool = False,
|
||||
summary_text: Optional[str] = None,
|
||||
apply_summary: bool = False,
|
||||
instruction_fields: Optional[Dict[str, Any]] = None,
|
||||
apply_instructions: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||
if not exercise:
|
||||
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
||||
|
||||
skip_reason = validate_exercise_for_enrichment(
|
||||
exercise,
|
||||
want_skills=apply_skills,
|
||||
want_summary=apply_summary,
|
||||
want_instructions=apply_instructions,
|
||||
)
|
||||
if skip_reason:
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": False,
|
||||
"skipped": True,
|
||||
"error": skip_reason,
|
||||
}
|
||||
|
||||
skills_list = merged_skills or []
|
||||
if apply_skills:
|
||||
if not skills_list and merge_mode != "replace_all":
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": False,
|
||||
"error": "Keine Skills zum Anwenden",
|
||||
}
|
||||
persist_merged_skills(cur, exercise_id, skills_list, merge_mode)
|
||||
|
||||
sets: List[str] = []
|
||||
vals: List[Any] = []
|
||||
|
||||
if apply_summary and summary_text is not None:
|
||||
text = str(summary_text).strip()
|
||||
if text:
|
||||
sets.extend(["summary = %s", "summary_ai_generated = true"])
|
||||
vals.append(text[:220])
|
||||
|
||||
if apply_instructions:
|
||||
norm = _normalize_instruction_fields(instruction_fields)
|
||||
for key, val in norm.items():
|
||||
sets.append(f"{key} = %s")
|
||||
vals.append(val)
|
||||
|
||||
new_status = (set_status or "").strip().lower() or None
|
||||
if new_status:
|
||||
if new_status == "approved":
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": False,
|
||||
"error": "Automatisches Freigeben (approved) ist nicht erlaubt",
|
||||
}
|
||||
if new_status not in ("draft", "in_review", "archived"):
|
||||
return {"exercise_id": exercise_id, "ok": False, "error": "Ungültiger Ziel-Status"}
|
||||
sets.append("status = %s")
|
||||
vals.append(new_status)
|
||||
|
||||
if sets:
|
||||
sets.append("updated_at = NOW()")
|
||||
vals.append(exercise_id)
|
||||
cur.execute(
|
||||
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
|
||||
tuple(vals),
|
||||
)
|
||||
elif not apply_skills:
|
||||
return {"exercise_id": exercise_id, "ok": False, "error": "Nichts anzuwenden"}
|
||||
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": True,
|
||||
"status": new_status or exercise.get("status"),
|
||||
"skills_applied": len(skills_list) if apply_skills else 0,
|
||||
"summary_applied": apply_summary and bool(summary_text and str(summary_text).strip()),
|
||||
"instructions_applied": apply_instructions and bool(_normalize_instruction_fields(instruction_fields)),
|
||||
}
|
||||
|
||||
|
||||
def estimate_llm_calls(
|
||||
*,
|
||||
exercise_count: int,
|
||||
want_skills: bool,
|
||||
want_summary: bool,
|
||||
want_instructions: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
per_skills = exercise_count if want_skills else 0
|
||||
per_summary = exercise_count if want_summary else 0
|
||||
per_instructions = exercise_count if want_instructions else 0
|
||||
total = per_skills + per_summary + per_instructions
|
||||
return {
|
||||
"total": total,
|
||||
"per_exercise": sum([want_skills, want_summary, want_instructions]),
|
||||
"skills": per_skills,
|
||||
"summary": per_summary,
|
||||
"instructions": per_instructions,
|
||||
}
|
||||
|
|
@ -52,6 +52,28 @@ else:
|
|||
print(f"[FAIL] Migration-Laufzeitfehler: {e}")
|
||||
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
|
||||
|
||||
# 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")
|
||||
async def add_api_security_headers(request: Request, call_next):
|
||||
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
|
||||
|
|
@ -193,7 +243,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, 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, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -202,7 +252,11 @@ app.include_router(exercise_progression_graphs.router)
|
|||
app.include_router(clubs.router)
|
||||
app.include_router(club_memberships.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_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(media_assets.router)
|
||||
app.include_router(media_assets.admin_rights_router)
|
||||
|
|
@ -210,16 +264,21 @@ app.include_router(media_assets.admin_legal_hold_router)
|
|||
app.include_router(skills.router)
|
||||
app.include_router(skill_profiles.router)
|
||||
app.include_router(training_planning.router)
|
||||
app.include_router(planning_exercise_suggest.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(training_modules.router)
|
||||
app.include_router(training_framework_programs.router)
|
||||
app.include_router(catalogs.router)
|
||||
app.include_router(maturity_models.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_admin.router)
|
||||
app.include_router(legal_documents.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
|
||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||
|
|
|
|||
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';
|
||||
224
backend/openrouter_chat.py
Normal file
224
backend/openrouter_chat.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
"""
|
||||
Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MODEL / OPENROUTER_BASE_URL from env.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
_logger = logging.getLogger("shinkan.openrouter")
|
||||
|
||||
_SKIP_ANTHROPIC_BLOCK_TYPES = frozenset(
|
||||
{
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
"reasoning",
|
||||
"tool_use",
|
||||
"tool_calls",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _shinkan_ai_debug() -> bool:
|
||||
return os.getenv("SHINKAN_AI_DEBUG", "").strip().lower() in ("1", "true", "yes", "full")
|
||||
|
||||
|
||||
def _coerce_nested_text(val: Any) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, str):
|
||||
return val.strip()
|
||||
if isinstance(val, bool) or isinstance(val, (int, float)):
|
||||
return str(val).strip()
|
||||
if isinstance(val, list):
|
||||
return "".join(_coerce_nested_text(x) for x in val).strip()
|
||||
if isinstance(val, dict):
|
||||
# OpenRouter/Anthropic: verschachtelte text/content-Hüllen
|
||||
for key in ("text", "content", "value"):
|
||||
if key in val:
|
||||
nested = _coerce_nested_text(val.get(key))
|
||||
if nested:
|
||||
return nested
|
||||
return ""
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def _flatten_message_content(content: Any) -> str:
|
||||
"""
|
||||
Chat-Completion: `content` als String oder als Liste strukturierter Blöcke
|
||||
(Anthropic Claude über OpenRouter/Bedrock, teils verschachtelt).
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
bits = _coerce_nested_text(block)
|
||||
if bits:
|
||||
parts.append(bits)
|
||||
elif isinstance(block, dict):
|
||||
t_raw = block.get("type")
|
||||
ts = str(t_raw or "").strip().lower()
|
||||
if ts and (ts in _SKIP_ANTHROPIC_BLOCK_TYPES or ts.endswith("_thinking")):
|
||||
continue
|
||||
txt = None
|
||||
if ts in ("text", "output_text", ""):
|
||||
txt = block.get("text")
|
||||
if txt is None:
|
||||
txt = block.get("content")
|
||||
else:
|
||||
# unbekannten Typ weiter versuchen (Provider-Varianten), aber tool-use überspringen
|
||||
low = ts
|
||||
if "tool_use" in low or low.startswith("tool_"):
|
||||
continue
|
||||
txt = block.get("text") if block.get("text") is not None else block.get("content")
|
||||
bits = _coerce_nested_text(txt)
|
||||
if bits:
|
||||
parts.append(bits)
|
||||
return "".join(parts).strip()
|
||||
if isinstance(content, dict):
|
||||
return _coerce_nested_text(content)
|
||||
return str(content).strip()
|
||||
|
||||
|
||||
class OpenRouterError(Exception):
|
||||
"""Upstream or transport failure."""
|
||||
|
||||
|
||||
def openrouter_chat_completion(
|
||||
*,
|
||||
api_key: str,
|
||||
model: str,
|
||||
user_content: str,
|
||||
system_content: Optional[str] = None,
|
||||
timeout_sec: float = 120.0,
|
||||
temperature: float = 0.25,
|
||||
site_url: Optional[str] = None,
|
||||
app_title: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Returns assistant message content (plain string). Caller validates empty responses.
|
||||
"""
|
||||
base = (os.getenv("OPENROUTER_BASE_URL") or "").strip().rstrip("/") or "https://openrouter.ai/api/v1"
|
||||
url = f"{base}/chat/completions"
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
referer = (site_url or os.getenv("APP_URL") or "").strip()
|
||||
if referer:
|
||||
headers["HTTP-Referer"] = referer
|
||||
title = (app_title or os.getenv("OPENROUTER_APP_TITLE") or "Shinkan Jinkendo").strip()
|
||||
if title:
|
||||
headers["X-Title"] = title
|
||||
|
||||
messages: List[Dict[str, str]] = []
|
||||
if system_content and str(system_content).strip():
|
||||
messages.append({"role": "system", "content": str(system_content).strip()})
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=timeout_sec) as client:
|
||||
resp = client.post(url, headers=headers, json=payload)
|
||||
except httpx.RequestError as e:
|
||||
raise OpenRouterError(str(e)) from e
|
||||
|
||||
if resp.status_code >= 400:
|
||||
detail = ""
|
||||
try:
|
||||
j = resp.json()
|
||||
detail = (
|
||||
str(j.get("error", {}).get("message"))
|
||||
if isinstance(j.get("error"), dict)
|
||||
else str(j.get("message") or j)
|
||||
)
|
||||
except Exception:
|
||||
detail = (resp.text or "")[:600]
|
||||
raise OpenRouterError(f"HTTP {resp.status_code}: {detail}".strip())
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except json.JSONDecodeError as e:
|
||||
raise OpenRouterError("Ungueltige JSON-Antwort von OpenRouter") from e
|
||||
|
||||
choices = data.get("choices") if isinstance(data, dict) else None
|
||||
if not choices or not isinstance(choices, list):
|
||||
raise OpenRouterError("OpenRouter: keine choices in Antwort")
|
||||
|
||||
msg0 = choices[0] if choices else {}
|
||||
inner = msg0.get("message") if isinstance(msg0, dict) else None
|
||||
|
||||
blobs: List[Any] = []
|
||||
if isinstance(inner, dict):
|
||||
if inner.get("content") is not None:
|
||||
blobs.append(inner.get("content"))
|
||||
if inner.get("refusal") is not None:
|
||||
blobs.append(inner.get("refusal"))
|
||||
elif isinstance(inner, str):
|
||||
blobs.append(inner)
|
||||
if isinstance(msg0, dict) and msg0.get("content") is not None and msg0.get("content") not in blobs:
|
||||
blobs.append(msg0.get("content"))
|
||||
|
||||
pieces = [_flatten_message_content(b).strip() for b in blobs if b is not None]
|
||||
joined = ("\n".join(p for p in pieces if p)).strip()
|
||||
|
||||
if _shinkan_ai_debug():
|
||||
fr = str(msg0.get("finish_reason") or "") if isinstance(msg0, dict) else ""
|
||||
fu = data.get("usage") if isinstance(data.get("usage"), dict) else {}
|
||||
pu = str(fu.get("prompt_tokens") or "")
|
||||
pc = str(fu.get("completion_tokens") or "")
|
||||
pt = str(fu.get("total_tokens") or "")
|
||||
raw_cls = type(blobs[0]).__name__ if blobs else "none"
|
||||
cc = str(len(joined))
|
||||
_logger.warning(
|
||||
"[AI_DEBUG/openrouter] model=%s finish_reason=%s usage_prompt=%s usage_completion=%s usage_total=%s "
|
||||
"raw_content_cls=%s out_chars=%s",
|
||||
model,
|
||||
fr,
|
||||
pu,
|
||||
pc,
|
||||
pt,
|
||||
raw_cls,
|
||||
cc,
|
||||
)
|
||||
|
||||
return joined
|
||||
|
||||
|
||||
def normalize_openrouter_env() -> tuple[str, str]:
|
||||
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
|
||||
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
|
||||
return key, model
|
||||
|
||||
|
||||
def default_openrouter_model_id() -> str:
|
||||
"""Standard-Modell aus OPENROUTER_MODEL (ohne API-Key zu pruefen)."""
|
||||
_, model = normalize_openrouter_env()
|
||||
return model
|
||||
|
||||
|
||||
def effective_openrouter_model_for_prompt_row(row: Optional[Mapping[str, Any]]) -> str:
|
||||
"""
|
||||
Pro-Prompt-Override in ai_prompts.openrouter_model, sonst Env-Default.
|
||||
|
||||
`row` kann eine partial Row aus load_ai_prompt_row sein (Felder slug, openrouter_model, …).
|
||||
"""
|
||||
if row:
|
||||
custom = str(row.get("openrouter_model") or "").strip()
|
||||
if custom:
|
||||
return custom
|
||||
return default_openrouter_model_id()
|
||||
147
backend/planning_catalog_context.py
Normal file
147
backend/planning_catalog_context.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""
|
||||
Katalog-Kontext für Progressionsgraph-Planung — Fokusbereich, Stil, Trainingsstil, Zielgruppe.
|
||||
|
||||
Explizite Trainer-Auswahl ergänzt Freitext/LLM; ersetzt kein Roadmap-Didaktik-Modell.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from planning_exercise_profiles import PlanningTargetProfile, _normalize_weight_map
|
||||
from planning_exercise_target_pipeline import (
|
||||
SCENARIO_FREE_SEARCH,
|
||||
merge_query_overlay_into_target,
|
||||
)
|
||||
from planning_exercise_text_signals import resolve_planning_text_to_catalog_weights
|
||||
|
||||
|
||||
class PlanningCatalogContextItem(BaseModel):
|
||||
id: int = Field(..., ge=1)
|
||||
is_primary: bool = False
|
||||
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||
|
||||
|
||||
class ProgressionPlanningCatalogContext(BaseModel):
|
||||
focus_areas: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||
style_directions: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||
training_types: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||
target_groups: List[PlanningCatalogContextItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
def catalog_context_has_items(catalog: Optional[ProgressionPlanningCatalogContext]) -> bool:
|
||||
if catalog is None:
|
||||
return False
|
||||
return bool(
|
||||
catalog.focus_areas
|
||||
or catalog.style_directions
|
||||
or catalog.training_types
|
||||
or catalog.target_groups
|
||||
)
|
||||
|
||||
|
||||
def catalog_items_to_weight_map(
|
||||
items: Sequence[PlanningCatalogContextItem],
|
||||
*,
|
||||
primary_weight: float = 0.95,
|
||||
secondary_weight: float = 0.78,
|
||||
) -> Dict[int, float]:
|
||||
out: Dict[int, float] = {}
|
||||
for item in items or []:
|
||||
base = primary_weight if item.is_primary else secondary_weight
|
||||
w = base * float(item.weight)
|
||||
iid = int(item.id)
|
||||
out[iid] = max(out.get(iid, 0.0), w)
|
||||
return _normalize_weight_map(out) if out else out
|
||||
|
||||
|
||||
def merge_catalog_context_into_target(
|
||||
target: PlanningTargetProfile,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||
*,
|
||||
emphasis: str = "replace",
|
||||
) -> PlanningTargetProfile:
|
||||
"""Trainer-Katalog-Kontext ins Erwartungsprofil — beeinflusst Retrieval-Scoring."""
|
||||
if not catalog_context_has_items(catalog):
|
||||
return target
|
||||
|
||||
focus = catalog_items_to_weight_map(catalog.focus_areas)
|
||||
style = catalog_items_to_weight_map(catalog.style_directions, primary_weight=0.9, secondary_weight=0.72)
|
||||
tt = catalog_items_to_weight_map(catalog.training_types, primary_weight=0.9, secondary_weight=0.72)
|
||||
tg = catalog_items_to_weight_map(catalog.target_groups, primary_weight=0.88, secondary_weight=0.7)
|
||||
|
||||
merged = merge_query_overlay_into_target(
|
||||
target,
|
||||
focus=focus,
|
||||
style=style,
|
||||
tt=tt,
|
||||
tg=tg,
|
||||
skills={},
|
||||
emphasis=emphasis,
|
||||
scenario=SCENARIO_FREE_SEARCH,
|
||||
)
|
||||
sources = list(merged.sources or [])
|
||||
if "catalog_context" not in sources:
|
||||
sources.append("catalog_context")
|
||||
merged.sources = sources
|
||||
return merged
|
||||
|
||||
|
||||
def enrich_target_from_planning_text_blobs(
|
||||
cur,
|
||||
target: PlanningTargetProfile,
|
||||
*text_blobs: Optional[str],
|
||||
) -> PlanningTargetProfile:
|
||||
"""Additive Katalog-Signale aus Freitext (Anfrage, Start/Ziel, Notizen)."""
|
||||
combined = " ".join(str(t or "").strip() for t in text_blobs if (t or "").strip())
|
||||
if len(combined) < 4:
|
||||
return target
|
||||
focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(cur, combined)
|
||||
if not (focus or style or tt or tg or skills):
|
||||
return target
|
||||
merged = merge_query_overlay_into_target(
|
||||
target,
|
||||
focus=focus,
|
||||
style=style,
|
||||
tt=tt,
|
||||
tg=tg,
|
||||
skills=skills,
|
||||
emphasis="additive",
|
||||
scenario=SCENARIO_FREE_SEARCH,
|
||||
)
|
||||
sources = list(merged.sources or [])
|
||||
if "text_catalog_signals" not in sources:
|
||||
sources.append("text_catalog_signals")
|
||||
merged.sources = sources
|
||||
return merged
|
||||
|
||||
|
||||
def catalog_context_from_mapping(raw: Any) -> Optional[ProgressionPlanningCatalogContext]:
|
||||
if not raw or not isinstance(raw, Mapping):
|
||||
return None
|
||||
try:
|
||||
ctx = ProgressionPlanningCatalogContext.model_validate(dict(raw))
|
||||
except Exception:
|
||||
return None
|
||||
return ctx if catalog_context_has_items(ctx) else None
|
||||
|
||||
|
||||
def load_catalog_context_from_graph_row(
|
||||
planning_roadmap: Any,
|
||||
) -> Optional[ProgressionPlanningCatalogContext]:
|
||||
if not isinstance(planning_roadmap, dict):
|
||||
return None
|
||||
return catalog_context_from_mapping(planning_roadmap.get("planning_catalog_context"))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PlanningCatalogContextItem",
|
||||
"ProgressionPlanningCatalogContext",
|
||||
"catalog_context_from_mapping",
|
||||
"catalog_context_has_items",
|
||||
"catalog_items_to_weight_map",
|
||||
"enrich_target_from_planning_text_blobs",
|
||||
"load_catalog_context_from_graph_row",
|
||||
"merge_catalog_context_into_target",
|
||||
]
|
||||
69
backend/planning_exercise_expectation.py
Normal file
69
backend/planning_exercise_expectation.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""
|
||||
Preset „Nächste aus Kontext“: LLM leitet Erwartungsprofil aus Planungskontext ab.
|
||||
|
||||
Prompt: planning_exercise_expectation_profile (Migration 074)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
from planning_exercise_intent import (
|
||||
PlanningQueryIntentParsed,
|
||||
_compact_json,
|
||||
_load_compact_catalog,
|
||||
_load_skills_catalog_compact,
|
||||
parse_planning_query_intent_response,
|
||||
)
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
from openrouter_chat import (
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
normalize_openrouter_env,
|
||||
openrouter_chat_completion,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_exercise_expectation")
|
||||
|
||||
|
||||
def try_build_planning_expectation_from_context(
|
||||
cur,
|
||||
*,
|
||||
heuristic_intent: str,
|
||||
context_summary: Mapping[str, Any],
|
||||
target_profile_summary: Mapping[str, Any],
|
||||
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
||||
"""
|
||||
LLM-Erwartungsprofil für preset_next / leere Anfrage mit Planungsbezug.
|
||||
Returns (parsed overlay, applied).
|
||||
"""
|
||||
api_key, _ = normalize_openrouter_env()
|
||||
if not api_key:
|
||||
return None, False
|
||||
|
||||
variables = {
|
||||
"heuristic_intent": heuristic_intent or "suggest_next",
|
||||
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||
}
|
||||
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_expectation_profile", variables)
|
||||
model = effective_openrouter_model_for_prompt_row(prow)
|
||||
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||
parsed = parse_planning_query_intent_response(raw)
|
||||
if parsed.scenario not in ("preset_next", "continue_plan", "free_search"):
|
||||
parsed = parsed.model_copy(update={"scenario": "preset_next"})
|
||||
return parsed, True
|
||||
except AiPromptUnavailableError:
|
||||
return None, False
|
||||
except Exception as exc:
|
||||
_logger.warning("Planungs-Erwartungsprofil-LLM fehlgeschlagen: %s", exc)
|
||||
return None, False
|
||||
|
||||
|
||||
__all__ = ["try_build_planning_expectation_from_context"]
|
||||
395
backend/planning_exercise_form_context.py
Normal file
395
backend/planning_exercise_form_context.py
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"""
|
||||
Planungs-KI Phase D: strukturierter Planungskontext für POST /exercises/ai/suggest.
|
||||
|
||||
Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instructions) injiziert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
_MAX_JSON_CHARS = 6000
|
||||
_MAX_STRING = 800
|
||||
|
||||
|
||||
def compact_planning_context_json(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _trim_str(val: Any, *, limit: int = _MAX_STRING) -> Optional[str]:
|
||||
if val is None:
|
||||
return None
|
||||
s = str(val).strip()
|
||||
if not s:
|
||||
return None
|
||||
if len(s) > limit:
|
||||
return s[: limit - 1] + "…"
|
||||
return s
|
||||
|
||||
|
||||
def sanitize_planning_context_for_ai(ctx: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||
"""Reduziert Client-Payload auf prompt-taugliche, begrenzte Felder."""
|
||||
if not ctx:
|
||||
return {}
|
||||
out: Dict[str, Any] = {}
|
||||
for key, val in dict(ctx).items():
|
||||
if val is None:
|
||||
continue
|
||||
k = str(key).strip()
|
||||
if not k:
|
||||
continue
|
||||
if isinstance(val, str):
|
||||
t = _trim_str(val)
|
||||
if t:
|
||||
out[k] = t
|
||||
elif isinstance(val, (int, float, bool)):
|
||||
out[k] = val
|
||||
elif isinstance(val, list):
|
||||
items = []
|
||||
for item in val[:12]:
|
||||
if isinstance(item, str):
|
||||
t = _trim_str(item, limit=200)
|
||||
if t:
|
||||
items.append(t)
|
||||
elif isinstance(item, (int, float, bool)):
|
||||
items.append(item)
|
||||
elif isinstance(item, dict):
|
||||
sub = sanitize_planning_context_for_ai(item)
|
||||
if sub:
|
||||
items.append(sub)
|
||||
if items:
|
||||
out[k] = items
|
||||
elif isinstance(val, dict):
|
||||
sub = sanitize_planning_context_for_ai(val)
|
||||
if sub:
|
||||
out[k] = sub
|
||||
raw = compact_planning_context_json(out)
|
||||
if len(raw) > _MAX_JSON_CHARS:
|
||||
out["truncated"] = True
|
||||
out.pop("path_steps_preview", None)
|
||||
raw = compact_planning_context_json(out)
|
||||
if len(raw) > _MAX_JSON_CHARS:
|
||||
return {"source": out.get("source"), "truncated": True, "goal_query": out.get("goal_query")}
|
||||
return out
|
||||
|
||||
|
||||
def planning_context_prompt_variables(
|
||||
planning_context: Optional[Mapping[str, Any]],
|
||||
) -> Dict[str, str]:
|
||||
cleaned = sanitize_planning_context_for_ai(planning_context)
|
||||
if not cleaned:
|
||||
return {"planning_context_json": "-", "has_planning_context": ""}
|
||||
return {
|
||||
"planning_context_json": compact_planning_context_json(cleaned),
|
||||
"has_planning_context": "true",
|
||||
}
|
||||
|
||||
|
||||
def _major_index_from_step(step: Mapping[str, Any]) -> Optional[int]:
|
||||
for key in ("roadmap_major_step_index", "major_step_index"):
|
||||
raw = step.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def prior_path_steps_before_major(
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
major_idx: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Pfadschritte mit kleinerem roadmap_major_step_index, sortiert."""
|
||||
prior: List[Dict[str, Any]] = []
|
||||
for step in steps:
|
||||
mi = _major_index_from_step(step)
|
||||
if mi is not None and mi < major_idx:
|
||||
prior.append(dict(step))
|
||||
prior.sort(key=lambda s: _major_index_from_step(s) or 0)
|
||||
return prior
|
||||
|
||||
|
||||
def _step_display_fields(step: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
title = _trim_str(
|
||||
step.get("title") or step.get("exercise_title"),
|
||||
limit=200,
|
||||
)
|
||||
learning_goal = _trim_str(
|
||||
step.get("roadmap_learning_goal") or step.get("learning_goal"),
|
||||
limit=500,
|
||||
)
|
||||
summary = _trim_str(step.get("summary"), limit=400)
|
||||
start_state = _trim_str(step.get("roadmap_start_state") or step.get("start_state"))
|
||||
target_state = _trim_str(step.get("roadmap_target_state") or step.get("target_state"))
|
||||
phase = _trim_str(step.get("roadmap_phase") or step.get("phase"))
|
||||
criteria_raw = step.get("stage_success_criteria") or step.get("success_criteria") or []
|
||||
criteria = [
|
||||
t
|
||||
for x in criteria_raw
|
||||
if (t := _trim_str(x, limit=200))
|
||||
][:4]
|
||||
out: Dict[str, Any] = {
|
||||
"title": title,
|
||||
"learning_goal": learning_goal,
|
||||
"summary": summary,
|
||||
"start_state": start_state,
|
||||
"target_state": target_state,
|
||||
"phase": phase,
|
||||
"success_criteria": criteria or None,
|
||||
"major_step_index": _major_index_from_step(step),
|
||||
}
|
||||
return {k: v for k, v in out.items() if v is not None and v != "" and v != []}
|
||||
|
||||
|
||||
def build_progression_entry_state(
|
||||
*,
|
||||
major_step_index: Optional[int] = None,
|
||||
prior_steps: Sequence[Mapping[str, Any]] = (),
|
||||
start_situation: Optional[str] = None,
|
||||
current_stage_start: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Eingangszustand für eine Roadmap-Stufe: erreichte Voraussetzungen aus Vorstufen.
|
||||
"""
|
||||
prior_compact = [_step_display_fields(s) for s in prior_steps]
|
||||
prior_compact = [
|
||||
p
|
||||
for p in prior_compact
|
||||
if any(p.get(k) for k in ("title", "learning_goal", "summary", "success_criteria"))
|
||||
]
|
||||
|
||||
achievements: List[str] = []
|
||||
detail_lines: List[str] = []
|
||||
for p in prior_compact:
|
||||
if p.get("success_criteria"):
|
||||
achievements.extend(p["success_criteria"])
|
||||
elif p.get("learning_goal"):
|
||||
achievements.append(p["learning_goal"])
|
||||
|
||||
label_parts: List[str] = []
|
||||
if p.get("major_step_index") is not None:
|
||||
label_parts.append(f"Stufe {int(p['major_step_index']) + 1}")
|
||||
if p.get("phase"):
|
||||
label_parts.append(f"({p['phase']})")
|
||||
if p.get("title"):
|
||||
label_parts.append(f"„{p['title']}\"")
|
||||
prefix = " ".join(label_parts) if label_parts else "Vorstufe"
|
||||
achieved = ""
|
||||
if p.get("target_state"):
|
||||
achieved = p["target_state"]
|
||||
elif p.get("success_criteria"):
|
||||
achieved = "; ".join(p["success_criteria"])
|
||||
elif p.get("learning_goal"):
|
||||
achieved = p["learning_goal"]
|
||||
elif p.get("summary"):
|
||||
achieved = p["summary"]
|
||||
if achieved:
|
||||
detail_lines.append(f"{prefix}: erreicht — {achieved}")
|
||||
|
||||
immediate_entry: Optional[str] = _trim_str(current_stage_start)
|
||||
if not immediate_entry and prior_compact:
|
||||
immediate = prior_compact[-1]
|
||||
if immediate.get("target_state"):
|
||||
immediate_entry = immediate["target_state"]
|
||||
elif immediate.get("success_criteria"):
|
||||
immediate_entry = "; ".join(immediate["success_criteria"])
|
||||
elif immediate.get("learning_goal"):
|
||||
immediate_entry = immediate["learning_goal"]
|
||||
elif immediate.get("summary"):
|
||||
immediate_entry = immediate["summary"]
|
||||
elif not immediate_entry and start_situation:
|
||||
immediate_entry = start_situation
|
||||
|
||||
entry_state = immediate_entry or start_situation
|
||||
if prior_compact and start_situation and not immediate_entry:
|
||||
detail_lines.insert(0, f"Ausgangsbasis Pfad: {start_situation}")
|
||||
|
||||
out: Dict[str, Any] = {}
|
||||
if entry_state:
|
||||
out["entry_state"] = _trim_str(entry_state, limit=1200)
|
||||
if detail_lines:
|
||||
out["entry_state_detail"] = _trim_str("\n".join(detail_lines), limit=2000)
|
||||
if prior_compact:
|
||||
out["prior_steps"] = prior_compact[:6]
|
||||
if achievements:
|
||||
out["prior_achievements"] = list(dict.fromkeys(achievements))[:8]
|
||||
return out
|
||||
|
||||
|
||||
def enrich_gap_snapshot_with_entry_state(
|
||||
snapshot: Mapping[str, Any],
|
||||
*,
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
major_step_index: Optional[int],
|
||||
) -> Dict[str, Any]:
|
||||
snap = dict(snapshot)
|
||||
if major_step_index is None:
|
||||
return snap
|
||||
try:
|
||||
mi = int(major_step_index)
|
||||
except (TypeError, ValueError):
|
||||
return snap
|
||||
prior = prior_path_steps_before_major(steps, mi)
|
||||
entry = build_progression_entry_state(
|
||||
major_step_index=mi,
|
||||
prior_steps=prior,
|
||||
start_situation=snap.get("start_situation"),
|
||||
current_stage_start=snap.get("stage_start_state"),
|
||||
)
|
||||
snap.update(entry)
|
||||
return snap
|
||||
|
||||
|
||||
def build_progression_gap_snapshot(
|
||||
*,
|
||||
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||
stage_spec: Optional[Mapping[str, Any]] = None,
|
||||
semantic_brief: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Kompakter Roadmap-Kontext für Lücken-Übungen (Start, Ziel, Stufe, Fähigkeiten-Hinweise)."""
|
||||
ga = dict(goal_analysis or {})
|
||||
rs = dict(resolved_structured or {})
|
||||
spec = dict(stage_spec or {})
|
||||
brief = dict(semantic_brief or {})
|
||||
|
||||
start = _trim_str(rs.get("start_situation") or ga.get("start_assumption"))
|
||||
target = _trim_str(rs.get("target_state") or ga.get("target_state"))
|
||||
notes = _trim_str(rs.get("roadmap_notes"))
|
||||
topic = _trim_str(ga.get("primary_topic") or brief.get("primary_topic"))
|
||||
|
||||
skill_hints: List[str] = []
|
||||
for item in (brief.get("must_phrases") or [])[:4]:
|
||||
t = _trim_str(item, limit=120)
|
||||
if t:
|
||||
skill_hints.append(t)
|
||||
arc = brief.get("development_arc")
|
||||
if isinstance(arc, list) and arc:
|
||||
skill_hints.append(f"Entwicklungsbogen: {' → '.join(str(x) for x in arc[:5])}")
|
||||
|
||||
success_path = [
|
||||
_trim_str(x, limit=200)
|
||||
for x in (ga.get("success_criteria") or [])
|
||||
if _trim_str(x, limit=200)
|
||||
][:4]
|
||||
stage_success = [
|
||||
_trim_str(x, limit=200)
|
||||
for x in (spec.get("success_criteria") or [])
|
||||
if _trim_str(x, limit=200)
|
||||
][:4]
|
||||
load_profile = [
|
||||
_trim_str(x, limit=80)
|
||||
for x in (spec.get("load_profile") or [])
|
||||
if _trim_str(x, limit=80)
|
||||
][:6]
|
||||
anti_patterns = [
|
||||
_trim_str(x, limit=200)
|
||||
for x in (spec.get("anti_patterns") or [])
|
||||
if _trim_str(x, limit=200)
|
||||
][:3]
|
||||
|
||||
snap: Dict[str, Any] = {
|
||||
"primary_topic": topic,
|
||||
"start_situation": start,
|
||||
"target_state": target,
|
||||
"roadmap_notes": notes,
|
||||
"stage_learning_goal": _trim_str(
|
||||
spec.get("learning_goal"), limit=1200
|
||||
),
|
||||
"stage_start_state": _trim_str(spec.get("start_state")),
|
||||
"stage_target_state": _trim_str(spec.get("target_state")),
|
||||
"stage_phase": _trim_str(spec.get("phase")),
|
||||
"stage_exercise_type": _trim_str(spec.get("exercise_type")),
|
||||
"stage_load_profile": load_profile or None,
|
||||
"stage_success_criteria": stage_success or None,
|
||||
"stage_anti_patterns": anti_patterns or None,
|
||||
"path_success_criteria": success_path or None,
|
||||
"skill_hints": skill_hints or None,
|
||||
}
|
||||
return {k: v for k, v in snap.items() if v is not None and v != "" and v != []}
|
||||
|
||||
|
||||
def build_progression_path_gap_planning_context(
|
||||
*,
|
||||
goal_query: str,
|
||||
primary_topic: Optional[str] = None,
|
||||
progression_graph_id: Optional[int] = None,
|
||||
offer: Optional[Mapping[str, Any]] = None,
|
||||
neighbor_before: Optional[Mapping[str, Any]] = None,
|
||||
neighbor_after: Optional[Mapping[str, Any]] = None,
|
||||
prior_path_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
path_step_count: int = 0,
|
||||
major_step_count: Optional[int] = None,
|
||||
roadmap_phase: Optional[str] = None,
|
||||
roadmap_learning_goal: Optional[str] = None,
|
||||
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||
stage_spec: Optional[Mapping[str, Any]] = None,
|
||||
semantic_brief: Optional[Mapping[str, Any]] = None,
|
||||
stage_learning_goal_override: Optional[str] = None,
|
||||
gap_trainer_supplements: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
|
||||
offer = offer or {}
|
||||
gap = offer.get("gap") if isinstance(offer.get("gap"), dict) else {}
|
||||
major_idx = offer.get("roadmap_major_step_index")
|
||||
if major_idx is None and isinstance(gap, dict):
|
||||
major_idx = gap.get("roadmap_major_step_index")
|
||||
|
||||
ctx: Dict[str, Any] = {
|
||||
"source": "progression_path_gap_fill",
|
||||
"goal_query": _trim_str(goal_query, limit=2000),
|
||||
"primary_topic": _trim_str(primary_topic),
|
||||
"progression_graph_id": progression_graph_id,
|
||||
"gap_source": _trim_str(offer.get("source")),
|
||||
"gap_phase": _trim_str(offer.get("phase") or gap.get("expected_phase")),
|
||||
"roadmap_major_step_index": major_idx,
|
||||
"roadmap_phase": _trim_str(roadmap_phase or offer.get("phase")),
|
||||
"roadmap_learning_goal": _trim_str(
|
||||
roadmap_learning_goal or offer.get("title_hint") or gap.get("learning_goal"),
|
||||
limit=1200,
|
||||
),
|
||||
"neighbor_before_title": _trim_str(
|
||||
(neighbor_before or {}).get("title") or offer.get("from_title")
|
||||
),
|
||||
"neighbor_after_title": _trim_str(
|
||||
(neighbor_after or {}).get("title") or offer.get("to_title")
|
||||
),
|
||||
"path_step_count": path_step_count,
|
||||
"major_step_count": major_step_count,
|
||||
}
|
||||
snap = build_progression_gap_snapshot(
|
||||
goal_analysis=goal_analysis,
|
||||
resolved_structured=resolved_structured,
|
||||
stage_spec=stage_spec,
|
||||
semantic_brief=semantic_brief,
|
||||
)
|
||||
ctx.update(snap)
|
||||
if major_idx is not None and prior_path_steps:
|
||||
ctx.update(
|
||||
build_progression_entry_state(
|
||||
major_step_index=major_idx,
|
||||
prior_steps=list(prior_path_steps),
|
||||
start_situation=ctx.get("start_situation"),
|
||||
)
|
||||
)
|
||||
if stage_learning_goal_override and stage_learning_goal_override.strip():
|
||||
ctx["stage_learning_goal"] = _trim_str(stage_learning_goal_override, limit=1200)
|
||||
ctx["roadmap_learning_goal"] = ctx["stage_learning_goal"]
|
||||
if gap_trainer_supplements and gap_trainer_supplements.strip():
|
||||
ctx["gap_trainer_supplements"] = _trim_str(gap_trainer_supplements, limit=2000)
|
||||
return sanitize_planning_context_for_ai(ctx)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_progression_entry_state",
|
||||
"build_progression_gap_snapshot",
|
||||
"build_progression_path_gap_planning_context",
|
||||
"enrich_gap_snapshot_with_entry_state",
|
||||
"prior_path_steps_before_major",
|
||||
"compact_planning_context_json",
|
||||
"planning_context_prompt_variables",
|
||||
"sanitize_planning_context_for_ai",
|
||||
]
|
||||
272
backend/planning_exercise_intent.py
Normal file
272
backend/planning_exercise_intent.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"""
|
||||
P1: LLM-Intent aus Planungs-Suchfrage → strukturiertes Query-Overlay für PlanningTargetProfile.
|
||||
|
||||
Prompt: planning_exercise_search_intent (Migration 073)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
from openrouter_chat import (
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
normalize_openrouter_env,
|
||||
openrouter_chat_completion,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_exercise_intent")
|
||||
|
||||
VALID_PARSED_INTENTS = {
|
||||
"suggest_next",
|
||||
"progression_next",
|
||||
"deepen_exercise",
|
||||
"continue_plan_goal",
|
||||
"free_search",
|
||||
}
|
||||
|
||||
VALID_SCENARIOS = {
|
||||
"preset_next",
|
||||
"progression",
|
||||
"deepen",
|
||||
"continue_plan",
|
||||
"additive_constraint",
|
||||
"free_search",
|
||||
}
|
||||
|
||||
VALID_EMPHASIS = {"additive", "replace", "neutral"}
|
||||
|
||||
|
||||
class SkillHint(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=120)
|
||||
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||
|
||||
|
||||
class PlanningQueryIntentParsed(BaseModel):
|
||||
intent: str = "free_search"
|
||||
scenario: str = "free_search"
|
||||
skill_hints: List[SkillHint] = Field(default_factory=list)
|
||||
focus_hints: List[str] = Field(default_factory=list)
|
||||
style_hints: List[str] = Field(default_factory=list)
|
||||
training_type_hints: List[str] = Field(default_factory=list)
|
||||
target_group_hints: List[str] = Field(default_factory=list)
|
||||
requires_partner: Optional[bool] = None
|
||||
emphasis: str = "additive"
|
||||
rationale: Optional[str] = Field(default=None, max_length=400)
|
||||
|
||||
@field_validator("intent")
|
||||
@classmethod
|
||||
def _intent(cls, v: str) -> str:
|
||||
s = (v or "").strip().lower()
|
||||
return s if s in VALID_PARSED_INTENTS else "free_search"
|
||||
|
||||
@field_validator("scenario")
|
||||
@classmethod
|
||||
def _scenario(cls, v: str) -> str:
|
||||
s = (v or "").strip().lower()
|
||||
return s if s in VALID_SCENARIOS else "free_search"
|
||||
|
||||
@field_validator("emphasis")
|
||||
@classmethod
|
||||
def _emphasis(cls, v: str) -> str:
|
||||
s = (v or "").strip().lower()
|
||||
return s if s in VALID_EMPHASIS else "additive"
|
||||
|
||||
@field_validator("focus_hints", "style_hints", "training_type_hints", "target_group_hints", mode="before")
|
||||
@classmethod
|
||||
def _str_list(cls, v: Any) -> List[str]:
|
||||
if not v:
|
||||
return []
|
||||
if isinstance(v, str):
|
||||
return [v.strip()] if v.strip() else []
|
||||
out: List[str] = []
|
||||
for item in v:
|
||||
s = str(item or "").strip()
|
||||
if s and s not in out:
|
||||
out.append(s[:120])
|
||||
return out[:8]
|
||||
|
||||
|
||||
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||
s = (text or "").strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||
if s.endswith("```"):
|
||||
s = s[:-3].strip()
|
||||
start = s.find("{")
|
||||
end = s.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||
obj = json.loads(s[start : end + 1])
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||
return obj
|
||||
|
||||
|
||||
def parse_planning_query_intent_response(text: str) -> PlanningQueryIntentParsed:
|
||||
obj = _extract_json_object(text)
|
||||
return PlanningQueryIntentParsed.model_validate(obj)
|
||||
|
||||
|
||||
def _compact_json(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _load_compact_catalog(cur, table: str, id_col: str, name_col: str = "name", limit: int = 80) -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {id_col} AS id, {name_col} AS name
|
||||
FROM {table}
|
||||
ORDER BY {name_col} ASC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return [{"id": int(r["id"]), "name": str(r["name"] or "")[:80]} for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _load_skills_catalog_compact(cur, limit: int = 120) -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, category
|
||||
FROM skills
|
||||
WHERE status IS NULL OR status = 'active'
|
||||
ORDER BY name ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": int(r["id"]),
|
||||
"name": str(r["name"] or "")[:80],
|
||||
"category": str(r.get("category") or "")[:40],
|
||||
}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
|
||||
def _resolve_name_hint(cur, table: str, hint: str, *, extra_where: str = "") -> Optional[int]:
|
||||
h = (hint or "").strip()
|
||||
if len(h) < 2:
|
||||
return None
|
||||
q = h.lower()
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, name
|
||||
FROM {table}
|
||||
WHERE LOWER(name) LIKE %s {extra_where}
|
||||
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||
LENGTH(name) ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(f"%{q}%", q, f"{q}%"),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
|
||||
def resolve_query_intent_catalog_ids(
|
||||
cur,
|
||||
parsed: PlanningQueryIntentParsed,
|
||||
) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Mappt Text-Hints auf Katalog-IDs. Returns (focus, style, tt, tg, skills, resolved_skills_meta).
|
||||
"""
|
||||
focus: Dict[int, float] = {}
|
||||
style: Dict[int, float] = {}
|
||||
tt: Dict[int, float] = {}
|
||||
tg: Dict[int, float] = {}
|
||||
skills: Dict[int, float] = {}
|
||||
resolved_skills: List[Dict[str, Any]] = []
|
||||
|
||||
for hint in parsed.focus_hints:
|
||||
fid = _resolve_name_hint(cur, "focus_areas", hint)
|
||||
if fid:
|
||||
focus[fid] = max(focus.get(fid, 0.0), 0.9)
|
||||
|
||||
for hint in parsed.style_hints:
|
||||
sid = _resolve_name_hint(cur, "style_directions", hint)
|
||||
if sid:
|
||||
style[sid] = max(style.get(sid, 0.0), 0.85)
|
||||
|
||||
for hint in parsed.training_type_hints:
|
||||
tid = _resolve_name_hint(cur, "training_types", hint)
|
||||
if tid:
|
||||
tt[tid] = max(tt.get(tid, 0.0), 0.85)
|
||||
|
||||
for hint in parsed.target_group_hints:
|
||||
gid = _resolve_name_hint(cur, "target_groups", hint)
|
||||
if gid:
|
||||
tg[gid] = max(tg.get(gid, 0.0), 0.85)
|
||||
|
||||
for sh in parsed.skill_hints[:8]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name FROM skills
|
||||
WHERE (status IS NULL OR status = 'active')
|
||||
AND LOWER(name) LIKE %s
|
||||
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||
LENGTH(name) ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(f"%{sh.name.lower()}%", sh.name.lower(), f"{sh.name.lower()}%"),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
sid = int(row["id"])
|
||||
skills[sid] = max(skills.get(sid, 0.0), float(sh.weight))
|
||||
resolved_skills.append({"skill_id": sid, "name": str(row["name"] or sh.name), "weight": skills[sid]})
|
||||
|
||||
return focus, style, tt, tg, skills, resolved_skills
|
||||
|
||||
|
||||
def try_parse_planning_query_intent(
|
||||
cur,
|
||||
*,
|
||||
query: str,
|
||||
heuristic_intent: str,
|
||||
scenario_hint: str,
|
||||
context_summary: Mapping[str, Any],
|
||||
target_profile_summary: Mapping[str, Any],
|
||||
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
||||
api_key, _ = normalize_openrouter_env()
|
||||
if not api_key or not (query or "").strip():
|
||||
return None, False
|
||||
|
||||
variables = {
|
||||
"search_query": (query or "").strip(),
|
||||
"heuristic_intent": heuristic_intent or "",
|
||||
"scenario_hint": scenario_hint or "",
|
||||
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||
}
|
||||
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_intent", variables)
|
||||
model = effective_openrouter_model_for_prompt_row(prow)
|
||||
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||
parsed = parse_planning_query_intent_response(raw)
|
||||
return parsed, True
|
||||
except AiPromptUnavailableError:
|
||||
return None, False
|
||||
except Exception as exc:
|
||||
_logger.warning("Planungs-Intent-LLM fehlgeschlagen: %s", exc)
|
||||
return None, False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PlanningQueryIntentParsed",
|
||||
"parse_planning_query_intent_response",
|
||||
"resolve_query_intent_catalog_ids",
|
||||
"try_parse_planning_query_intent",
|
||||
]
|
||||
223
backend/planning_exercise_llm_rank.py
Normal file
223
backend/planning_exercise_llm_rank.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"""
|
||||
Phase 2 Planungs-Übungssuche: LLM-Rerank über Hybrid-Kandidaten.
|
||||
|
||||
Prompt-Slug: planning_exercise_search_rank (Migration 072)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
from exercise_ai import strip_html_to_plain
|
||||
from openrouter_chat import (
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
normalize_openrouter_env,
|
||||
openrouter_chat_completion,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_exercise_llm_rank")
|
||||
|
||||
_LLM_RERANK_POOL = 32
|
||||
_MAX_GOAL_PLAIN = 480
|
||||
_MAX_SUMMARY_PLAIN = 320
|
||||
_MAX_REASON_LEN = 160
|
||||
|
||||
|
||||
def _compact_json(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||
s = (text or "").strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||
if s.endswith("```"):
|
||||
s = s[:-3].strip()
|
||||
start = s.find("{")
|
||||
end = s.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||
obj = json.loads(s[start : end + 1])
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||
return obj
|
||||
|
||||
|
||||
def parse_planning_exercise_rank_response(
|
||||
text: str,
|
||||
allowed_ids: Set[int],
|
||||
) -> Tuple[List[int], Dict[int, str]]:
|
||||
"""
|
||||
Validiert LLM-Ranking: nur erlaubte exercise_id, dedupliziert, Reihenfolge beibehalten.
|
||||
"""
|
||||
obj = _extract_json_object(text)
|
||||
ranked_raw = obj.get("ranked_ids") or obj.get("ranked") or obj.get("ids")
|
||||
if not isinstance(ranked_raw, list):
|
||||
raise ValueError("ranked_ids fehlt oder ist keine Liste")
|
||||
|
||||
ranked: List[int] = []
|
||||
seen: Set[int] = set()
|
||||
for raw in ranked_raw:
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid not in allowed_ids or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
ranked.append(eid)
|
||||
|
||||
reasons_out: Dict[int, str] = {}
|
||||
reasons_raw = obj.get("reasons") or obj.get("reasons_by_id") or {}
|
||||
if isinstance(reasons_raw, dict):
|
||||
for k, v in reasons_raw.items():
|
||||
try:
|
||||
eid = int(k)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid not in allowed_ids:
|
||||
continue
|
||||
txt = str(v or "").strip()
|
||||
if txt:
|
||||
reasons_out[eid] = txt[:_MAX_REASON_LEN]
|
||||
|
||||
return ranked, reasons_out
|
||||
|
||||
|
||||
def _build_candidate_payload(
|
||||
hit: Mapping[str, Any],
|
||||
*,
|
||||
goal_plain: str,
|
||||
skill_names: Sequence[str],
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": int(hit["id"]),
|
||||
"title": str(hit.get("title") or "").strip()[:200],
|
||||
"summary": strip_html_to_plain(hit.get("summary"), max_len=_MAX_SUMMARY_PLAIN),
|
||||
"goal": goal_plain,
|
||||
"skills": list(skill_names)[:8],
|
||||
"retrieval_score": float(hit.get("score") or 0.0),
|
||||
}
|
||||
|
||||
|
||||
def _load_exercise_goals(cur, exercise_ids: Sequence[int]) -> Dict[int, str]:
|
||||
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||
if not ids:
|
||||
return {}
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(
|
||||
f"SELECT id, goal FROM exercises WHERE id IN ({ph})",
|
||||
ids,
|
||||
)
|
||||
return {int(r["id"]): str(r.get("goal") or "") for r in cur.fetchall()}
|
||||
|
||||
|
||||
def _load_skill_names(cur, skill_ids: Sequence[int]) -> Dict[int, str]:
|
||||
ids = sorted({int(x) for x in skill_ids if int(x) > 0})
|
||||
if not ids:
|
||||
return {}
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", ids)
|
||||
return {int(r["id"]): str(r.get("name") or "") for r in cur.fetchall()}
|
||||
|
||||
|
||||
def try_llm_rerank_planning_hits(
|
||||
cur,
|
||||
*,
|
||||
hits: List[Dict[str, Any]],
|
||||
skills_by_ex: Mapping[int, Set[int]],
|
||||
query: str,
|
||||
intent: str,
|
||||
context_summary: Mapping[str, Any],
|
||||
target_profile_summary: Mapping[str, Any],
|
||||
limit: int,
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""
|
||||
Optionaler LLM-Rerank der Top-Kandidaten. Bei Fehler: Original-Reihenfolge, llm_applied=False.
|
||||
"""
|
||||
if not hits:
|
||||
return hits, False
|
||||
|
||||
api_key, _ = normalize_openrouter_env()
|
||||
if not api_key:
|
||||
return hits, False
|
||||
|
||||
pool = hits[:_LLM_RERANK_POOL]
|
||||
allowed_ids = {int(h["id"]) for h in pool}
|
||||
goals = _load_exercise_goals(cur, list(allowed_ids))
|
||||
|
||||
all_skill_ids: Set[int] = set()
|
||||
for eid in allowed_ids:
|
||||
all_skill_ids.update(skills_by_ex.get(eid) or set())
|
||||
skill_name_map = _load_skill_names(cur, list(all_skill_ids))
|
||||
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
for hit in pool:
|
||||
eid = int(hit["id"])
|
||||
sk_ids = sorted(skills_by_ex.get(eid) or set())
|
||||
sk_names = [skill_name_map.get(sid, f"#{sid}") for sid in sk_ids[:8]]
|
||||
goal_plain = strip_html_to_plain(goals.get(eid), max_len=_MAX_GOAL_PLAIN)
|
||||
candidates.append(
|
||||
_build_candidate_payload(hit, goal_plain=goal_plain, skill_names=sk_names)
|
||||
)
|
||||
|
||||
variables = {
|
||||
"search_query": query or "",
|
||||
"intent": intent or "",
|
||||
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||
"candidates_json": _compact_json(candidates),
|
||||
"result_limit": str(max(1, min(int(limit), 50))),
|
||||
}
|
||||
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_rank", variables)
|
||||
model = effective_openrouter_model_for_prompt_row(prow)
|
||||
raw = openrouter_chat_completion(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
user_content=rendered.text,
|
||||
)
|
||||
ranked_ids, llm_reasons = parse_planning_exercise_rank_response(raw, allowed_ids)
|
||||
except AiPromptUnavailableError:
|
||||
return hits, False
|
||||
except Exception as exc:
|
||||
_logger.warning("Planungs-LLM-Rerank fehlgeschlagen: %s", exc)
|
||||
return hits, False
|
||||
|
||||
if not ranked_ids:
|
||||
return hits, False
|
||||
|
||||
hit_by_id = {int(h["id"]): h for h in hits}
|
||||
reranked: List[Dict[str, Any]] = []
|
||||
used: Set[int] = set()
|
||||
for eid in ranked_ids:
|
||||
hit = hit_by_id.get(eid)
|
||||
if not hit:
|
||||
continue
|
||||
used.add(eid)
|
||||
new_hit = dict(hit)
|
||||
reasons = list(hit.get("reasons") or [])
|
||||
llm_reason = llm_reasons.get(eid)
|
||||
if llm_reason and llm_reason not in reasons:
|
||||
reasons.insert(0, llm_reason)
|
||||
new_hit["reasons"] = reasons
|
||||
new_hit["llm_rank"] = len(reranked) + 1
|
||||
reranked.append(new_hit)
|
||||
|
||||
for hit in hits:
|
||||
eid = int(hit["id"])
|
||||
if eid in used:
|
||||
continue
|
||||
reranked.append(dict(hit))
|
||||
|
||||
return reranked[: max(int(limit), len(reranked))], True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"parse_planning_exercise_rank_response",
|
||||
"try_llm_rerank_planning_hits",
|
||||
]
|
||||
788
backend/planning_exercise_path_ai_fill.py
Normal file
788
backend/planning_exercise_path_ai_fill.py
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
"""
|
||||
Planungs-KI Phase E2/E3: KI-Neuanlage für Pfad-Lücken + strukturierte Angebote für die UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||
from exercise_ai import strip_html_to_plain
|
||||
|
||||
from planning_exercise_path_qa import find_step_pair_index
|
||||
from planning_exercise_form_context import (
|
||||
build_progression_entry_state,
|
||||
build_progression_gap_snapshot,
|
||||
enrich_gap_snapshot_with_entry_state,
|
||||
prior_path_steps_before_major,
|
||||
)
|
||||
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
||||
|
||||
|
||||
def _resolve_neighbor_steps_by_major_index(
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
major_idx: int,
|
||||
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
|
||||
"""Nachbarn im Pfad anhand roadmap_major_step_index (nicht Array-Position)."""
|
||||
step_before: Optional[Mapping[str, Any]] = None
|
||||
step_after: Optional[Mapping[str, Any]] = None
|
||||
for step in steps:
|
||||
raw = step.get("roadmap_major_step_index")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
mi = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if mi < major_idx:
|
||||
step_before = step
|
||||
elif mi > major_idx and step_after is None:
|
||||
step_after = step
|
||||
return step_before, step_after
|
||||
|
||||
|
||||
def _build_stage_ai_context(
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
spec: Mapping[str, Any],
|
||||
step_before: Optional[Mapping[str, Any]] = None,
|
||||
step_after: Optional[Mapping[str, Any]] = None,
|
||||
prior_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
start_situation: Optional[str] = None,
|
||||
) -> ExerciseFormAiPromptContext:
|
||||
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
|
||||
gap = dict(spec.get("gap") or {})
|
||||
phase = spec.get("phase") or gap.get("expected_phase") or "vertiefung"
|
||||
topic = (brief.primary_topic or "Technik").strip()
|
||||
learning_goal = (
|
||||
gap.get("learning_goal")
|
||||
or spec.get("title_hint")
|
||||
or spec.get("sketch")
|
||||
or ""
|
||||
).strip()
|
||||
title = (spec.get("title_hint") or f"{topic} — {phase}").strip()[:280]
|
||||
major_idx = spec.get("roadmap_major_step_index")
|
||||
entry: Dict[str, Any] = {}
|
||||
if prior_steps is not None and major_idx is not None:
|
||||
entry = build_progression_entry_state(
|
||||
major_step_index=major_idx,
|
||||
prior_steps=prior_steps,
|
||||
start_situation=start_situation,
|
||||
)
|
||||
|
||||
goal_parts = [
|
||||
f"Planungsziel: {goal_query}",
|
||||
f"Roadmap-Stufe ({phase}): {learning_goal}",
|
||||
"Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.",
|
||||
]
|
||||
if entry.get("entry_state"):
|
||||
goal_parts.append(
|
||||
f"Eingangszustand (erreichte Voraussetzungen): {entry['entry_state']}"
|
||||
)
|
||||
if entry.get("entry_state_detail") and entry.get("entry_state_detail") != entry.get("entry_state"):
|
||||
goal_parts.append(f"Bisheriger Pfad:\n{entry['entry_state_detail']}")
|
||||
if step_before:
|
||||
goal_parts.append(
|
||||
f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}“"
|
||||
)
|
||||
if step_after:
|
||||
goal_parts.append(
|
||||
f"Nächste Stufe im Pfad: „{(step_after.get('title') or '').strip()}“"
|
||||
)
|
||||
sketch = (spec.get("sketch") or "").strip()
|
||||
if sketch and sketch != learning_goal:
|
||||
goal_parts.extend(["", f"Kontext: {sketch}"])
|
||||
goal = "\n".join(goal_parts)
|
||||
|
||||
focus_hint = topic if brief.topic_type == "technique" else None
|
||||
if brief.must_phrases:
|
||||
focus_hint = ", ".join(brief.must_phrases[:2])
|
||||
|
||||
return ExerciseFormAiPromptContext(
|
||||
title=title[:280],
|
||||
goal=goal[:8000],
|
||||
execution=None,
|
||||
focus_hint=focus_hint,
|
||||
)
|
||||
|
||||
|
||||
def try_suggest_ai_stage_step(
|
||||
cur,
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
spec: Mapping[str, Any],
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""KI-Vorschlag für leere Roadmap-Stufe."""
|
||||
major_idx = spec.get("roadmap_major_step_index")
|
||||
if major_idx is None:
|
||||
return None
|
||||
try:
|
||||
mi = int(major_idx)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi)
|
||||
prior_steps = prior_path_steps_before_major(steps, mi)
|
||||
gap = dict(spec.get("gap") or {})
|
||||
if not gap.get("expected_phase"):
|
||||
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
||||
gap["roadmap_major_step_index"] = mi
|
||||
if not gap.get("learning_goal"):
|
||||
gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
|
||||
|
||||
ctx = _build_stage_ai_context(
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
spec=spec,
|
||||
step_before=step_before,
|
||||
step_after=step_after,
|
||||
prior_steps=prior_steps,
|
||||
)
|
||||
try:
|
||||
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
|
||||
except Exception:
|
||||
_logger.exception("roadmap_unfilled AI suggest failed")
|
||||
return None
|
||||
if not ai_payload:
|
||||
return None
|
||||
|
||||
summary_text = ""
|
||||
summary_obj = ai_payload.get("summary")
|
||||
if isinstance(summary_obj, dict):
|
||||
summary_text = str(summary_obj.get("text") or "").strip()
|
||||
elif isinstance(summary_obj, str):
|
||||
summary_text = summary_obj.strip()
|
||||
|
||||
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
|
||||
title = (ctx.title or spec.get("title_hint") or "KI-Vorschlag").strip()
|
||||
return {
|
||||
"exercise_id": None,
|
||||
"proposal_key": proposal_key,
|
||||
"variant_id": None,
|
||||
"title": title,
|
||||
"summary": summary_text or None,
|
||||
"score": None,
|
||||
"semantic_score": None,
|
||||
"reasons": ["KI-Neuanlage für Roadmap-Stufe ohne Bibliothekstreffer"],
|
||||
"variants": [],
|
||||
"is_bridge": False,
|
||||
"is_ai_proposal": True,
|
||||
"ai_suggestion": dict(ai_payload),
|
||||
"roadmap_major_step_index": mi,
|
||||
"roadmap_phase": gap.get("expected_phase"),
|
||||
"roadmap_learning_goal": gap.get("learning_goal"),
|
||||
}
|
||||
|
||||
|
||||
def _build_gap_ai_context(
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
step_a: Mapping[str, Any],
|
||||
step_b: Mapping[str, Any],
|
||||
gap: Mapping[str, Any],
|
||||
title_hint: Optional[str] = None,
|
||||
sketch_hint: Optional[str] = None,
|
||||
) -> ExerciseFormAiPromptContext:
|
||||
topic = (brief.primary_topic or "Technik").strip()
|
||||
phase = gap.get("expected_phase") or "vertiefung"
|
||||
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
|
||||
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
|
||||
|
||||
title = (title_hint or f"Brücke {topic} ({phase})").strip()[:280]
|
||||
sketch = (sketch_hint or "").strip()
|
||||
goal_parts = [
|
||||
f"Planungsziel: {goal_query}",
|
||||
"",
|
||||
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.",
|
||||
f"Phase: {phase}. Thema: {topic}.",
|
||||
"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor.",
|
||||
]
|
||||
if sketch:
|
||||
goal_parts.extend(["", f"Hinweis: {sketch}"])
|
||||
goal = "\n".join(goal_parts)
|
||||
|
||||
focus_hint = topic if brief.topic_type == "technique" else None
|
||||
if brief.must_phrases:
|
||||
focus_hint = ", ".join(brief.must_phrases[:2])
|
||||
|
||||
return ExerciseFormAiPromptContext(
|
||||
title=title[:280],
|
||||
goal=goal[:8000],
|
||||
execution=None,
|
||||
focus_hint=focus_hint,
|
||||
)
|
||||
|
||||
|
||||
def ai_proposal_to_path_step(
|
||||
*,
|
||||
ai_payload: Mapping[str, Any],
|
||||
ctx_title: str,
|
||||
gap: Mapping[str, Any],
|
||||
step_a: Mapping[str, Any],
|
||||
step_b: Mapping[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
summary_text = ""
|
||||
summary_obj = ai_payload.get("summary")
|
||||
if isinstance(summary_obj, dict):
|
||||
summary_text = str(summary_obj.get("text") or "").strip()
|
||||
elif isinstance(summary_obj, str):
|
||||
summary_text = summary_obj.strip()
|
||||
|
||||
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
|
||||
title = (ctx_title or "").strip() or "KI-Vorschlag (Brücke)"
|
||||
reasons = ["KI-Neuanlage-Vorschlag — Lücke ohne passende Bibliotheks-Übung"]
|
||||
|
||||
return {
|
||||
"exercise_id": None,
|
||||
"proposal_key": proposal_key,
|
||||
"variant_id": None,
|
||||
"title": title,
|
||||
"summary": summary_text or None,
|
||||
"score": None,
|
||||
"semantic_score": None,
|
||||
"reasons": reasons,
|
||||
"variants": [],
|
||||
"is_bridge": True,
|
||||
"is_ai_proposal": True,
|
||||
"ai_suggestion": dict(ai_payload),
|
||||
"bridge_for_gap": {
|
||||
"from_exercise_id": step_a.get("exercise_id"),
|
||||
"to_exercise_id": step_b.get("exercise_id"),
|
||||
"gap_score": gap.get("gap_score"),
|
||||
"expected_phase": gap.get("expected_phase"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def try_suggest_ai_bridge_step(
|
||||
cur,
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
step_a: Mapping[str, Any],
|
||||
step_b: Mapping[str, Any],
|
||||
gap: Mapping[str, Any],
|
||||
title_hint: Optional[str] = None,
|
||||
sketch_hint: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
|
||||
ctx = _build_gap_ai_context(
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
step_a=step_a,
|
||||
step_b=step_b,
|
||||
gap=gap,
|
||||
title_hint=title_hint,
|
||||
sketch_hint=sketch_hint,
|
||||
)
|
||||
g_plain = strip_html_to_plain(ctx.goal)
|
||||
if not g_plain.strip() and not (ctx.title or "").strip():
|
||||
return None
|
||||
try:
|
||||
payload = run_exercise_form_ai_suggestion(
|
||||
cur,
|
||||
ctx,
|
||||
want_summary=True,
|
||||
want_skills=True,
|
||||
want_instructions=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning("KI-Lückenfüller fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
if not payload:
|
||||
return None
|
||||
return ai_proposal_to_path_step(
|
||||
ai_payload=payload,
|
||||
ctx_title=ctx.title or "",
|
||||
gap=gap,
|
||||
step_a=step_a,
|
||||
step_b=step_b,
|
||||
)
|
||||
|
||||
|
||||
def _default_sketch(
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
step_a: Optional[Mapping[str, Any]],
|
||||
step_b: Optional[Mapping[str, Any]],
|
||||
phase: str,
|
||||
rationale: str = "",
|
||||
) -> str:
|
||||
topic = (brief.primary_topic or "Technik").strip()
|
||||
from_t = (step_a or {}).get("title") or "vorherigem Schritt"
|
||||
to_t = (step_b or {}).get("title") or "nächstem Schritt"
|
||||
parts = [
|
||||
f"Planungsziel: {goal_query}",
|
||||
f"Zwischenschritt für {topic} ({phase}) zwischen „{from_t}“ und „{to_t}“.",
|
||||
]
|
||||
if rationale:
|
||||
parts.append(rationale)
|
||||
return " ".join(parts)[:1200]
|
||||
|
||||
|
||||
def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
|
||||
return (
|
||||
spec.get("source"),
|
||||
int(spec.get("insert_after_index") or 0),
|
||||
str(spec.get("title_hint") or "")[:48],
|
||||
)
|
||||
|
||||
|
||||
def _step_neighbors_at_index(
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
idx: int,
|
||||
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
|
||||
"""Vorheriger/nächster Pfadschritt ohne IndexError (Rand-Slots, leere Stufen)."""
|
||||
if idx < 0 or idx >= len(steps):
|
||||
return None, None
|
||||
step_a = steps[idx - 1] if idx > 0 else None
|
||||
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
||||
return step_a, step_b
|
||||
|
||||
|
||||
def collect_gap_fill_specs(
|
||||
*,
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
unfilled_gaps: Sequence[Mapping[str, Any]],
|
||||
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||
llm_specs: Sequence[Mapping[str, Any]],
|
||||
brief: PlanningSemanticBrief,
|
||||
goal_query: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Sammelt alle Lücken, für die ein KI-Anlege-Angebot sinnvoll ist."""
|
||||
topic = (brief.primary_topic or "Technik").strip()
|
||||
specs: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
|
||||
def add(spec: Dict[str, Any]) -> None:
|
||||
key = _spec_dedupe_key(spec)
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
specs.append(spec)
|
||||
|
||||
for gap in unfilled_gaps:
|
||||
idx = find_step_pair_index(
|
||||
steps,
|
||||
int(gap["from_exercise_id"]),
|
||||
int(gap["to_exercise_id"]),
|
||||
)
|
||||
if idx is None or idx + 1 >= len(steps):
|
||||
continue
|
||||
step_a = steps[idx]
|
||||
step_b = steps[idx + 1]
|
||||
phase = gap.get("expected_phase") or "vertiefung"
|
||||
add(
|
||||
{
|
||||
"source": "unfilled_gap",
|
||||
"insert_after_index": idx,
|
||||
"gap": dict(gap),
|
||||
"phase": phase,
|
||||
"title_hint": f"{topic} — {phase}",
|
||||
"sketch": _default_sketch(
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
step_a=step_a,
|
||||
step_b=step_b,
|
||||
phase=str(phase),
|
||||
rationale="Bibliothek enthält keine passende Brücke.",
|
||||
),
|
||||
"rationale": "Lücke zwischen benachbaren Schritten — keine passende Bibliotheks-Übung.",
|
||||
}
|
||||
)
|
||||
|
||||
for ot in off_topic_steps:
|
||||
major_idx = ot.get("roadmap_major_step_index")
|
||||
idx: Optional[int] = None
|
||||
if major_idx is not None:
|
||||
try:
|
||||
mi = int(major_idx)
|
||||
except (TypeError, ValueError):
|
||||
mi = None
|
||||
if mi is not None:
|
||||
idx = next(
|
||||
(
|
||||
i
|
||||
for i, s in enumerate(steps)
|
||||
if s.get("roadmap_major_step_index") is not None
|
||||
and int(s["roadmap_major_step_index"]) == mi
|
||||
),
|
||||
None,
|
||||
)
|
||||
if idx is None:
|
||||
idx = int(ot.get("step_index") or 0)
|
||||
if idx < 0 or idx >= len(steps):
|
||||
continue
|
||||
step_a, step_b = _step_neighbors_at_index(steps, idx)
|
||||
phase = ot.get("expected_phase") or "vertiefung"
|
||||
insert_after = max(idx - 1, -1)
|
||||
stage_goal = str(ot.get("roadmap_learning_goal") or "").strip()
|
||||
if str(ot.get("issue") or "") == "stage_mismatch" and stage_goal:
|
||||
title_hint = stage_goal[:120]
|
||||
rationale = (
|
||||
f"Keine passende Bibliotheks-Übung für Stufen-Lernziel „{stage_goal[:100]}“."
|
||||
)
|
||||
sketch_rationale = (
|
||||
f"Slot braucht Übung passend zu: {stage_goal[:200]}"
|
||||
)
|
||||
else:
|
||||
title_hint = f"{topic} — {phase} (Ersatz für themenfremden Schritt)"
|
||||
rationale = f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema."
|
||||
sketch_rationale = f"Ersetzt themenfremden Schritt „{ot.get('title')}“."
|
||||
add(
|
||||
{
|
||||
"source": "off_topic" if ot.get("issue") != "stage_mismatch" else "stage_mismatch",
|
||||
"insert_after_index": insert_after,
|
||||
"replace_step_index": idx,
|
||||
"roadmap_major_step_index": major_idx,
|
||||
"gap": {
|
||||
"expected_phase": phase,
|
||||
"off_topic_title": ot.get("title"),
|
||||
"off_topic_exercise_id": ot.get("exercise_id"),
|
||||
"roadmap_learning_goal": stage_goal or None,
|
||||
},
|
||||
"phase": phase,
|
||||
"title_hint": title_hint,
|
||||
"sketch": _default_sketch(
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
step_a=step_a,
|
||||
step_b=step_b,
|
||||
phase=str(phase),
|
||||
rationale=sketch_rationale,
|
||||
),
|
||||
"rationale": rationale,
|
||||
}
|
||||
)
|
||||
|
||||
for spec in llm_specs:
|
||||
add(dict(spec))
|
||||
|
||||
return specs[:5]
|
||||
|
||||
|
||||
def build_gap_fill_goal_text(
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
spec: Mapping[str, Any],
|
||||
step_a: Optional[Mapping[str, Any]] = None,
|
||||
step_b: Optional[Mapping[str, Any]] = None,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext."""
|
||||
topic = (brief.primary_topic or "Technik").strip()
|
||||
phase = spec.get("phase") or "vertiefung"
|
||||
from_title = (step_a or {}).get("title") or spec.get("from_title") or "vorherigem Schritt"
|
||||
to_title = (step_b or {}).get("title") or spec.get("to_title") or "nächstem Schritt"
|
||||
arc = ", ".join(brief.development_arc or []) or "einstieg → grundlage → vertiefung → anwendung → perfektion"
|
||||
snap = dict(roadmap_snapshot or {})
|
||||
if not snap:
|
||||
snap = build_progression_gap_snapshot(semantic_brief=brief_to_summary_dict(brief))
|
||||
|
||||
parts = [
|
||||
f"Planungsziel (gesamter Pfad): {goal_query}",
|
||||
f"Hauptthema: {snap.get('primary_topic') or topic}",
|
||||
]
|
||||
if snap.get("entry_state"):
|
||||
parts.append(
|
||||
f"Eingangszustand (erreichte Voraussetzungen aus Vorstufen): {snap['entry_state']}"
|
||||
)
|
||||
if snap.get("entry_state_detail") and snap.get("entry_state_detail") != snap.get("entry_state"):
|
||||
parts.append(f"Bisheriger Pfad:\n{snap['entry_state_detail']}")
|
||||
if snap.get("start_situation") and not snap.get("entry_state"):
|
||||
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
|
||||
elif snap.get("start_situation") and snap.get("prior_steps"):
|
||||
parts.append(f"Ausgangsbasis des gesamten Pfads: {snap['start_situation']}")
|
||||
if snap.get("target_state"):
|
||||
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
|
||||
if snap.get("roadmap_notes"):
|
||||
parts.append(f"Ergänzender Kontext: {snap['roadmap_notes']}")
|
||||
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
|
||||
if stage_goal:
|
||||
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
|
||||
parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}")
|
||||
parts.append(f"Erwarteter Entwicklungsbogen: {arc}")
|
||||
if spec.get("source") == "roadmap_unfilled":
|
||||
parts.append(
|
||||
"Einordnung: Übung für diese Roadmap-Stufe — das Stufen-Lernziel steht im Vordergrund."
|
||||
)
|
||||
if step_a:
|
||||
parts.append(f"Vorherige Stufe: „{from_title}“")
|
||||
if step_b:
|
||||
parts.append(f"Nächste Stufe: „{to_title}“")
|
||||
else:
|
||||
parts.append(
|
||||
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“."
|
||||
)
|
||||
if snap.get("stage_load_profile"):
|
||||
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
|
||||
if snap.get("stage_success_criteria"):
|
||||
parts.append(
|
||||
"Erfolgskriterien dieser Stufe: "
|
||||
+ "; ".join(str(x) for x in snap["stage_success_criteria"][:4])
|
||||
)
|
||||
if snap.get("stage_anti_patterns"):
|
||||
parts.append(
|
||||
"Vermeiden: " + "; ".join(str(x) for x in snap["stage_anti_patterns"][:3])
|
||||
)
|
||||
if snap.get("skill_hints"):
|
||||
parts.append(
|
||||
"Fähigkeiten-/Fokus-Hinweise: "
|
||||
+ "; ".join(str(x) for x in snap["skill_hints"][:4])
|
||||
)
|
||||
expected = snap.get("expected_skills") or []
|
||||
if expected:
|
||||
names = [
|
||||
str(s.get("skill_name") or "").strip()
|
||||
for s in expected[:5]
|
||||
if str(s.get("skill_name") or "").strip()
|
||||
]
|
||||
if names:
|
||||
parts.append(
|
||||
"Erwartete Fähigkeiten (Scoring): " + ", ".join(names)
|
||||
)
|
||||
if spec.get("rationale"):
|
||||
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
|
||||
if spec.get("sketch"):
|
||||
parts.append(f"Skizze: {spec['sketch']}")
|
||||
parts.append(
|
||||
"Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, "
|
||||
"Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. "
|
||||
"Konkrete Durchführung, Ziel und Trainerhinweise ausformulieren."
|
||||
)
|
||||
return "\n\n".join(parts)[:8000]
|
||||
|
||||
|
||||
def build_gap_fill_offer(
|
||||
*,
|
||||
spec: Mapping[str, Any],
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
goal_query: str = "",
|
||||
brief: Optional[PlanningSemanticBrief] = None,
|
||||
proposal: Optional[Mapping[str, Any]] = None,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
source = spec.get("source")
|
||||
idx = int(spec.get("insert_after_index") or 0)
|
||||
major_idx = spec.get("roadmap_major_step_index")
|
||||
if source == "roadmap_unfilled" and major_idx is not None:
|
||||
try:
|
||||
mi = int(major_idx)
|
||||
except (TypeError, ValueError):
|
||||
mi = idx
|
||||
step_a, step_b = _resolve_neighbor_steps_by_major_index(steps, mi)
|
||||
idx = mi
|
||||
else:
|
||||
step_a = steps[idx] if idx < len(steps) else None
|
||||
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
||||
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||
enriched_snapshot = dict(roadmap_snapshot) if roadmap_snapshot else {}
|
||||
major_raw = spec.get("roadmap_major_step_index")
|
||||
if major_raw is not None:
|
||||
enriched_snapshot = enrich_gap_snapshot_with_entry_state(
|
||||
enriched_snapshot,
|
||||
steps=steps,
|
||||
major_step_index=major_raw,
|
||||
)
|
||||
goal_for_ai = ""
|
||||
if brief and goal_query:
|
||||
goal_for_ai = build_gap_fill_goal_text(
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
spec=spec,
|
||||
step_a=step_a,
|
||||
step_b=step_b,
|
||||
roadmap_snapshot=enriched_snapshot or None,
|
||||
)
|
||||
ctx_preview = enriched_snapshot or None
|
||||
offer: Dict[str, Any] = {
|
||||
"offer_id": offer_id,
|
||||
"source": spec.get("source"),
|
||||
"insert_after_index": idx,
|
||||
"replace_step_index": spec.get("replace_step_index"),
|
||||
"title_hint": spec.get("title_hint"),
|
||||
"sketch": spec.get("sketch"),
|
||||
"goal_for_ai": goal_for_ai or spec.get("sketch"),
|
||||
"context_preview": ctx_preview,
|
||||
"phase": spec.get("phase"),
|
||||
"rationale": spec.get("rationale"),
|
||||
"has_ai_payload": False,
|
||||
"from_title": (step_a or {}).get("title"),
|
||||
"to_title": (step_b or {}).get("title"),
|
||||
"primary_topic": (brief.primary_topic if brief else None),
|
||||
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
||||
}
|
||||
if proposal:
|
||||
offer["has_ai_payload"] = True
|
||||
offer["proposal_key"] = proposal.get("proposal_key")
|
||||
offer["ai_suggestion"] = proposal.get("ai_suggestion")
|
||||
offer["proposal_title"] = proposal.get("title")
|
||||
offer["proposal_summary"] = proposal.get("summary")
|
||||
return offer
|
||||
|
||||
|
||||
def apply_gap_fill_after_qa(
|
||||
cur,
|
||||
steps: List[Dict[str, Any]],
|
||||
specs: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
include_ai_calls: bool = True,
|
||||
max_ai_proposals: int = 3,
|
||||
auto_insert_proposals: bool = False,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
|
||||
Returns: (steps, ai_proposals, gap_fill_offers)
|
||||
"""
|
||||
if not specs:
|
||||
return steps, [], []
|
||||
|
||||
out = list(steps)
|
||||
proposals: List[Dict[str, Any]] = []
|
||||
offers: List[Dict[str, Any]] = []
|
||||
|
||||
for spec in specs:
|
||||
source = spec.get("source")
|
||||
|
||||
if source == "roadmap_unfilled":
|
||||
proposal: Optional[Dict[str, Any]] = None
|
||||
if include_ai_calls and len(proposals) < max_ai_proposals:
|
||||
proposal = try_suggest_ai_stage_step(
|
||||
cur,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
spec=spec,
|
||||
steps=out,
|
||||
)
|
||||
offer = build_gap_fill_offer(
|
||||
spec=spec,
|
||||
steps=out,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
proposal=proposal,
|
||||
roadmap_snapshot=roadmap_snapshot,
|
||||
)
|
||||
offers.append(offer)
|
||||
if proposal and auto_insert_proposals:
|
||||
proposals.append(
|
||||
{
|
||||
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
||||
"proposal_key": proposal.get("proposal_key"),
|
||||
"proposal_title": proposal.get("title"),
|
||||
"offer_id": offer.get("offer_id"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
idx = int(spec.get("insert_after_index") or 0)
|
||||
if idx < 0 or idx >= len(out) - 1:
|
||||
continue
|
||||
step_a = out[idx]
|
||||
step_b = out[idx + 1]
|
||||
if step_a.get("is_ai_proposal") or step_b.get("is_ai_proposal"):
|
||||
offer = build_gap_fill_offer(
|
||||
spec=spec,
|
||||
steps=out,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
proposal=None,
|
||||
roadmap_snapshot=roadmap_snapshot,
|
||||
)
|
||||
offers.append(offer)
|
||||
continue
|
||||
|
||||
gap = dict(spec.get("gap") or {})
|
||||
if not gap.get("expected_phase"):
|
||||
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
||||
|
||||
proposal = None
|
||||
if include_ai_calls and len(proposals) < max_ai_proposals:
|
||||
proposal = try_suggest_ai_bridge_step(
|
||||
cur,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
step_a=step_a,
|
||||
step_b=step_b,
|
||||
gap=gap,
|
||||
title_hint=str(spec.get("title_hint") or ""),
|
||||
sketch_hint=str(spec.get("sketch") or ""),
|
||||
)
|
||||
|
||||
offer = build_gap_fill_offer(
|
||||
spec=spec,
|
||||
steps=out,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
proposal=proposal,
|
||||
roadmap_snapshot=roadmap_snapshot,
|
||||
)
|
||||
offers.append(offer)
|
||||
|
||||
if proposal and auto_insert_proposals:
|
||||
out.insert(idx + 1, proposal)
|
||||
proposals.append(
|
||||
{
|
||||
"inserted_after_index": idx,
|
||||
"proposal_key": proposal.get("proposal_key"),
|
||||
"proposal_title": proposal.get("title"),
|
||||
"gap": gap,
|
||||
"offer_id": offer.get("offer_id"),
|
||||
}
|
||||
)
|
||||
|
||||
return out, proposals, offers
|
||||
|
||||
|
||||
def insert_ai_proposals_for_gaps(
|
||||
cur,
|
||||
steps: list,
|
||||
unfilled_gaps: list,
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
max_proposals: int = 2,
|
||||
) -> tuple[list, list]:
|
||||
"""Legacy: Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
|
||||
specs = collect_gap_fill_specs(
|
||||
steps=steps,
|
||||
unfilled_gaps=unfilled_gaps,
|
||||
off_topic_steps=[],
|
||||
llm_specs=[],
|
||||
brief=brief,
|
||||
goal_query=goal_query,
|
||||
)
|
||||
out, proposals, _offers = apply_gap_fill_after_qa(
|
||||
cur,
|
||||
steps,
|
||||
specs,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
include_ai_calls=True,
|
||||
max_ai_proposals=max_proposals,
|
||||
auto_insert_proposals=True,
|
||||
)
|
||||
return out, proposals
|
||||
|
||||
|
||||
__all__ = [
|
||||
"apply_gap_fill_after_qa",
|
||||
"build_gap_fill_goal_text",
|
||||
"build_gap_fill_offer",
|
||||
"collect_gap_fill_specs",
|
||||
"insert_ai_proposals_for_gaps",
|
||||
"try_suggest_ai_bridge_step",
|
||||
"try_suggest_ai_stage_step",
|
||||
]
|
||||
4418
backend/planning_exercise_path_builder.py
Normal file
4418
backend/planning_exercise_path_builder.py
Normal file
File diff suppressed because it is too large
Load Diff
795
backend/planning_exercise_path_qa.py
Normal file
795
backend/planning_exercise_path_qa.py
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
"""
|
||||
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 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]],
|
||||
) -> 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 = {
|
||||
"goal_query": goal_query or "",
|
||||
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
||||
"steps_json": json.dumps(step_payload, ensure_ascii=False),
|
||||
"gaps_json": json.dumps(list(gaps), ensure_ascii=False),
|
||||
"bridge_inserts_json": json.dumps(list(bridge_inserts), ensure_ascii=False),
|
||||
}
|
||||
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables)
|
||||
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 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,
|
||||
) -> 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)
|
||||
if llm_qa:
|
||||
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
|
||||
summary["quality_score"] = llm_qa.get("quality_score")
|
||||
summary["issues"] = list(llm_qa.get("issues") or [])
|
||||
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
|
||||
summary["topic_coverage"] = llm_qa.get("topic_coverage")
|
||||
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
|
||||
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
|
||||
else:
|
||||
summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0
|
||||
summary["issues"] = [
|
||||
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
|
||||
for g in gaps
|
||||
] if gaps else []
|
||||
if off_topic:
|
||||
summary["issues"] = list(summary["issues"]) + [
|
||||
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
|
||||
for o in off_topic
|
||||
]
|
||||
summary["quality_score"] = compute_deterministic_path_quality_score(
|
||||
gaps=gaps,
|
||||
off_topic_steps=off_topic,
|
||||
steps=steps,
|
||||
multistage_qa=multistage_qa,
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
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 — Basis für Slot-Vergleiche."""
|
||||
score = 0.92
|
||||
score -= 0.08 * len(off_topic_steps or [])
|
||||
score -= 0.05 * len(gaps or [])
|
||||
if steps:
|
||||
empty = sum(
|
||||
1
|
||||
for s in steps
|
||||
if isinstance(s, dict)
|
||||
and s.get("exercise_id") is None
|
||||
and not s.get("is_ai_proposal")
|
||||
)
|
||||
score -= 0.06 * empty
|
||||
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
|
||||
score -= min(0.14, 0.02 * hint_count)
|
||||
return max(0.35, min(0.98, round(score, 4)))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"apply_llm_path_reorder",
|
||||
"build_path_qa_summary",
|
||||
"compute_deterministic_path_quality_score",
|
||||
"detect_off_topic_steps",
|
||||
"detect_path_gaps",
|
||||
"is_roadmap_planned_neighbor_pair",
|
||||
"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
873
backend/planning_exercise_suggest.py
Normal file
873
backend/planning_exercise_suggest.py
Normal file
|
|
@ -0,0 +1,873 @@
|
|||
"""
|
||||
Planungs-KI P0: Kontext-Pack + Hybrid-Retrieval für Übungssuche in der Trainingsplanung.
|
||||
|
||||
Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from tenant_context import TenantContext, library_content_visibility_sql
|
||||
from planning_exercise_profiles import skill_profile_summary_from_exercise_ids
|
||||
from planning_exercise_retrieval import run_multistage_planning_retrieval
|
||||
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
|
||||
from planning_exercise_progression import apply_progression_context_to_pack
|
||||
from planning_exercise_target_pipeline import (
|
||||
build_planning_target_with_query_pipeline,
|
||||
compose_retrieval_phase,
|
||||
should_run_llm_rank_pipeline,
|
||||
)
|
||||
from planning_exercise_semantics import (
|
||||
PlanningSemanticBrief,
|
||||
apply_dynamic_retrieval_weights,
|
||||
brief_to_summary_dict,
|
||||
build_semantic_brief,
|
||||
step_retrieval_query,
|
||||
try_enrich_semantic_brief_with_llm,
|
||||
)
|
||||
|
||||
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
|
||||
from routers.training_planning import (
|
||||
_assert_training_unit_permission,
|
||||
_fetch_sections,
|
||||
_has_planning_role,
|
||||
)
|
||||
|
||||
INTENT_SUGGEST_NEXT = "suggest_next"
|
||||
INTENT_PROGRESSION_NEXT = "progression_next"
|
||||
INTENT_DEEPEN_EXERCISE = "deepen_exercise"
|
||||
INTENT_CONTINUE_PLAN = "continue_plan_goal"
|
||||
INTENT_FREE_SEARCH = "free_search"
|
||||
|
||||
VALID_INTENTS = {
|
||||
INTENT_SUGGEST_NEXT,
|
||||
INTENT_PROGRESSION_NEXT,
|
||||
INTENT_DEEPEN_EXERCISE,
|
||||
INTENT_CONTINUE_PLAN,
|
||||
INTENT_FREE_SEARCH,
|
||||
}
|
||||
|
||||
|
||||
_LLM_RERANK_PRE_LIMIT = 32
|
||||
|
||||
|
||||
class PlanningExerciseSuggestRequest(BaseModel):
|
||||
unit_id: Optional[int] = Field(default=None, ge=1)
|
||||
group_id: Optional[int] = Field(default=None, ge=1)
|
||||
section_order_index: Optional[int] = Field(default=None, ge=0)
|
||||
phase_order_index: Optional[int] = Field(default=None, ge=0)
|
||||
parallel_stream_order_index: Optional[int] = Field(default=None, ge=0)
|
||||
anchor_exercise_id: Optional[int] = Field(default=None, ge=1)
|
||||
anchor_exercise_variant_id: Optional[int] = Field(default=None, ge=1)
|
||||
progression_graph_id: Optional[int] = Field(default=None, ge=1)
|
||||
query: Optional[str] = ""
|
||||
intent_hint: Optional[str] = None
|
||||
planned_exercise_ids: Optional[List[int]] = None
|
||||
section_title: Optional[str] = None
|
||||
section_guidance_notes: Optional[str] = None
|
||||
section_planned_exercise_ids: Optional[List[int]] = None
|
||||
include_llm_intent: bool = True
|
||||
include_llm_rank: bool = False
|
||||
limit: int = Field(default=20, ge=1, le=50)
|
||||
exercise_kind_any: Optional[List[str]] = None
|
||||
|
||||
|
||||
def resolve_planning_exercise_intent(query: Optional[str], intent_hint: Optional[str]) -> str:
|
||||
hint = (intent_hint or "").strip().lower()
|
||||
if hint in VALID_INTENTS:
|
||||
return hint
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return INTENT_SUGGEST_NEXT
|
||||
if any(w in q for w in ("nächste", "naechste", "vorschlag", "vorschlagen", "empfehl")):
|
||||
return INTENT_SUGGEST_NEXT
|
||||
if "vertief" in q:
|
||||
return INTENT_DEEPEN_EXERCISE
|
||||
if "progression" in q or "graph" in q or "pfad" in q:
|
||||
return INTENT_PROGRESSION_NEXT
|
||||
if "aufbau" in q or "planung" in q or "bisher" in q:
|
||||
return INTENT_CONTINUE_PLAN
|
||||
return INTENT_FREE_SEARCH
|
||||
|
||||
|
||||
def _intent_weights(intent: str) -> Dict[str, float]:
|
||||
base = {
|
||||
"fulltext": 0.18,
|
||||
"semantic": 0.0,
|
||||
"progression": 0.18,
|
||||
"skill": 0.12,
|
||||
"plan": 0.08,
|
||||
"profile": 0.22,
|
||||
"repeat_unit": -0.30,
|
||||
"repeat_group": -0.15,
|
||||
}
|
||||
if intent == INTENT_SUGGEST_NEXT:
|
||||
return {
|
||||
**base,
|
||||
"progression": 0.28,
|
||||
"skill": 0.12,
|
||||
"plan": 0.10,
|
||||
"profile": 0.25,
|
||||
"fulltext": 0.08,
|
||||
}
|
||||
if intent == INTENT_PROGRESSION_NEXT:
|
||||
return {**base, "progression": 0.42, "fulltext": 0.12, "skill": 0.10, "profile": 0.20}
|
||||
if intent == INTENT_DEEPEN_EXERCISE:
|
||||
return {**base, "skill": 0.15, "profile": 0.35, "fulltext": 0.15, "progression": 0.10}
|
||||
if intent == INTENT_CONTINUE_PLAN:
|
||||
return {**base, "plan": 0.12, "skill": 0.10, "profile": 0.30, "fulltext": 0.10, "progression": 0.08}
|
||||
if intent == INTENT_FREE_SEARCH:
|
||||
return {**base, "fulltext": 0.45, "progression": 0.08, "skill": 0.08, "profile": 0.15}
|
||||
return base
|
||||
|
||||
|
||||
def _collect_planned_exercise_ids(sections: Sequence[Dict[str, Any]]) -> List[int]:
|
||||
out: List[int] = []
|
||||
seen: Set[int] = set()
|
||||
for sec in sorted(sections, key=lambda s: int(s.get("order_index") or 0)):
|
||||
items = sec.get("items") or []
|
||||
for it in sorted(items, key=lambda x: int(x.get("order_index") or 0)):
|
||||
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||
continue
|
||||
raw = it.get("exercise_id")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
out.append(eid)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_anchor_from_plan(
|
||||
planned_ids: Sequence[int],
|
||||
anchor_exercise_id: Optional[int],
|
||||
) -> Optional[int]:
|
||||
if anchor_exercise_id and int(anchor_exercise_id) > 0:
|
||||
return int(anchor_exercise_id)
|
||||
if planned_ids:
|
||||
return int(planned_ids[-1])
|
||||
return None
|
||||
|
||||
|
||||
def _load_exercise_titles(cur, exercise_ids: Sequence[int]) -> Dict[int, str]:
|
||||
if not exercise_ids:
|
||||
return {}
|
||||
ids = list(dict.fromkeys(int(x) for x in exercise_ids if int(x) > 0))
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(
|
||||
f"SELECT id, title FROM exercises WHERE id IN ({ph})",
|
||||
ids,
|
||||
)
|
||||
return {int(r["id"]): str(r["title"] or "").strip() for r in cur.fetchall()}
|
||||
|
||||
|
||||
def _load_skill_ids_for_exercise(cur, exercise_id: Optional[int]) -> Set[int]:
|
||||
if not exercise_id:
|
||||
return set()
|
||||
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")}
|
||||
|
||||
|
||||
def _resolve_anchor_variant_id(
|
||||
pack: Mapping[str, Any],
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
sections: Optional[Sequence[Dict[str, Any]]] = None,
|
||||
) -> Optional[int]:
|
||||
raw = body.anchor_exercise_variant_id
|
||||
if raw is not None:
|
||||
try:
|
||||
vid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
vid = 0
|
||||
if vid > 0:
|
||||
return vid
|
||||
anchor_id = pack.get("anchor_exercise_id")
|
||||
if not anchor_id or not sections:
|
||||
return None
|
||||
sec = _section_for_context(sections, pack.get("section_order_index"))
|
||||
if not sec:
|
||||
return None
|
||||
target = int(anchor_id)
|
||||
for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0), reverse=True):
|
||||
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||
continue
|
||||
try:
|
||||
eid = int(it.get("exercise_id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid != target:
|
||||
continue
|
||||
raw_v = it.get("exercise_variant_id")
|
||||
if raw_v is None:
|
||||
return None
|
||||
try:
|
||||
vid = int(raw_v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return vid if vid > 0 else None
|
||||
return None
|
||||
|
||||
|
||||
def _enrich_planning_hits_with_variant_meta(
|
||||
cur,
|
||||
hits: Sequence[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Variantennamen und -listen für Treffer mit suggested_variant_id (Phase C2)."""
|
||||
if not hits:
|
||||
return []
|
||||
variant_ids: Set[int] = set()
|
||||
exercise_ids: Set[int] = set()
|
||||
for h in hits:
|
||||
exercise_ids.add(int(h["id"]))
|
||||
raw = h.get("suggested_variant_id")
|
||||
if raw is not None:
|
||||
try:
|
||||
vid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
vid = 0
|
||||
if vid > 0:
|
||||
variant_ids.add(vid)
|
||||
|
||||
names_by_variant: Dict[int, str] = {}
|
||||
if variant_ids:
|
||||
ph = ",".join(["%s"] * len(variant_ids))
|
||||
cur.execute(
|
||||
f"SELECT id, variant_name FROM exercise_variants WHERE id IN ({ph})",
|
||||
list(variant_ids),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
n = (row.get("variant_name") or "").strip()
|
||||
if n:
|
||||
names_by_variant[int(row["id"])] = n
|
||||
|
||||
variants_by_exercise: Dict[int, List[Dict[str, Any]]] = {}
|
||||
if exercise_ids:
|
||||
ph = ",".join(["%s"] * len(exercise_ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT exercise_id, id, variant_name, sequence_order
|
||||
FROM exercise_variants
|
||||
WHERE exercise_id IN ({ph})
|
||||
ORDER BY exercise_id, sequence_order NULLS LAST, id
|
||||
""",
|
||||
list(exercise_ids),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
eid = int(row["exercise_id"])
|
||||
variants_by_exercise.setdefault(eid, []).append(
|
||||
{
|
||||
"id": int(row["id"]),
|
||||
"variant_name": (row.get("variant_name") or "").strip() or None,
|
||||
"sequence_order": row.get("sequence_order"),
|
||||
}
|
||||
)
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for h in hits:
|
||||
item = dict(h)
|
||||
eid = int(item["id"])
|
||||
vars_for_ex = variants_by_exercise.get(eid) or []
|
||||
if vars_for_ex:
|
||||
item["variants"] = vars_for_ex
|
||||
raw_vid = item.get("suggested_variant_id")
|
||||
if raw_vid is not None:
|
||||
try:
|
||||
vid = int(raw_vid)
|
||||
except (TypeError, ValueError):
|
||||
vid = 0
|
||||
if vid > 0:
|
||||
item["suggested_variant_name"] = names_by_variant.get(vid)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _finalize_progression_context(
|
||||
cur,
|
||||
tenant: TenantContext,
|
||||
pack: Dict[str, Any],
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
*,
|
||||
sections: Optional[Sequence[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
anchor_variant = _resolve_anchor_variant_id(pack, body, sections)
|
||||
return apply_progression_context_to_pack(
|
||||
cur,
|
||||
tenant,
|
||||
pack,
|
||||
explicit_graph_id=body.progression_graph_id,
|
||||
anchor_variant_id=anchor_variant,
|
||||
)
|
||||
|
||||
|
||||
def _load_group_recent_exercise_ids(
|
||||
cur,
|
||||
group_id: Optional[int],
|
||||
exclude_unit_id: Optional[int] = None,
|
||||
limit: int = 40,
|
||||
) -> Set[int]:
|
||||
if not group_id:
|
||||
return set()
|
||||
if exclude_unit_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tusi.exercise_id AS eid
|
||||
FROM training_units tu
|
||||
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
|
||||
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
|
||||
WHERE tu.group_id = %s
|
||||
AND tu.id <> %s
|
||||
AND tusi.exercise_id IS NOT NULL
|
||||
AND COALESCE(tu.status, '') <> 'cancelled'
|
||||
ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
(int(group_id), int(exclude_unit_id)),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tusi.exercise_id AS eid
|
||||
FROM training_units tu
|
||||
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
|
||||
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
|
||||
WHERE tu.group_id = %s
|
||||
AND tusi.exercise_id IS NOT NULL
|
||||
AND COALESCE(tu.status, '') <> 'cancelled'
|
||||
ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
(int(group_id),),
|
||||
)
|
||||
out: Set[int] = set()
|
||||
for r in cur.fetchall():
|
||||
if r.get("eid") is None:
|
||||
continue
|
||||
out.add(int(r["eid"]))
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _section_for_context(
|
||||
sections: Sequence[Dict[str, Any]],
|
||||
section_order_index: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if section_order_index is None:
|
||||
return None
|
||||
target = int(section_order_index)
|
||||
for sec in sections:
|
||||
if int(sec.get("order_index") or -1) == target:
|
||||
return sec
|
||||
if 0 <= target < len(sections):
|
||||
return sections[target]
|
||||
return None
|
||||
|
||||
|
||||
def _collect_exercise_ids_from_section(sec: Optional[Dict[str, Any]]) -> List[int]:
|
||||
if not sec:
|
||||
return []
|
||||
out: List[int] = []
|
||||
seen: Set[int] = set()
|
||||
for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0)):
|
||||
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||
continue
|
||||
raw = it.get("exercise_id")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
out.append(eid)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_last_exercise_in_section(sec: Optional[Dict[str, Any]]) -> Tuple[Optional[int], Optional[str]]:
|
||||
if not sec:
|
||||
return None, None
|
||||
last_id: Optional[int] = None
|
||||
last_title: Optional[str] = None
|
||||
for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0)):
|
||||
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||
continue
|
||||
raw = it.get("exercise_id")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1:
|
||||
continue
|
||||
last_id = eid
|
||||
t = (it.get("exercise_title") or "").strip()
|
||||
last_title = t or None
|
||||
return last_id, last_title
|
||||
|
||||
|
||||
def _attach_planning_context_details(
|
||||
cur,
|
||||
pack: Dict[str, Any],
|
||||
*,
|
||||
sections: Optional[Sequence[Dict[str, Any]]] = None,
|
||||
body: Optional[PlanningExerciseSuggestRequest] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Abschnitt, Fähigkeitenprofile und letzte Übung anreichern."""
|
||||
sec: Optional[Dict[str, Any]] = None
|
||||
section_idx = pack.get("section_order_index")
|
||||
if sections is not None and section_idx is not None:
|
||||
sec = _section_for_context(sections, section_idx)
|
||||
|
||||
section_ids = _collect_exercise_ids_from_section(sec)
|
||||
if body and body.section_planned_exercise_ids:
|
||||
section_ids = []
|
||||
seen: Set[int] = set()
|
||||
for raw in body.section_planned_exercise_ids:
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
section_ids.append(eid)
|
||||
elif pack.get("section_planned_exercise_ids"):
|
||||
section_ids = list(pack.get("section_planned_exercise_ids") or [])
|
||||
|
||||
section_title = pack.get("section_title")
|
||||
if body and (body.section_title or "").strip():
|
||||
section_title = (body.section_title or "").strip()
|
||||
elif sec and (sec.get("title") or "").strip():
|
||||
section_title = (sec.get("title") or "").strip()
|
||||
|
||||
guidance = None
|
||||
if body and (body.section_guidance_notes or "").strip():
|
||||
guidance = (body.section_guidance_notes or "").strip()
|
||||
elif sec and (sec.get("guidance_notes") or "").strip():
|
||||
guidance = (sec.get("guidance_notes") or "").strip()
|
||||
|
||||
last_in_section_id, last_in_section_title = _resolve_last_exercise_in_section(sec)
|
||||
if body and not last_in_section_id and pack.get("anchor_exercise_id"):
|
||||
last_in_section_id = pack.get("anchor_exercise_id")
|
||||
last_in_section_title = pack.get("anchor_title")
|
||||
|
||||
unit_ids = list(pack.get("planned_exercise_ids") or [])
|
||||
pack["section_title"] = section_title
|
||||
pack["section_guidance_notes"] = guidance
|
||||
pack["section_planned_exercise_ids"] = section_ids
|
||||
pack["section_exercise_count"] = len(section_ids)
|
||||
pack["last_section_exercise_id"] = last_in_section_id
|
||||
pack["last_section_exercise_title"] = last_in_section_title
|
||||
pack["unit_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, unit_ids)
|
||||
pack["section_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, section_ids)
|
||||
pack["has_planning_reference"] = bool(
|
||||
unit_ids
|
||||
or section_ids
|
||||
or pack.get("anchor_exercise_id")
|
||||
or (pack.get("unit") or {}).get("framework_slot_id")
|
||||
or (pack.get("unit") or {}).get("origin_framework_slot_id")
|
||||
)
|
||||
return pack
|
||||
|
||||
|
||||
def _section_title_for_index(sections: Sequence[Dict[str, Any]], section_order_index: Optional[int]) -> Optional[str]:
|
||||
if section_order_index is None:
|
||||
return None
|
||||
for sec in sections:
|
||||
if int(sec.get("order_index") or -1) == int(section_order_index):
|
||||
t = (sec.get("title") or "").strip()
|
||||
return t or None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_query(query: Optional[str]) -> str:
|
||||
return re.sub(r"\s+", " ", (query or "").strip())
|
||||
|
||||
|
||||
def _apply_client_planned_override(
|
||||
cur,
|
||||
pack: Dict[str, Any],
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
) -> Dict[str, Any]:
|
||||
"""Client-Plan (ungespeichertes Formular) überschreibt DB-Stand."""
|
||||
if not body.planned_exercise_ids:
|
||||
return pack
|
||||
planned_ids: List[int] = []
|
||||
seen: Set[int] = set()
|
||||
for raw in body.planned_exercise_ids:
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
planned_ids.append(eid)
|
||||
if not planned_ids:
|
||||
return pack
|
||||
|
||||
pack["planned_exercise_ids"] = planned_ids
|
||||
if not body.anchor_exercise_id:
|
||||
anchor_id = _resolve_anchor_from_plan(planned_ids, None)
|
||||
pack["anchor_exercise_id"] = anchor_id
|
||||
if anchor_id:
|
||||
titles = _load_exercise_titles(cur, [anchor_id])
|
||||
pack["anchor_title"] = titles.get(anchor_id)
|
||||
pack["anchor_skill_ids"] = sorted(_load_skill_ids_for_exercise(cur, anchor_id))
|
||||
else:
|
||||
pack["anchor_title"] = None
|
||||
pack["anchor_skill_ids"] = []
|
||||
return pack
|
||||
|
||||
|
||||
def build_planning_exercise_context_pack(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
) -> Dict[str, Any]:
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
|
||||
if not _has_planning_role(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Planungs-Vorschläge abrufen")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tu.*, tg.name AS group_name
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tg.id = tu.group_id
|
||||
WHERE tu.id = %s
|
||||
""",
|
||||
(body.unit_id,),
|
||||
)
|
||||
unit_row = cur.fetchone()
|
||||
if not unit_row:
|
||||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||||
unit = dict(unit_row)
|
||||
|
||||
if unit.get("framework_slot_id"):
|
||||
if role not in ("admin", "superadmin"):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT fp.created_by FROM training_framework_slots s
|
||||
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
|
||||
WHERE s.id = %s
|
||||
""",
|
||||
(unit["framework_slot_id"],),
|
||||
)
|
||||
fr = cur.fetchone()
|
||||
cb = fr["created_by"] if fr else None
|
||||
if unit.get("created_by") != profile_id and cb != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
else:
|
||||
if not unit.get("group_id"):
|
||||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||||
_assert_training_unit_permission(cur, unit, profile_id, role)
|
||||
|
||||
sections = _fetch_sections(cur, int(body.unit_id))
|
||||
planned_ids = _collect_planned_exercise_ids(sections)
|
||||
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
|
||||
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
|
||||
group_recent = _load_group_recent_exercise_ids(cur, unit.get("group_id"), int(body.unit_id))
|
||||
|
||||
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
||||
anchor_title = titles.get(anchor_id) if anchor_id else None
|
||||
|
||||
pack = {
|
||||
"unit_id": int(body.unit_id),
|
||||
"unit": {
|
||||
"id": int(body.unit_id),
|
||||
"framework_slot_id": unit.get("framework_slot_id"),
|
||||
"origin_framework_slot_id": unit.get("origin_framework_slot_id"),
|
||||
},
|
||||
"unit_title": (unit.get("title") or unit.get("planned_focus") or "").strip() or None,
|
||||
"group_id": unit.get("group_id"),
|
||||
"group_name": (unit.get("group_name") or "").strip() or None,
|
||||
"section_order_index": body.section_order_index,
|
||||
"section_title": _section_title_for_index(sections, body.section_order_index),
|
||||
"planned_exercise_ids": planned_ids,
|
||||
"anchor_exercise_id": anchor_id,
|
||||
"anchor_title": anchor_title,
|
||||
"anchor_skill_ids": sorted(anchor_skills),
|
||||
"group_recent_exercise_ids": sorted(group_recent),
|
||||
}
|
||||
return _attach_planning_context_details(cur, pack, sections=sections, body=body)
|
||||
|
||||
|
||||
def build_client_planning_context_pack(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
) -> Dict[str, Any]:
|
||||
"""Freie / Client-Kontext-Suche ohne persistierte training_units.id (Formular, Rahmen-Slot)."""
|
||||
role = tenant.global_role
|
||||
if not _has_planning_role(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Planungs-Vorschläge abrufen")
|
||||
|
||||
planned_ids: List[int] = []
|
||||
if body.planned_exercise_ids:
|
||||
seen: Set[int] = set()
|
||||
for raw in body.planned_exercise_ids:
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
planned_ids.append(eid)
|
||||
|
||||
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
|
||||
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
|
||||
|
||||
group_id = body.group_id
|
||||
group_name = None
|
||||
if group_id:
|
||||
cur.execute("SELECT name FROM training_groups WHERE id = %s", (int(group_id),))
|
||||
gr = cur.fetchone()
|
||||
if gr:
|
||||
group_name = (gr.get("name") or "").strip() or None
|
||||
|
||||
group_recent = _load_group_recent_exercise_ids(cur, group_id, exclude_unit_id=None)
|
||||
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
||||
anchor_title = titles.get(anchor_id) if anchor_id else None
|
||||
|
||||
pack = {
|
||||
"unit_id": None,
|
||||
"unit": {
|
||||
"id": None,
|
||||
"framework_slot_id": None,
|
||||
"origin_framework_slot_id": None,
|
||||
},
|
||||
"unit_title": None,
|
||||
"group_id": group_id,
|
||||
"group_name": group_name,
|
||||
"section_order_index": body.section_order_index,
|
||||
"section_title": (body.section_title or "").strip() or None,
|
||||
"planned_exercise_ids": planned_ids,
|
||||
"anchor_exercise_id": anchor_id,
|
||||
"anchor_title": anchor_title,
|
||||
"anchor_skill_ids": sorted(anchor_skills),
|
||||
"group_recent_exercise_ids": sorted(group_recent),
|
||||
"context_mode": "client_free",
|
||||
}
|
||||
return _attach_planning_context_details(cur, pack, sections=None, body=body)
|
||||
|
||||
|
||||
def suggest_planning_exercises(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
) -> Dict[str, Any]:
|
||||
if body.unit_id:
|
||||
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
|
||||
else:
|
||||
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
|
||||
pack = _apply_client_planned_override(cur, pack, body)
|
||||
pack = _attach_planning_context_details(cur, pack, body=body)
|
||||
sections_for_variant = None
|
||||
if body.unit_id and not (body.anchor_exercise_variant_id and int(body.anchor_exercise_variant_id) > 0):
|
||||
sections_for_variant = _fetch_sections(cur, int(body.unit_id))
|
||||
pack = _finalize_progression_context(
|
||||
cur, tenant, pack, body, sections=sections_for_variant
|
||||
)
|
||||
query = _normalize_query(body.query)
|
||||
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
||||
|
||||
has_plan_ref = bool(pack.get("has_planning_reference"))
|
||||
expectation_mode = "planning_hybrid" if has_plan_ref else "query_only"
|
||||
|
||||
pipeline_context = {
|
||||
"unit_title": pack.get("unit_title"),
|
||||
"group_name": pack.get("group_name"),
|
||||
"section_title": pack.get("section_title"),
|
||||
"section_guidance_notes": pack.get("section_guidance_notes"),
|
||||
"section_exercise_count": pack.get("section_exercise_count"),
|
||||
"planned_count": len(pack.get("planned_exercise_ids") or []),
|
||||
"anchor_title": pack.get("anchor_title"),
|
||||
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
||||
"last_section_exercise_title": pack.get("last_section_exercise_title"),
|
||||
"progression_graph_id": pack.get("progression_graph_id"),
|
||||
"unit_skill_profile": pack.get("unit_skill_profile_summary"),
|
||||
"section_skill_profile": pack.get("section_skill_profile_summary"),
|
||||
"has_planning_reference": has_plan_ref,
|
||||
"expectation_mode": expectation_mode,
|
||||
}
|
||||
target_profile, intent, scenario_kind, query_intent_summary = build_planning_target_with_query_pipeline(
|
||||
cur,
|
||||
unit=pack["unit"],
|
||||
planned_exercise_ids=pack["planned_exercise_ids"],
|
||||
section_planned_exercise_ids=pack.get("section_planned_exercise_ids") or [],
|
||||
anchor_exercise_id=pack.get("anchor_exercise_id"),
|
||||
query=query,
|
||||
heuristic_intent=heuristic_intent,
|
||||
include_llm_intent=body.include_llm_intent,
|
||||
context_summary=pipeline_context,
|
||||
has_planning_reference=has_plan_ref,
|
||||
)
|
||||
target_profile_summary = target_profile.to_summary_dict(cur)
|
||||
query_intent_applied = bool(query_intent_summary.get("llm_applied"))
|
||||
llm_expectation_applied = bool(query_intent_summary.get("llm_expectation_applied"))
|
||||
profile_llm_applied = bool(query_intent_summary.get("profile_llm_applied"))
|
||||
|
||||
semantic_brief = build_semantic_brief(query)
|
||||
semantic_llm_applied = False
|
||||
if body.include_llm_intent and semantic_brief.semantic_strength >= 0.35:
|
||||
semantic_brief, semantic_llm_applied = try_enrich_semantic_brief_with_llm(
|
||||
cur, query, semantic_brief
|
||||
)
|
||||
|
||||
weights = apply_dynamic_retrieval_weights(
|
||||
_intent_weights(intent),
|
||||
semantic_brief,
|
||||
scenario=scenario_kind,
|
||||
has_planning_reference=has_plan_ref,
|
||||
)
|
||||
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
vis_sql, vis_params = library_content_visibility_sql(
|
||||
alias="e",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
|
||||
hits, skills_by_ex, full_library_ranked = run_multistage_planning_retrieval(
|
||||
cur,
|
||||
vis_sql=vis_sql,
|
||||
vis_params=vis_params,
|
||||
query=query,
|
||||
exercise_kind_any=body.exercise_kind_any,
|
||||
target=target_profile,
|
||||
intent=intent,
|
||||
intent_weights=weights,
|
||||
pack={
|
||||
**pack,
|
||||
"requires_partner": query_intent_summary.get("requires_partner"),
|
||||
"semantic_brief": semantic_brief,
|
||||
"retrieval_query": semantic_brief.retrieval_query or query,
|
||||
},
|
||||
)
|
||||
|
||||
text_signals_applied = "planning_text_signals" in (target_profile.sources or [])
|
||||
|
||||
planned_set = set(pack["planned_exercise_ids"])
|
||||
|
||||
llm_rank_applied = False
|
||||
retrieval_phase = compose_retrieval_phase(
|
||||
full_library=full_library_ranked,
|
||||
text_signals=text_signals_applied,
|
||||
query_intent=query_intent_applied,
|
||||
llm_expectation=llm_expectation_applied,
|
||||
llm_rank=False,
|
||||
semantics=semantic_brief.semantic_strength >= 0.35,
|
||||
)
|
||||
run_llm_rank = should_run_llm_rank_pipeline(
|
||||
query,
|
||||
scenario_kind,
|
||||
include_llm_rank=body.include_llm_rank,
|
||||
query_intent_applied=query_intent_applied,
|
||||
llm_expectation_applied=llm_expectation_applied,
|
||||
has_planning_reference=has_plan_ref,
|
||||
hits=hits,
|
||||
)
|
||||
if run_llm_rank:
|
||||
pre_limit = max(int(body.limit), _LLM_RERANK_PRE_LIMIT)
|
||||
pool_hits = hits[:pre_limit]
|
||||
pool_hits, llm_rank_applied = try_llm_rerank_planning_hits(
|
||||
cur,
|
||||
hits=pool_hits,
|
||||
skills_by_ex=skills_by_ex,
|
||||
query=query,
|
||||
intent=intent,
|
||||
context_summary={
|
||||
"unit_title": pack.get("unit_title"),
|
||||
"group_name": pack.get("group_name"),
|
||||
"section_title": pack.get("section_title"),
|
||||
"planned_count": len(planned_set),
|
||||
"anchor_title": pack.get("anchor_title"),
|
||||
"intent": intent,
|
||||
},
|
||||
target_profile_summary=target_profile_summary,
|
||||
limit=int(body.limit),
|
||||
)
|
||||
if llm_rank_applied:
|
||||
retrieval_phase = compose_retrieval_phase(
|
||||
full_library=full_library_ranked,
|
||||
text_signals=text_signals_applied,
|
||||
query_intent=query_intent_applied,
|
||||
llm_expectation=llm_expectation_applied,
|
||||
llm_rank=True,
|
||||
semantics=semantic_brief.semantic_strength >= 0.35,
|
||||
)
|
||||
tail = hits[pre_limit:]
|
||||
hits = pool_hits + tail
|
||||
else:
|
||||
hits = pool_hits[: int(body.limit)]
|
||||
else:
|
||||
hits = hits[: int(body.limit)]
|
||||
|
||||
hits = hits[: int(body.limit)]
|
||||
hits = _enrich_planning_hits_with_variant_meta(cur, hits)
|
||||
|
||||
context_summary = {
|
||||
"unit_title": pack.get("unit_title"),
|
||||
"group_name": pack.get("group_name"),
|
||||
"section_title": pack.get("section_title"),
|
||||
"section_guidance_notes": pack.get("section_guidance_notes"),
|
||||
"section_exercise_count": pack.get("section_exercise_count"),
|
||||
"planned_count": len(planned_set),
|
||||
"anchor_title": pack.get("anchor_title"),
|
||||
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
||||
"last_section_exercise_title": pack.get("last_section_exercise_title"),
|
||||
"progression_graph_id": pack.get("progression_graph_id"),
|
||||
"progression_graph_name": pack.get("progression_graph_name"),
|
||||
"progression_graph_auto_resolved": pack.get("progression_graph_auto_resolved"),
|
||||
"anchor_exercise_variant_id": pack.get("anchor_exercise_variant_id"),
|
||||
"context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"),
|
||||
"unit_skill_profile": pack.get("unit_skill_profile_summary"),
|
||||
"section_skill_profile": pack.get("section_skill_profile_summary"),
|
||||
"has_planning_reference": pack.get("has_planning_reference"),
|
||||
"expectation_mode": expectation_mode,
|
||||
}
|
||||
|
||||
return {
|
||||
"context_summary": context_summary,
|
||||
"target_profile_summary": target_profile_summary,
|
||||
"scenario_kind": scenario_kind,
|
||||
"query_intent_summary": query_intent_summary,
|
||||
"retrieval_phase": retrieval_phase,
|
||||
"full_library_ranked": full_library_ranked,
|
||||
"text_signals_applied": text_signals_applied,
|
||||
"profile_preselect_applied": False,
|
||||
"llm_rank_applied": llm_rank_applied,
|
||||
"llm_intent_applied": query_intent_applied,
|
||||
"llm_expectation_applied": llm_expectation_applied,
|
||||
"profile_llm_applied": profile_llm_applied,
|
||||
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
|
||||
"semantic_llm_applied": semantic_llm_applied,
|
||||
"intent_resolved": intent,
|
||||
"intent_heuristic": heuristic_intent,
|
||||
"query_normalized": query or None,
|
||||
"expectation_mode": expectation_mode,
|
||||
"hits": hits,
|
||||
}
|
||||
464
backend/planning_exercise_target_pipeline.py
Normal file
464
backend/planning_exercise_target_pipeline.py
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
"""
|
||||
Szenario-Routing und Erwartungsprofil-Pipeline für Planungs-Übungssuche (P1).
|
||||
|
||||
Ablauf:
|
||||
1. Heuristik: Intent + Szenario-Klasse aus Query/Kontext
|
||||
2. Optional LLM (planning_exercise_search_intent) bei komplexen Anfragen
|
||||
3. Deterministisches Basis-Profil (Rahmen, Plan, Anker)
|
||||
4. Query-Overlay mergen → PlanningTargetProfile für Vorselektion
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from planning_exercise_expectation import try_build_planning_expectation_from_context
|
||||
from planning_exercise_intent import (
|
||||
PlanningQueryIntentParsed,
|
||||
resolve_query_intent_catalog_ids,
|
||||
try_parse_planning_query_intent,
|
||||
)
|
||||
from planning_exercise_profiles import (
|
||||
PlanningTargetProfile,
|
||||
_merge_weight_maps,
|
||||
_normalize_weight_map,
|
||||
build_planning_target_profile,
|
||||
)
|
||||
|
||||
SCENARIO_PRESET_NEXT = "preset_next"
|
||||
SCENARIO_PROGRESSION = "progression"
|
||||
SCENARIO_DEEPEN = "deepen"
|
||||
SCENARIO_CONTINUE_PLAN = "continue_plan"
|
||||
SCENARIO_ADDITIVE = "additive_constraint"
|
||||
SCENARIO_FREE_SEARCH = "free_search"
|
||||
|
||||
_SIMPLE_PRESET_PATTERNS = (
|
||||
r"^(schlage?\s+(mir\s+)?(die\s+)?(n[aä]chste|naechste)\s+(sinnvolle\s+)?(übung|uebung)\s*(vor)?\.?)$",
|
||||
r"^(n[aä]chste|naechste)\s+(übung|uebung)\s*(vorschlag|vorschlagen|empfehl\w*)?\.?$",
|
||||
r"^(vorschlag|vorschlagen|empfehl\w*)\s*(für|fuer)?\s*(die\s+)?(n[aä]chste|naechste)?\s*(übung|uebung)?\.?$",
|
||||
r"^n[aä]chste\s+übung$",
|
||||
r"^n[aä]chste\s+uebung$",
|
||||
r"^(n[aä]chste|naechste)\s+(übung|uebung)\s+planen\.?$",
|
||||
)
|
||||
|
||||
_ADDITIVE_MARKERS = (
|
||||
"zusätzlich",
|
||||
"zusaetzlich",
|
||||
"auch ",
|
||||
" außerdem",
|
||||
" ausserdem",
|
||||
" dazu",
|
||||
" extra",
|
||||
" mehr ",
|
||||
" und dabei",
|
||||
" sowie ",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_query(q: Optional[str]) -> str:
|
||||
return re.sub(r"\s+", " ", (q or "").strip())
|
||||
|
||||
|
||||
def is_simple_preset_query(query: Optional[str]) -> bool:
|
||||
q = _normalize_query(query).lower()
|
||||
if not q:
|
||||
return True
|
||||
for pat in _SIMPLE_PRESET_PATTERNS:
|
||||
if re.match(pat, q, flags=re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def classify_planning_scenario(
|
||||
query: Optional[str],
|
||||
heuristic_intent: str,
|
||||
) -> str:
|
||||
q = _normalize_query(query).lower()
|
||||
if not q or is_simple_preset_query(q):
|
||||
return SCENARIO_PRESET_NEXT
|
||||
if heuristic_intent == "progression_next":
|
||||
return SCENARIO_PROGRESSION
|
||||
if heuristic_intent == "deepen_exercise":
|
||||
return SCENARIO_DEEPEN
|
||||
if any(m in f" {q} " for m in _ADDITIVE_MARKERS):
|
||||
return SCENARIO_ADDITIVE
|
||||
if heuristic_intent == "continue_plan_goal":
|
||||
return SCENARIO_CONTINUE_PLAN
|
||||
if heuristic_intent == "free_search":
|
||||
return SCENARIO_FREE_SEARCH
|
||||
if heuristic_intent == "suggest_next":
|
||||
return SCENARIO_CONTINUE_PLAN
|
||||
return SCENARIO_FREE_SEARCH
|
||||
|
||||
|
||||
def should_run_llm_expectation_pipeline(
|
||||
scenario: str,
|
||||
*,
|
||||
include_llm_intent: bool,
|
||||
has_planning_reference: bool,
|
||||
) -> bool:
|
||||
"""Preset/leere Anfrage mit Planungsbezug → LLM-Erwartungsprofil statt Query-Intent."""
|
||||
if not include_llm_intent:
|
||||
return False
|
||||
if not has_planning_reference:
|
||||
return False
|
||||
return scenario == SCENARIO_PRESET_NEXT
|
||||
|
||||
|
||||
def should_run_llm_intent_pipeline(
|
||||
query: Optional[str],
|
||||
scenario: str,
|
||||
*,
|
||||
include_llm_intent: bool,
|
||||
) -> bool:
|
||||
if not include_llm_intent:
|
||||
return False
|
||||
if scenario == SCENARIO_PRESET_NEXT:
|
||||
return False
|
||||
q = _normalize_query(query)
|
||||
if not q:
|
||||
return False
|
||||
# Kurze Stichwortsuche: Volltext + Profil reichen — kein Intent-LLM
|
||||
if scenario == SCENARIO_FREE_SEARCH and len(q) < 14:
|
||||
return False
|
||||
if scenario in (SCENARIO_CONTINUE_PLAN, SCENARIO_PROGRESSION) and len(q) < 18:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def deterministic_rank_confident(hits: Sequence[Mapping[str, Any]], *, gap_threshold: float = 0.12) -> bool:
|
||||
"""True wenn Hybrid-Ranking schon klar genug ist — LLM-Rerank sparen."""
|
||||
if len(hits) < 4:
|
||||
return True
|
||||
top = float(hits[0].get("score") or 0.0)
|
||||
fourth = float(hits[3].get("score") or 0.0)
|
||||
return (top - fourth) >= gap_threshold
|
||||
|
||||
|
||||
def hybrid_ranking_ambiguous(
|
||||
hits: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
top_four_gap: float = 0.08,
|
||||
top_ten_gap: float = 0.055,
|
||||
) -> bool:
|
||||
"""True wenn Top-Kandidaten scores zu nah beieinander liegen — Rerank lohnt sich."""
|
||||
if len(hits) < 3:
|
||||
return False
|
||||
top = float(hits[0].get("score") or 0.0)
|
||||
if len(hits) >= 4:
|
||||
fourth = float(hits[3].get("score") or 0.0)
|
||||
if (top - fourth) < top_four_gap:
|
||||
return True
|
||||
if len(hits) >= 10:
|
||||
tenth = float(hits[9].get("score") or 0.0)
|
||||
if (top - tenth) < top_ten_gap:
|
||||
return True
|
||||
elif len(hits) >= 2:
|
||||
tail = float(hits[min(len(hits) - 1, 9)].get("score") or 0.0)
|
||||
if (top - tail) < top_four_gap:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_run_llm_rank_pipeline(
|
||||
query: Optional[str],
|
||||
scenario: str,
|
||||
*,
|
||||
include_llm_rank: bool,
|
||||
query_intent_applied: bool,
|
||||
llm_expectation_applied: bool = False,
|
||||
has_planning_reference: bool = True,
|
||||
hits: Sequence[Mapping[str, Any]],
|
||||
) -> bool:
|
||||
"""
|
||||
Phase B2: Rerank bei unklarem Hybrid-Ranking — auch nach Erwartungs-/Intent-LLM.
|
||||
|
||||
Budget: max. 2 LLM-Calls pro Suche (Profil-LLM + optional Rerank).
|
||||
"""
|
||||
if not include_llm_rank:
|
||||
return False
|
||||
if len(hits) < 3:
|
||||
return False
|
||||
if not hybrid_ranking_ambiguous(hits):
|
||||
return False
|
||||
|
||||
q = _normalize_query(query)
|
||||
profile_llm = query_intent_applied or llm_expectation_applied
|
||||
|
||||
if scenario == SCENARIO_PRESET_NEXT:
|
||||
return has_planning_reference
|
||||
|
||||
if scenario == SCENARIO_FREE_SEARCH:
|
||||
if len(q) < 10 and not profile_llm:
|
||||
return False
|
||||
return True
|
||||
|
||||
if scenario == SCENARIO_ADDITIVE:
|
||||
return len(q) >= 8 or profile_llm
|
||||
|
||||
if profile_llm:
|
||||
return True
|
||||
return len(q) >= 14
|
||||
|
||||
|
||||
def _recalculate_skill_gap(target: PlanningTargetProfile) -> PlanningTargetProfile:
|
||||
skill_target = _normalize_weight_map(dict(target.skill_weights))
|
||||
skill_plan_norm = _normalize_weight_map(dict(target.skill_plan_weights))
|
||||
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
|
||||
sources = list(target.sources)
|
||||
if skill_gap and "skill_gap_vs_plan" not in sources:
|
||||
sources.append("skill_gap_vs_plan")
|
||||
elif not skill_gap:
|
||||
sources = [s for s in sources if s != "skill_gap_vs_plan"]
|
||||
return PlanningTargetProfile(
|
||||
focus_area_ids=target.focus_area_ids,
|
||||
style_direction_ids=target.style_direction_ids,
|
||||
training_type_ids=target.training_type_ids,
|
||||
target_group_ids=target.target_group_ids,
|
||||
skill_weights=skill_target,
|
||||
skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else {},
|
||||
skill_plan_weights=target.skill_plan_weights,
|
||||
sources=sources,
|
||||
)
|
||||
|
||||
|
||||
def merge_query_overlay_into_target(
|
||||
base: PlanningTargetProfile,
|
||||
*,
|
||||
focus: Dict[int, float],
|
||||
style: Dict[int, float],
|
||||
tt: Dict[int, float],
|
||||
tg: Dict[int, float],
|
||||
skills: Dict[int, float],
|
||||
emphasis: str = "additive",
|
||||
scenario: str,
|
||||
) -> PlanningTargetProfile:
|
||||
sources = list(base.sources)
|
||||
if "query_intent" not in sources:
|
||||
sources.append("query_intent")
|
||||
|
||||
if emphasis == "replace" or scenario == SCENARIO_FREE_SEARCH:
|
||||
skill_w = _merge_weight_maps({}, skills, scale=1.0)
|
||||
if skills:
|
||||
skill_w = _normalize_weight_map(_merge_weight_maps(base.skill_weights, skills, scale=0.55))
|
||||
if emphasis == "replace":
|
||||
skill_w = _normalize_weight_map(skills)
|
||||
focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.5 if emphasis == "replace" else 0.85)
|
||||
style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.5)
|
||||
tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.5)
|
||||
tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.5)
|
||||
else:
|
||||
skill_scale = 1.0 if scenario == SCENARIO_ADDITIVE else 0.85
|
||||
skill_w = _merge_weight_maps(base.skill_weights, skills, scale=skill_scale)
|
||||
focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.9)
|
||||
style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.75)
|
||||
tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.75)
|
||||
tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.75)
|
||||
|
||||
out = PlanningTargetProfile(
|
||||
focus_area_ids=_normalize_weight_map(focus_w) if focus_w else focus_w,
|
||||
style_direction_ids=_normalize_weight_map(style_w) if style_w else style_w,
|
||||
training_type_ids=_normalize_weight_map(tt_w) if tt_w else tt_w,
|
||||
target_group_ids=_normalize_weight_map(tg_w) if tg_w else tg_w,
|
||||
skill_weights=_normalize_weight_map(skill_w) if skill_w else skill_w,
|
||||
skill_gap_weights=dict(base.skill_gap_weights),
|
||||
skill_plan_weights=dict(base.skill_plan_weights),
|
||||
sources=sources,
|
||||
)
|
||||
return _recalculate_skill_gap(out)
|
||||
|
||||
|
||||
def build_planning_target_with_query_pipeline(
|
||||
cur,
|
||||
*,
|
||||
unit: Dict[str, Any],
|
||||
planned_exercise_ids: List[int],
|
||||
section_planned_exercise_ids: Optional[List[int]] = None,
|
||||
anchor_exercise_id: Optional[int],
|
||||
query: Optional[str],
|
||||
heuristic_intent: str,
|
||||
include_llm_intent: bool,
|
||||
context_summary: Mapping[str, Any],
|
||||
has_planning_reference: bool = True,
|
||||
) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]:
|
||||
"""
|
||||
Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict
|
||||
|
||||
Ohne Planungsbezug (keine Übungen/Anker/Rahmen): Erwartungsprofil primär aus Suchtext (query_only).
|
||||
Mit Planungsbezug: hybrid aus Plan + optional Query-Overlay.
|
||||
"""
|
||||
scenario = classify_planning_scenario(query, heuristic_intent)
|
||||
resolved_intent = heuristic_intent
|
||||
llm_applied = False
|
||||
llm_expectation_applied = False
|
||||
parsed: Optional[PlanningQueryIntentParsed] = None
|
||||
expectation_parsed: Optional[PlanningQueryIntentParsed] = None
|
||||
resolved_skills: List[Dict[str, Any]] = []
|
||||
|
||||
if has_planning_reference:
|
||||
base = build_planning_target_profile(
|
||||
cur,
|
||||
unit=unit,
|
||||
planned_exercise_ids=planned_exercise_ids,
|
||||
section_planned_exercise_ids=section_planned_exercise_ids or [],
|
||||
anchor_exercise_id=anchor_exercise_id,
|
||||
intent=heuristic_intent,
|
||||
section_guidance_notes=(context_summary.get("section_guidance_notes") or None),
|
||||
section_title=(context_summary.get("section_title") or None),
|
||||
)
|
||||
else:
|
||||
base = PlanningTargetProfile(sources=["query_only"])
|
||||
|
||||
base_summary = base.to_summary_dict(cur)
|
||||
target = base
|
||||
|
||||
if should_run_llm_expectation_pipeline(
|
||||
scenario,
|
||||
include_llm_intent=include_llm_intent,
|
||||
has_planning_reference=has_planning_reference,
|
||||
):
|
||||
expectation_parsed, llm_expectation_applied = try_build_planning_expectation_from_context(
|
||||
cur,
|
||||
heuristic_intent=heuristic_intent,
|
||||
context_summary=context_summary,
|
||||
target_profile_summary=base_summary,
|
||||
)
|
||||
parsed = expectation_parsed
|
||||
if parsed and llm_expectation_applied:
|
||||
if parsed.intent in {
|
||||
"suggest_next",
|
||||
"progression_next",
|
||||
"deepen_exercise",
|
||||
"continue_plan_goal",
|
||||
"free_search",
|
||||
}:
|
||||
resolved_intent = parsed.intent
|
||||
focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
|
||||
if focus or style or tt or tg or skills or parsed.rationale:
|
||||
target = merge_query_overlay_into_target(
|
||||
base,
|
||||
focus=focus,
|
||||
style=style,
|
||||
tt=tt,
|
||||
tg=tg,
|
||||
skills=skills,
|
||||
emphasis=parsed.emphasis or "additive",
|
||||
scenario=SCENARIO_PRESET_NEXT,
|
||||
)
|
||||
if "context_expectation" not in target.sources:
|
||||
target.sources.append("context_expectation")
|
||||
elif should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent):
|
||||
parsed, llm_applied = try_parse_planning_query_intent(
|
||||
cur,
|
||||
query=_normalize_query(query),
|
||||
heuristic_intent=heuristic_intent,
|
||||
scenario_hint=scenario,
|
||||
context_summary=context_summary,
|
||||
target_profile_summary=base_summary,
|
||||
)
|
||||
|
||||
if parsed and llm_applied and not llm_expectation_applied:
|
||||
if parsed.intent in {
|
||||
"suggest_next",
|
||||
"progression_next",
|
||||
"deepen_exercise",
|
||||
"continue_plan_goal",
|
||||
"free_search",
|
||||
}:
|
||||
resolved_intent = parsed.intent
|
||||
if parsed.scenario in VALID_SCENARIOS_SET:
|
||||
scenario = parsed.scenario
|
||||
|
||||
focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
|
||||
if focus or style or tt or tg or skills:
|
||||
overlay_scenario = scenario
|
||||
overlay_emphasis = parsed.emphasis
|
||||
if not has_planning_reference:
|
||||
overlay_scenario = SCENARIO_FREE_SEARCH
|
||||
overlay_emphasis = "replace"
|
||||
target = merge_query_overlay_into_target(
|
||||
base,
|
||||
focus=focus,
|
||||
style=style,
|
||||
tt=tt,
|
||||
tg=tg,
|
||||
skills=skills,
|
||||
emphasis=overlay_emphasis,
|
||||
scenario=overlay_scenario,
|
||||
)
|
||||
elif not has_planning_reference and _normalize_query(query):
|
||||
# Kein LLM, aber Freitext: leichtes Profil bleibt leer — Retrieval nutzt Volltext
|
||||
target = PlanningTargetProfile(sources=["query_only"])
|
||||
|
||||
query_intent_summary: Dict[str, Any] = {
|
||||
"scenario": scenario,
|
||||
"intent": resolved_intent,
|
||||
"heuristic_intent": heuristic_intent,
|
||||
"llm_applied": llm_applied,
|
||||
"llm_expectation_applied": llm_expectation_applied,
|
||||
"profile_llm_applied": llm_applied or llm_expectation_applied,
|
||||
"emphasis": parsed.emphasis if parsed else None,
|
||||
"rationale": (parsed.rationale if parsed else None),
|
||||
"skill_hints_resolved": resolved_skills,
|
||||
"requires_partner": parsed.requires_partner if parsed else None,
|
||||
"expectation_mode": "planning_hybrid" if has_planning_reference else "query_only",
|
||||
}
|
||||
|
||||
return target, resolved_intent, scenario, query_intent_summary
|
||||
|
||||
|
||||
VALID_SCENARIOS_SET = {
|
||||
SCENARIO_PRESET_NEXT,
|
||||
SCENARIO_PROGRESSION,
|
||||
SCENARIO_DEEPEN,
|
||||
SCENARIO_CONTINUE_PLAN,
|
||||
SCENARIO_ADDITIVE,
|
||||
SCENARIO_FREE_SEARCH,
|
||||
}
|
||||
|
||||
|
||||
def compose_retrieval_phase(
|
||||
*,
|
||||
full_library: bool = False,
|
||||
profile_preselect: bool = False,
|
||||
text_signals: bool = False,
|
||||
query_intent: bool = False,
|
||||
llm_expectation: bool = False,
|
||||
llm_rank: bool = False,
|
||||
semantics: bool = False,
|
||||
) -> str:
|
||||
parts = ["profile_v1"]
|
||||
if full_library or profile_preselect:
|
||||
parts.append("full_library")
|
||||
if text_signals:
|
||||
parts.append("text_signals")
|
||||
if semantics:
|
||||
parts.append("semantics")
|
||||
if llm_expectation:
|
||||
parts.append("llm_expectation")
|
||||
elif query_intent:
|
||||
parts.append("query_intent")
|
||||
if llm_rank:
|
||||
parts.append("llm_rank")
|
||||
return "+".join(parts)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SCENARIO_ADDITIVE",
|
||||
"SCENARIO_PRESET_NEXT",
|
||||
"build_planning_target_with_query_pipeline",
|
||||
"classify_planning_scenario",
|
||||
"compose_retrieval_phase",
|
||||
"is_simple_preset_query",
|
||||
"merge_query_overlay_into_target",
|
||||
"should_run_llm_expectation_pipeline",
|
||||
"should_run_llm_intent_pipeline",
|
||||
"should_run_llm_rank_pipeline",
|
||||
"deterministic_rank_confident",
|
||||
"hybrid_ranking_ambiguous",
|
||||
]
|
||||
201
backend/planning_exercise_text_signals.py
Normal file
201
backend/planning_exercise_text_signals.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""
|
||||
Phase B: Deterministische Text→Katalog-Signale für PlanningTargetProfile.
|
||||
|
||||
Mappt Abschnitts-guidance, Rahmen-Ziele/-Notizen und Programmbeschreibung
|
||||
auf Skill-/Katalog-Gewichte (ohne LLM).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
_MIN_SKILL_NAME_LEN = 3
|
||||
_MAX_SKILL_MATCHES = 12
|
||||
_MAX_CATALOG_MATCHES = 6
|
||||
|
||||
|
||||
def _normalize_text_blob(*parts: Optional[str]) -> str:
|
||||
chunks: List[str] = []
|
||||
for p in parts:
|
||||
s = (p or "").strip()
|
||||
if s:
|
||||
chunks.append(s)
|
||||
return "\n".join(chunks).lower()
|
||||
|
||||
|
||||
def _load_skills_for_text_match(cur) -> List[Tuple[int, str, int]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name FROM skills
|
||||
WHERE (status IS NULL OR status = 'active')
|
||||
AND name IS NOT NULL AND TRIM(name) <> ''
|
||||
ORDER BY LENGTH(name) DESC, name ASC
|
||||
"""
|
||||
)
|
||||
out: List[Tuple[int, str, int]] = []
|
||||
for row in cur.fetchall():
|
||||
name = str(row.get("name") or "").strip()
|
||||
if len(name) < _MIN_SKILL_NAME_LEN:
|
||||
continue
|
||||
out.append((int(row["id"]), name.lower(), len(name)))
|
||||
return out
|
||||
|
||||
|
||||
def _load_catalog_names(cur, table: str, id_col: str = "id", name_col: str = "name") -> List[Tuple[int, str, int]]:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {id_col} AS id, {name_col} AS name
|
||||
FROM {table}
|
||||
WHERE {name_col} IS NOT NULL AND TRIM({name_col}) <> ''
|
||||
ORDER BY LENGTH({name_col}) DESC, {name_col} ASC
|
||||
"""
|
||||
)
|
||||
out: List[Tuple[int, str, int]] = []
|
||||
for row in cur.fetchall():
|
||||
name = str(row.get("name") or "").strip()
|
||||
if len(name) < 2:
|
||||
continue
|
||||
out.append((int(row["id"]), name.lower(), len(name)))
|
||||
return out
|
||||
|
||||
|
||||
def _match_catalog_names_in_text(
|
||||
text: str,
|
||||
catalog_rows: Sequence[Tuple[int, str, int]],
|
||||
*,
|
||||
weight: float = 0.85,
|
||||
limit: int = _MAX_CATALOG_MATCHES,
|
||||
) -> Dict[int, float]:
|
||||
if not text or not catalog_rows:
|
||||
return {}
|
||||
out: Dict[int, float] = {}
|
||||
for cid, name_lower, _ in catalog_rows:
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if len(name_lower) < 2:
|
||||
continue
|
||||
if name_lower in text:
|
||||
out[cid] = max(out.get(cid, 0.0), weight)
|
||||
return out
|
||||
|
||||
|
||||
def _match_skills_in_text(
|
||||
text: str,
|
||||
skill_rows: Sequence[Tuple[int, str, int]],
|
||||
*,
|
||||
limit: int = _MAX_SKILL_MATCHES,
|
||||
) -> Dict[int, float]:
|
||||
if not text or not skill_rows:
|
||||
return {}
|
||||
out: Dict[int, float] = {}
|
||||
for sid, name_lower, name_len in skill_rows:
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if name_len < _MIN_SKILL_NAME_LEN:
|
||||
continue
|
||||
if name_lower in text:
|
||||
w = min(1.0, 0.72 + min(name_len, 20) * 0.012)
|
||||
out[sid] = max(out.get(sid, 0.0), w)
|
||||
return out
|
||||
|
||||
|
||||
def load_framework_planning_text_parts(
|
||||
cur,
|
||||
framework_program_id: int,
|
||||
*,
|
||||
slot_id: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
"""Sammelt Rahmen-Texte für Text-Signal-Matching."""
|
||||
parts: List[str] = []
|
||||
cur.execute(
|
||||
"SELECT description FROM training_framework_programs WHERE id = %s",
|
||||
(int(framework_program_id),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and (row.get("description") or "").strip():
|
||||
parts.append(str(row["description"]).strip())
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT title, notes FROM training_framework_goals
|
||||
WHERE framework_program_id = %s
|
||||
ORDER BY sort_order ASC
|
||||
""",
|
||||
(int(framework_program_id),),
|
||||
)
|
||||
for g in cur.fetchall():
|
||||
t = (g.get("title") or "").strip()
|
||||
n = (g.get("notes") or "").strip()
|
||||
if t:
|
||||
parts.append(t)
|
||||
if n:
|
||||
parts.append(n)
|
||||
|
||||
if slot_id:
|
||||
cur.execute(
|
||||
"SELECT title, notes FROM training_framework_slots WHERE id = %s",
|
||||
(int(slot_id),),
|
||||
)
|
||||
srow = cur.fetchone()
|
||||
if srow:
|
||||
st = (srow.get("title") or "").strip()
|
||||
sn = (srow.get("notes") or "").strip()
|
||||
if st:
|
||||
parts.append(st)
|
||||
if sn:
|
||||
parts.append(sn)
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def resolve_planning_text_to_catalog_weights(
|
||||
cur,
|
||||
text_blob: str,
|
||||
) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float]]:
|
||||
"""
|
||||
Returns: focus, style, training_type, target_group, skill weight maps.
|
||||
"""
|
||||
text = _normalize_text_blob(text_blob)
|
||||
if not text or len(text) < 3:
|
||||
return {}, {}, {}, {}, {}
|
||||
|
||||
skill_rows = _load_skills_for_text_match(cur)
|
||||
focus_rows = _load_catalog_names(cur, "focus_areas")
|
||||
style_rows = _load_catalog_names(cur, "style_directions")
|
||||
tt_rows = _load_catalog_names(cur, "training_types")
|
||||
tg_rows = _load_catalog_names(cur, "target_groups")
|
||||
|
||||
skills = _match_skills_in_text(text, skill_rows)
|
||||
focus = _match_catalog_names_in_text(text, focus_rows, weight=0.88)
|
||||
style = _match_catalog_names_in_text(text, style_rows, weight=0.82)
|
||||
tt = _match_catalog_names_in_text(text, tt_rows, weight=0.82)
|
||||
tg = _match_catalog_names_in_text(text, tg_rows, weight=0.8)
|
||||
|
||||
if re.search(r"\bpartner\b|\bpaar\b|\bpaarweise\b|\bzu zweit\b", text):
|
||||
for gid, name_lower, _ in tg_rows:
|
||||
if "partner" in name_lower or "paar" in name_lower:
|
||||
tg[gid] = max(tg.get(gid, 0.0), 0.9)
|
||||
break
|
||||
|
||||
return focus, style, tt, tg, skills
|
||||
|
||||
|
||||
def merge_text_signal_summary(
|
||||
summary: Mapping[str, Any],
|
||||
*,
|
||||
text_sources: Sequence[str],
|
||||
matched_skills: Sequence[Mapping[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
out = dict(summary)
|
||||
if text_sources:
|
||||
out["text_signal_sources"] = list(text_sources)
|
||||
if matched_skills:
|
||||
out["text_signal_skills"] = list(matched_skills)[:8]
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"load_framework_planning_text_parts",
|
||||
"merge_text_signal_summary",
|
||||
"resolve_planning_text_to_catalog_weights",
|
||||
]
|
||||
248
backend/planning_intent_context.py
Normal file
248
backend/planning_intent_context.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""
|
||||
Gemeinsame Intent-Anreicherung für Planungs-Retrieval.
|
||||
|
||||
Progressionsgraph (Roadmap stage_specs) und später Trainingsplanung
|
||||
(Abschnitt/Slot) nutzen dieselben Bausteine:
|
||||
Intent-Kontext bauen → Specs finalisieren → Matching-Gates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
from planning_exercise_semantics import (
|
||||
PlanningSemanticBrief,
|
||||
resolve_path_anti_patterns,
|
||||
technique_sibling_excludes,
|
||||
)
|
||||
|
||||
_NEGATION_CLAUSE_RE = re.compile(
|
||||
r"\b(?:ohne|kein(?:e|en|er|em)?|nicht)\s+[^,.;\n]+",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_explicit_exclusions(*texts: Optional[str]) -> List[str]:
|
||||
"""Lesbare Negationsklauseln aus Freitext (ohne Themen-Raten)."""
|
||||
out: List[str] = []
|
||||
for raw in texts:
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
for m in _NEGATION_CLAUSE_RE.finditer(s):
|
||||
clause = m.group(0).strip().rstrip(".,;")
|
||||
if clause and clause.lower() not in {x.lower() for x in out}:
|
||||
out.append(clause[:220])
|
||||
return out[:12]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanningIntentContext:
|
||||
"""Pfad-/Abschnittsweiter Planungs-Intent — domänenneutral."""
|
||||
|
||||
source_query: str = ""
|
||||
primary_topic: str = ""
|
||||
path_anti_patterns: List[str] = field(default_factory=list)
|
||||
path_success_criteria: List[str] = field(default_factory=list)
|
||||
explicit_exclusions: List[str] = field(default_factory=list)
|
||||
context_notes: str = ""
|
||||
topic_type: str = "general"
|
||||
technique_sibling_excludes: List[str] = field(default_factory=list)
|
||||
|
||||
def to_api_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"source_query": self.source_query,
|
||||
"primary_topic": self.primary_topic,
|
||||
"topic_type": self.topic_type,
|
||||
"path_anti_patterns": self.path_anti_patterns[:16],
|
||||
"path_success_criteria": self.path_success_criteria[:10],
|
||||
"explicit_exclusions": self.explicit_exclusions[:10],
|
||||
"technique_sibling_excludes": self.technique_sibling_excludes[:16],
|
||||
"context_notes": self.context_notes[:1200] or None,
|
||||
}
|
||||
|
||||
|
||||
def build_planning_intent_context(
|
||||
goal_query: str,
|
||||
*,
|
||||
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
||||
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||
extra_context: Optional[str] = None,
|
||||
primary_topic: Optional[str] = None,
|
||||
) -> PlanningIntentContext:
|
||||
"""Intent aus Anfrage, Zielanalyse und optionalem Kontext — ohne Sonderregeln pro Thema."""
|
||||
ga = dict(goal_analysis or {})
|
||||
notes_parts = [extra_context or ""]
|
||||
constraints = ga.get("constraints") if isinstance(ga.get("constraints"), dict) else {}
|
||||
if isinstance(constraints, dict):
|
||||
trainer_notes = str(constraints.get("trainer_notes") or "").strip()
|
||||
if trainer_notes:
|
||||
notes_parts.append(trainer_notes)
|
||||
|
||||
combined_notes = " ".join(p.strip() for p in notes_parts if p and p.strip())
|
||||
explicit = extract_explicit_exclusions(goal_query, combined_notes or None)
|
||||
ga_excluded = constraints.get("excluded_themes") if isinstance(constraints, dict) else None
|
||||
if isinstance(ga_excluded, list):
|
||||
for item in ga_excluded:
|
||||
s = str(item or "").strip()
|
||||
if s and s.lower() not in {x.lower() for x in explicit}:
|
||||
explicit.append(s[:220])
|
||||
|
||||
path_anti = resolve_path_anti_patterns(
|
||||
goal_query,
|
||||
semantic_brief=semantic_brief,
|
||||
extra_context=combined_notes or None,
|
||||
)
|
||||
path_success: List[str] = []
|
||||
for item in ga.get("success_criteria") or []:
|
||||
s = str(item or "").strip()
|
||||
if s and s not in path_success:
|
||||
path_success.append(s[:240])
|
||||
target = str(ga.get("target_state") or "").strip()
|
||||
if target and len(target) >= 8:
|
||||
line = f"Zielzustand erreichbar: {target[:200]}"
|
||||
if line not in path_success:
|
||||
path_success.append(line)
|
||||
|
||||
topic = (primary_topic or ga.get("primary_topic") or "").strip()
|
||||
topic_type = "general"
|
||||
siblings: List[str] = []
|
||||
if semantic_brief:
|
||||
if not topic:
|
||||
topic = (semantic_brief.primary_topic or "").strip()
|
||||
topic_type = (semantic_brief.topic_type or "general").strip().lower()
|
||||
if topic_type == "technique" and topic:
|
||||
siblings = technique_sibling_excludes(topic)
|
||||
for raw in semantic_brief.exclude_phrases or []:
|
||||
s = str(raw or "").strip()
|
||||
if s and s.lower() not in {x.lower() for x in siblings}:
|
||||
siblings.append(s[:120])
|
||||
|
||||
if topic_type == "technique" and topic:
|
||||
line = f"Haupttechnik {topic} in Kurzbeschreibung oder Übungsziel erkennbar"
|
||||
if line not in path_success:
|
||||
path_success.insert(0, line)
|
||||
|
||||
return PlanningIntentContext(
|
||||
source_query=(goal_query or "").strip(),
|
||||
primary_topic=topic,
|
||||
topic_type=topic_type,
|
||||
path_anti_patterns=path_anti,
|
||||
path_success_criteria=path_success,
|
||||
explicit_exclusions=explicit,
|
||||
technique_sibling_excludes=siblings[:16],
|
||||
context_notes=combined_notes[:1200],
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_preserve(items: Sequence[str], *, limit: int = 14) -> List[str]:
|
||||
out: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in items:
|
||||
s = str(raw or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
key = s.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(s[:240])
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def finalize_stage_spec_artifact(
|
||||
spec: "StageSpecArtifact",
|
||||
*,
|
||||
major_step: Optional["MajorStep"] = None,
|
||||
intent: PlanningIntentContext,
|
||||
) -> "StageSpecArtifact":
|
||||
"""Pfad-Intent in eine Stufenspezifikation mergen (LLM oder heuristisch)."""
|
||||
from planning_progression_roadmap import MajorStep, StageSpecArtifact
|
||||
|
||||
learning_goal = (spec.learning_goal or (major_step.learning_goal if major_step else "")).strip()
|
||||
phase = (major_step.phase if major_step else "").strip().lower()
|
||||
|
||||
anti = _dedupe_preserve(
|
||||
[
|
||||
*(spec.anti_patterns or []),
|
||||
*intent.explicit_exclusions,
|
||||
*intent.path_anti_patterns,
|
||||
*intent.technique_sibling_excludes,
|
||||
(
|
||||
f"andere Technik als {intent.primary_topic}"
|
||||
if intent.topic_type == "technique" and intent.primary_topic
|
||||
else ""
|
||||
),
|
||||
],
|
||||
limit=14,
|
||||
)
|
||||
stage_start = (spec.start_state or "").strip()
|
||||
stage_target = (spec.target_state or "").strip()
|
||||
success = _dedupe_preserve(
|
||||
[
|
||||
*(spec.success_criteria or []),
|
||||
*intent.path_success_criteria,
|
||||
(f"Soll-Start der Stufe erreichbar: {stage_start[:180]}" if stage_start else ""),
|
||||
(f"Stufen-Ziel erreichbar: {stage_target[:180]}" if stage_target else ""),
|
||||
(
|
||||
f"Übung liefert messbar: {learning_goal[:160]}"
|
||||
if learning_goal
|
||||
else ""
|
||||
),
|
||||
(
|
||||
f"Kurzbeschreibung und Übungsziel passen zur Phase {phase}"
|
||||
if phase
|
||||
else "Kurzbeschreibung und Übungsziel passen zum Stufen-Lernziel"
|
||||
),
|
||||
],
|
||||
limit=8,
|
||||
)
|
||||
|
||||
idx = spec.major_step_index
|
||||
if major_step is not None:
|
||||
idx = major_step.index
|
||||
|
||||
return StageSpecArtifact(
|
||||
major_step_index=idx,
|
||||
learning_goal=learning_goal,
|
||||
load_profile=list(spec.load_profile or []),
|
||||
exercise_type=(spec.exercise_type or "").strip(),
|
||||
success_criteria=success,
|
||||
anti_patterns=anti,
|
||||
)
|
||||
|
||||
|
||||
def finalize_stage_specs_with_intent(
|
||||
specs: Sequence["StageSpecArtifact"],
|
||||
major_steps: Sequence["MajorStep"],
|
||||
*,
|
||||
intent: PlanningIntentContext,
|
||||
fallback_specs: Optional[Sequence["StageSpecArtifact"]] = None,
|
||||
) -> List["StageSpecArtifact"]:
|
||||
"""Alle Stufen mit gleichem Pfad-Intent anreichern; fehlende Indizes aus Fallback."""
|
||||
from planning_progression_roadmap import MajorStep, StageSpecArtifact
|
||||
|
||||
by_idx = {int(s.major_step_index): s for s in specs}
|
||||
fallback_by_idx = {int(s.major_step_index): s for s in (fallback_specs or [])}
|
||||
out: List[StageSpecArtifact] = []
|
||||
for major in major_steps:
|
||||
raw = by_idx.get(major.index) or fallback_by_idx.get(major.index)
|
||||
if raw is None:
|
||||
raw = StageSpecArtifact(
|
||||
major_step_index=major.index,
|
||||
learning_goal=major.learning_goal,
|
||||
)
|
||||
out.append(finalize_stage_spec_artifact(raw, major_step=major, intent=intent))
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PlanningIntentContext",
|
||||
"build_planning_intent_context",
|
||||
"extract_explicit_exclusions",
|
||||
"finalize_stage_spec_artifact",
|
||||
"finalize_stage_specs_with_intent",
|
||||
]
|
||||
176
backend/planning_path_qa_pipeline.py
Normal file
176
backend/planning_path_qa_pipeline.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""
|
||||
Mehrstufige Pfad-QS — Findings pro Stufe, daraus Optimierungspotenziale ableiten.
|
||||
|
||||
Stufen (allgemein, domänenneutral):
|
||||
1. deterministische Gates (Technik-Scope, Ausschlüsse, Stufen-Fit)
|
||||
2. Übergangs-Kohärenz (Lücken zwischen Schritten)
|
||||
3. LLM-Ganzpfad-Bewertung (Empfehlungen, keine Auto-Patches)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
|
||||
_ACTION_BY_ISSUE: Dict[str, str] = {
|
||||
"technique_scope": "rematch_slot",
|
||||
"path_exclude": "rematch_slot",
|
||||
"stage_mismatch": "refine_stage_spec",
|
||||
"off_topic": "rematch_slot",
|
||||
"gap": "bridge_or_gap_fill",
|
||||
"large_gap": "bridge_or_gap_fill",
|
||||
"roadmap_unfilled": "rematch_slot",
|
||||
}
|
||||
|
||||
|
||||
def _action_for_finding(finding: Mapping[str, Any]) -> str:
|
||||
issue = str(finding.get("issue") or finding.get("type") or "").strip().lower()
|
||||
if finding.get("is_large_gap"):
|
||||
return "bridge_or_gap_fill"
|
||||
return _ACTION_BY_ISSUE.get(issue, "review")
|
||||
|
||||
|
||||
def _hint_from_finding(finding: Mapping[str, Any], *, tier: str) -> Dict[str, Any]:
|
||||
step_index = finding.get("step_index")
|
||||
if step_index is None:
|
||||
step_index = finding.get("major_step_index")
|
||||
issue = str(finding.get("issue") or finding.get("type") or tier)
|
||||
action = _action_for_finding(finding)
|
||||
title = str(finding.get("title") or finding.get("removed_title") or "").strip()
|
||||
reasons = finding.get("reasons") or []
|
||||
reason = reasons[0] if reasons else str(finding.get("rationale") or finding.get("detail") or "")
|
||||
|
||||
hint: Dict[str, Any] = {
|
||||
"tier": tier,
|
||||
"action": action,
|
||||
"issue": issue,
|
||||
"step_index": step_index,
|
||||
"title": title or None,
|
||||
"reason": (reason or "")[:400] or None,
|
||||
}
|
||||
if finding.get("roadmap_learning_goal"):
|
||||
hint["roadmap_learning_goal"] = finding.get("roadmap_learning_goal")
|
||||
if finding.get("roadmap_major_step_index") is not None:
|
||||
hint["roadmap_major_step_index"] = finding.get("roadmap_major_step_index")
|
||||
return {k: v for k, v in hint.items() if v is not None and v != ""}
|
||||
|
||||
|
||||
def derive_optimization_hints(
|
||||
tiers: Sequence[Mapping[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Aus QS-Stufen konkrete Optimierungsaktionen (ohne anfrage-spezifische Heuristiken)."""
|
||||
hints: List[Dict[str, Any]] = []
|
||||
seen: set[tuple] = set()
|
||||
for tier in tiers:
|
||||
tier_id = str(tier.get("id") or "")
|
||||
for finding in tier.get("findings") or []:
|
||||
if not isinstance(finding, dict):
|
||||
continue
|
||||
hint = _hint_from_finding(finding, tier=tier_id)
|
||||
key = (
|
||||
hint.get("tier"),
|
||||
hint.get("action"),
|
||||
hint.get("step_index"),
|
||||
hint.get("issue"),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
hints.append(hint)
|
||||
return hints[:24]
|
||||
|
||||
|
||||
def run_multistage_path_qa(
|
||||
*,
|
||||
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||
stripped_off_topic: Sequence[Mapping[str, Any]],
|
||||
gaps: Sequence[Mapping[str, Any]],
|
||||
llm_qa: Optional[Mapping[str, Any]] = None,
|
||||
llm_applied: bool = False,
|
||||
roadmap_unfilled: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Orchestriert QS-Stufen und leitet Optimierungspotenziale ab."""
|
||||
tier1_findings: List[Dict[str, Any]] = []
|
||||
for item in stripped_off_topic or off_topic_steps or []:
|
||||
if isinstance(item, dict):
|
||||
tier1_findings.append(dict(item))
|
||||
|
||||
tier2_findings: List[Dict[str, Any]] = [dict(g) for g in gaps if isinstance(g, dict)]
|
||||
|
||||
tier3_findings: List[Dict[str, Any]] = []
|
||||
llm_recommendations: List[str] = []
|
||||
if llm_applied and llm_qa:
|
||||
q_score = llm_qa.get("quality_score")
|
||||
tier3_findings.append(
|
||||
{
|
||||
"issue": "llm_assessment",
|
||||
"quality_score": q_score,
|
||||
"overall_ok": llm_qa.get("overall_ok"),
|
||||
"detail": llm_qa.get("summary") or llm_qa.get("assessment"),
|
||||
}
|
||||
)
|
||||
for raw in llm_qa.get("recommendations") or llm_qa.get("suggestions") or []:
|
||||
s = str(raw or "").strip()
|
||||
if s:
|
||||
llm_recommendations.append(s[:500])
|
||||
|
||||
unfilled = list(roadmap_unfilled or [])
|
||||
if unfilled:
|
||||
for item in unfilled:
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
idx, spec = item[0], item[1]
|
||||
tier1_findings.append(
|
||||
{
|
||||
"issue": "roadmap_unfilled",
|
||||
"step_index": int(idx),
|
||||
"roadmap_major_step_index": getattr(spec, "major_step_index", idx),
|
||||
"roadmap_learning_goal": getattr(spec, "learning_goal", None),
|
||||
"reasons": ["Keine passende Übung für Roadmap-Stufe"],
|
||||
}
|
||||
)
|
||||
elif isinstance(item, dict):
|
||||
tier1_findings.append({**item, "issue": item.get("issue") or "roadmap_unfilled"})
|
||||
|
||||
tiers: List[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "tier1_deterministic",
|
||||
"label": "Deterministische Gates",
|
||||
"finding_count": len(tier1_findings),
|
||||
"findings": tier1_findings[:16],
|
||||
},
|
||||
{
|
||||
"id": "tier2_transitions",
|
||||
"label": "Übergangs-Kohärenz",
|
||||
"finding_count": len(tier2_findings),
|
||||
"findings": tier2_findings[:12],
|
||||
},
|
||||
{
|
||||
"id": "tier3_llm_holistic",
|
||||
"label": "LLM-Ganzpfad",
|
||||
"finding_count": len(tier3_findings),
|
||||
"findings": tier3_findings,
|
||||
"recommendations": llm_recommendations[:8],
|
||||
"applied": bool(llm_applied),
|
||||
},
|
||||
]
|
||||
optimization_hints = derive_optimization_hints(tiers)
|
||||
for rec in llm_recommendations[:5]:
|
||||
optimization_hints.append(
|
||||
{
|
||||
"tier": "tier3_llm_holistic",
|
||||
"action": "review_roadmap",
|
||||
"issue": "llm_recommendation",
|
||||
"reason": rec,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"qa_tiers": tiers,
|
||||
"optimization_hints": optimization_hints[:28],
|
||||
"optimization_hint_count": len(optimization_hints),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"derive_optimization_hints",
|
||||
"run_multistage_path_qa",
|
||||
]
|
||||
222
backend/planning_path_refine_stage.py
Normal file
222
backend/planning_path_refine_stage.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"""
|
||||
Phase C: Stufen-Spec verfeinern nach stage_mismatch, dann Rematch.
|
||||
|
||||
Deterministisch — keine LLM-Ratelosigkeit. Schärft anti_patterns / success_criteria
|
||||
aus QS-Finding, schließt abgelehnte Übung aus.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from planning_exercise_semantics import (
|
||||
is_trainer_stage_anti_marker,
|
||||
merge_stage_exclude_phrases,
|
||||
parse_stage_goal_constraints,
|
||||
stage_refinement_criteria_from_learning_goal,
|
||||
)
|
||||
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
|
||||
|
||||
|
||||
def _resolve_major_index(
|
||||
item: Mapping[str, Any],
|
||||
stage_specs: Sequence[StageSpecArtifact],
|
||||
) -> Optional[int]:
|
||||
raw = item.get("roadmap_major_step_index")
|
||||
if raw is not None:
|
||||
return int(raw)
|
||||
si = item.get("step_index")
|
||||
if si is not None:
|
||||
pos = int(si)
|
||||
specs = list(stage_specs or [])
|
||||
if 0 <= pos < len(specs):
|
||||
return int(specs[pos].major_step_index)
|
||||
return None
|
||||
|
||||
|
||||
def collect_refine_stage_targets(
|
||||
*,
|
||||
optimization_hints: Sequence[Mapping[str, Any]],
|
||||
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||
stage_specs: Sequence[StageSpecArtifact],
|
||||
) -> Dict[int, Mapping[str, Any]]:
|
||||
"""Major-Step-Indizes mit stage_mismatch / refine_stage_spec + Quell-Finding."""
|
||||
targets: Dict[int, Mapping[str, Any]] = {}
|
||||
|
||||
def _register(midx: int, source: Mapping[str, Any]) -> None:
|
||||
if midx not in targets:
|
||||
targets[int(midx)] = dict(source)
|
||||
|
||||
for hint in optimization_hints or []:
|
||||
if not isinstance(hint, dict):
|
||||
continue
|
||||
if str(hint.get("action") or "") != "refine_stage_spec":
|
||||
continue
|
||||
midx = _resolve_major_index(hint, stage_specs)
|
||||
if midx is not None:
|
||||
_register(midx, hint)
|
||||
|
||||
for item in off_topic_steps or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("issue") or "") != "stage_mismatch":
|
||||
continue
|
||||
midx = _resolve_major_index(item, stage_specs)
|
||||
if midx is not None:
|
||||
_register(midx, item)
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def _append_unique_strings(dest: List[str], items: Sequence[str], *, limit: int = 14) -> List[str]:
|
||||
out = list(dest or [])
|
||||
for raw in items:
|
||||
s = str(raw or "").strip()
|
||||
if not s or s in out:
|
||||
continue
|
||||
out.append(s[:200])
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _rejected_exercise_marker(title: str) -> str:
|
||||
return f"keine Übung wie „{title[:120]}“"
|
||||
|
||||
|
||||
def refine_stage_spec_artifact(
|
||||
spec: StageSpecArtifact,
|
||||
*,
|
||||
finding: Mapping[str, Any],
|
||||
goal_query: str = "",
|
||||
semantic_brief: Optional[Any] = None,
|
||||
path_anti_patterns: Optional[Sequence[str]] = None,
|
||||
) -> Tuple[StageSpecArtifact, List[str]]:
|
||||
"""
|
||||
Schärft eine StageSpec aus QS-Finding. Returns (neue Spec, Änderungsliste).
|
||||
|
||||
Pfad-Ausschlüsse werden beim Match separat gemerged — nicht in stage_spec duplizieren.
|
||||
"""
|
||||
del goal_query, semantic_brief, path_anti_patterns
|
||||
learning_goal = (
|
||||
str(finding.get("roadmap_learning_goal") or spec.learning_goal or "").strip()
|
||||
or spec.learning_goal
|
||||
)
|
||||
anti = [a for a in list(spec.anti_patterns or []) if not is_trainer_stage_anti_marker(a)]
|
||||
success = list(spec.success_criteria or [])
|
||||
changes: List[str] = []
|
||||
|
||||
rejected_title = str(finding.get("title") or "").strip()
|
||||
if rejected_title:
|
||||
marker = _rejected_exercise_marker(rejected_title)
|
||||
if marker not in anti:
|
||||
anti.append(marker)
|
||||
changes.append(f"Ausschluss abgelehnter Übung: {rejected_title[:80]}")
|
||||
|
||||
goal_excludes = parse_stage_goal_constraints(learning_goal).exclude_phrases
|
||||
for phrase in goal_excludes or []:
|
||||
if phrase and phrase not in anti:
|
||||
anti.append(phrase)
|
||||
changes.append(f"Ausschluss aus Lernziel: {phrase[:60]}")
|
||||
|
||||
for phrase in stage_refinement_criteria_from_learning_goal(learning_goal):
|
||||
crit = f"Bezug zu Stufen-Lernziel: {phrase[:100]}"
|
||||
if crit not in success:
|
||||
success.append(crit)
|
||||
changes.append(f"Erfolgskriterium: {phrase[:60]}")
|
||||
|
||||
for raw in finding.get("reasons") or []:
|
||||
r = str(raw or "").strip()
|
||||
if len(r) < 8:
|
||||
continue
|
||||
if r == "Kern-Thema der Anfrage im Übungstext":
|
||||
continue
|
||||
crit = f"QS-Hinweis: {r[:120]}"
|
||||
if crit not in success:
|
||||
success.append(crit)
|
||||
if len(changes) < 6:
|
||||
changes.append(f"Kriterium aus QS: {r[:60]}")
|
||||
if len(success) >= 8:
|
||||
break
|
||||
|
||||
if not changes:
|
||||
return spec, []
|
||||
|
||||
refined = StageSpecArtifact(
|
||||
major_step_index=spec.major_step_index,
|
||||
learning_goal=learning_goal or spec.learning_goal,
|
||||
start_state=spec.start_state,
|
||||
target_state=spec.target_state,
|
||||
load_profile=list(spec.load_profile or []),
|
||||
exercise_type=spec.exercise_type,
|
||||
success_criteria=success[:8],
|
||||
anti_patterns=merge_stage_exclude_phrases(learning_goal, anti)[:14],
|
||||
)
|
||||
return refined, changes
|
||||
|
||||
|
||||
def apply_stage_spec_refinements(
|
||||
roadmap_ctx: ProgressionRoadmapContext,
|
||||
*,
|
||||
optimization_hints: Sequence[Mapping[str, Any]],
|
||||
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||
goal_query: str,
|
||||
semantic_brief: Optional[Any] = None,
|
||||
) -> Tuple[List[StageSpecArtifact], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Wendet refine_stage_spec auf betroffene Slots an (mutiert stage_specs in ctx).
|
||||
|
||||
Returns: (stage_specs, refine_log)
|
||||
"""
|
||||
del goal_query, semantic_brief
|
||||
stage_specs = list(roadmap_ctx.stage_specs or [])
|
||||
if not stage_specs:
|
||||
return stage_specs, []
|
||||
|
||||
targets = collect_refine_stage_targets(
|
||||
optimization_hints=optimization_hints,
|
||||
off_topic_steps=off_topic_steps,
|
||||
stage_specs=stage_specs,
|
||||
)
|
||||
if not targets:
|
||||
return stage_specs, []
|
||||
|
||||
spec_by_major = {int(s.major_step_index): s for s in stage_specs}
|
||||
refine_log: List[Dict[str, Any]] = []
|
||||
|
||||
for midx in sorted(targets):
|
||||
spec = spec_by_major.get(int(midx))
|
||||
if spec is None:
|
||||
continue
|
||||
refined_spec, changes = refine_stage_spec_artifact(
|
||||
spec,
|
||||
finding=targets[midx],
|
||||
)
|
||||
if not changes:
|
||||
continue
|
||||
spec_by_major[int(midx)] = refined_spec
|
||||
rejected_id = targets[midx].get("exercise_id")
|
||||
refine_log.append(
|
||||
{
|
||||
"roadmap_major_step_index": int(midx),
|
||||
"action": "refined",
|
||||
"issue": "stage_mismatch",
|
||||
"rejected_title": targets[midx].get("title"),
|
||||
"rejected_exercise_id": int(rejected_id) if rejected_id else None,
|
||||
"changes": changes[:6],
|
||||
"reason": (changes[0] if changes else "refine_stage_spec")[:400],
|
||||
}
|
||||
)
|
||||
|
||||
if not refine_log:
|
||||
return stage_specs, []
|
||||
|
||||
ordered = [spec_by_major[int(s.major_step_index)] for s in stage_specs]
|
||||
roadmap_ctx.stage_specs = ordered
|
||||
return ordered, refine_log
|
||||
|
||||
|
||||
__all__ = [
|
||||
"apply_stage_spec_refinements",
|
||||
"collect_refine_stage_targets",
|
||||
"refine_stage_spec_artifact",
|
||||
]
|
||||
379
backend/planning_path_rematch.py
Normal file
379
backend/planning_path_rematch.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
"""
|
||||
Auto-Rematch nach Pfad-QS — betroffene Roadmap-Slots erneut matchen (Phase A/B).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
|
||||
|
||||
|
||||
def _slot_priority_for_rematch(
|
||||
body,
|
||||
*,
|
||||
major_idx: int,
|
||||
old: Optional[Mapping[str, Any]],
|
||||
rejected_by_major: Optional[Mapping[int, Set[int]]],
|
||||
) -> Optional[int]:
|
||||
"""Bestehende Slot-Zuordnung beim Rematch bevorzugen — außer explizit abgelehnt."""
|
||||
priority_id: Optional[int] = None
|
||||
if body is not None:
|
||||
for raw in getattr(body, "slot_assignments", None) or []:
|
||||
midx = getattr(raw, "roadmap_major_step_index", None)
|
||||
if midx is None or int(midx) != int(major_idx):
|
||||
continue
|
||||
eid = getattr(raw, "exercise_id", None)
|
||||
if eid is not None:
|
||||
try:
|
||||
priority_id = int(eid)
|
||||
except (TypeError, ValueError):
|
||||
priority_id = None
|
||||
break
|
||||
if priority_id is None and old and old.get("exercise_id") is not None:
|
||||
try:
|
||||
priority_id = int(old["exercise_id"])
|
||||
except (TypeError, ValueError):
|
||||
priority_id = None
|
||||
if priority_id is None or priority_id < 1:
|
||||
return None
|
||||
rejected = rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
|
||||
if priority_id in rejected:
|
||||
return None
|
||||
return priority_id
|
||||
|
||||
|
||||
def collect_rematch_slot_indices(
|
||||
*,
|
||||
stripped_off_topic: Sequence[Mapping[str, Any]],
|
||||
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||
optimization_hints: Sequence[Mapping[str, Any]],
|
||||
stage_specs: Sequence[StageSpecArtifact],
|
||||
roadmap_unfilled: Optional[Sequence[Any]] = None,
|
||||
) -> Tuple[Set[int], Dict[int, str]]:
|
||||
"""Major-Step-Indizes für rematch_slot + Begründung pro Slot."""
|
||||
spec_by_pos = list(stage_specs)
|
||||
indices: Set[int] = set()
|
||||
reasons: Dict[int, str] = {}
|
||||
|
||||
def _register(midx: int, reason: str) -> None:
|
||||
indices.add(int(midx))
|
||||
if midx not in reasons and reason:
|
||||
reasons[int(midx)] = reason[:400]
|
||||
|
||||
def _resolve_major(item: Mapping[str, Any]) -> Optional[int]:
|
||||
raw = item.get("roadmap_major_step_index")
|
||||
if raw is not None:
|
||||
return int(raw)
|
||||
si = item.get("step_index")
|
||||
if si is not None:
|
||||
pos = int(si)
|
||||
if 0 <= pos < len(spec_by_pos):
|
||||
return int(spec_by_pos[pos].major_step_index)
|
||||
return None
|
||||
|
||||
for item in stripped_off_topic or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
midx = _resolve_major(item)
|
||||
if midx is not None:
|
||||
issue = str(item.get("issue") or "stripped_off_topic")
|
||||
r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
|
||||
_register(midx, str(r))
|
||||
|
||||
for item in off_topic_steps or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
midx = _resolve_major(item)
|
||||
if midx is None:
|
||||
continue
|
||||
issue = str(item.get("issue") or "off_topic")
|
||||
r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
|
||||
_register(midx, str(r))
|
||||
|
||||
for hint in optimization_hints or []:
|
||||
if not isinstance(hint, dict):
|
||||
continue
|
||||
if str(hint.get("action") or "") != "rematch_slot":
|
||||
continue
|
||||
midx = _resolve_major(hint)
|
||||
if midx is not None:
|
||||
_register(midx, str(hint.get("reason") or hint.get("issue") or "rematch_slot"))
|
||||
|
||||
for item in roadmap_unfilled or []:
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
idx, spec = item[0], item[1]
|
||||
midx = getattr(spec, "major_step_index", idx)
|
||||
_register(int(midx), "Keine passende Übung für Roadmap-Stufe")
|
||||
elif isinstance(item, dict):
|
||||
midx = _resolve_major(item)
|
||||
if midx is not None:
|
||||
issue = str(item.get("issue") or "roadmap_unfilled")
|
||||
r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
|
||||
_register(midx, str(r))
|
||||
|
||||
return indices, reasons
|
||||
|
||||
|
||||
def filter_rematch_slot_indices(
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
slot_indices: Set[int],
|
||||
*,
|
||||
stripped_off_topic: Sequence[Mapping[str, Any]],
|
||||
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||
) -> Set[int]:
|
||||
"""Trainer-Zuordnungen (slot_best_match) nicht rematchen, außer Slot ist explizit beanstandet."""
|
||||
flagged: Set[int] = set()
|
||||
for item in list(stripped_off_topic or []) + list(off_topic_steps or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
midx = item.get("roadmap_major_step_index")
|
||||
if midx is not None:
|
||||
try:
|
||||
flagged.add(int(midx))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
preserved: Set[int] = set()
|
||||
for raw in steps or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
midx = raw.get("roadmap_major_step_index")
|
||||
if midx is None:
|
||||
continue
|
||||
try:
|
||||
major_idx = int(midx)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if raw.get("roadmap_match_source") == "slot_best_match" or raw.get("slot_status") == "preserved":
|
||||
if major_idx not in flagged:
|
||||
preserved.add(major_idx)
|
||||
|
||||
return {idx for idx in slot_indices if idx not in preserved}
|
||||
|
||||
|
||||
def _context_before_major(
|
||||
steps_by_major: Mapping[int, Mapping[str, Any]],
|
||||
target_major: int,
|
||||
) -> Tuple[List[int], Optional[int], Optional[int]]:
|
||||
planned: List[int] = []
|
||||
anchor: Optional[int] = None
|
||||
anchor_vid: Optional[int] = None
|
||||
for midx in sorted(steps_by_major):
|
||||
if midx >= target_major:
|
||||
break
|
||||
step = steps_by_major[midx]
|
||||
eid = step.get("exercise_id")
|
||||
if eid is not None:
|
||||
planned.append(int(eid))
|
||||
anchor = int(eid)
|
||||
vid = step.get("variant_id")
|
||||
anchor_vid = int(vid) if vid is not None else None
|
||||
return planned, anchor, anchor_vid
|
||||
|
||||
|
||||
def rematch_roadmap_slots(
|
||||
cur,
|
||||
*,
|
||||
tenant,
|
||||
body,
|
||||
goal_query: str,
|
||||
max_steps: int,
|
||||
semantic_brief,
|
||||
path_target_profile,
|
||||
path_intent: str,
|
||||
roadmap_ctx: ProgressionRoadmapContext,
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
slot_indices: Set[int],
|
||||
rematch_reasons: Mapping[int, str],
|
||||
match_slot_fn,
|
||||
rejected_by_major: Optional[Mapping[int, Set[int]]] = None,
|
||||
slot_assignment_history: Optional[Mapping[int, Set[int]]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
|
||||
"""
|
||||
Ersetzt nur betroffene Slots; andere Schritte und used-Set bleiben konsistent.
|
||||
|
||||
match_slot_fn: _match_roadmap_slot aus path_builder (Injection gegen Zirkularität).
|
||||
"""
|
||||
stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
|
||||
if not stage_specs or not slot_indices:
|
||||
return list(steps), [], []
|
||||
|
||||
spec_by_major = {int(s.major_step_index): s for s in stage_specs}
|
||||
steps_by_major: Dict[int, Dict[str, Any]] = {}
|
||||
for raw in steps:
|
||||
step = dict(raw)
|
||||
midx = step.get("roadmap_major_step_index")
|
||||
if midx is not None:
|
||||
steps_by_major[int(midx)] = step
|
||||
|
||||
rematch_log: List[Dict[str, Any]] = []
|
||||
new_unfilled: List[Tuple[int, StageSpecArtifact]] = []
|
||||
|
||||
for major_idx in sorted(slot_indices):
|
||||
stage_spec = spec_by_major.get(int(major_idx))
|
||||
if stage_spec is None:
|
||||
continue
|
||||
step_index = next(
|
||||
(i for i, sp in enumerate(stage_specs) if int(sp.major_step_index) == int(major_idx)),
|
||||
major_idx,
|
||||
)
|
||||
old = steps_by_major.pop(int(major_idx), None)
|
||||
used = {
|
||||
int(s["exercise_id"])
|
||||
for m, s in steps_by_major.items()
|
||||
if s.get("exercise_id") is not None
|
||||
}
|
||||
if old and old.get("exercise_id") is not None:
|
||||
used.add(int(old["exercise_id"]))
|
||||
for rejected_id in rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set():
|
||||
if rejected_id > 0:
|
||||
used.add(int(rejected_id))
|
||||
planned_ids, anchor_id, anchor_variant_id = _context_before_major(
|
||||
steps_by_major, int(major_idx)
|
||||
)
|
||||
|
||||
new_step, unfilled_spec = match_slot_fn(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=body,
|
||||
goal_query=goal_query,
|
||||
max_steps=max_steps,
|
||||
semantic_brief=semantic_brief,
|
||||
path_target_profile=path_target_profile,
|
||||
path_intent=path_intent,
|
||||
roadmap_ctx=roadmap_ctx,
|
||||
stage_spec=stage_spec,
|
||||
step_index=step_index,
|
||||
stage_count=len(stage_specs),
|
||||
planned_ids=planned_ids,
|
||||
anchor_id=anchor_id,
|
||||
anchor_variant_id=anchor_variant_id,
|
||||
used=used,
|
||||
slot_priority_exercise_id=_slot_priority_for_rematch(
|
||||
body,
|
||||
major_idx=major_idx,
|
||||
old=old,
|
||||
rejected_by_major=rejected_by_major,
|
||||
),
|
||||
)
|
||||
|
||||
reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot")
|
||||
if new_step:
|
||||
try:
|
||||
new_eid = int(new_step.get("exercise_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
new_eid = 0
|
||||
rejected = (
|
||||
rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
|
||||
)
|
||||
if new_eid > 0 and new_eid in rejected:
|
||||
new_step = None
|
||||
if new_step:
|
||||
steps_by_major[int(major_idx)] = new_step
|
||||
rematch_log.append(
|
||||
{
|
||||
"roadmap_major_step_index": int(major_idx),
|
||||
"action": "replaced",
|
||||
"reason": reason,
|
||||
"replaced_exercise_id": old.get("exercise_id") if old else None,
|
||||
"replaced_title": old.get("title") if old else None,
|
||||
"new_exercise_id": new_step.get("exercise_id"),
|
||||
"new_title": new_step.get("title"),
|
||||
}
|
||||
)
|
||||
else:
|
||||
if old and old.get("exercise_id") is not None:
|
||||
try:
|
||||
old_eid = int(old["exercise_id"])
|
||||
except (TypeError, ValueError):
|
||||
old_eid = 0
|
||||
rejected = (
|
||||
rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
|
||||
)
|
||||
if old_eid > 0 and old_eid not in rejected:
|
||||
steps_by_major[int(major_idx)] = dict(old)
|
||||
rematch_log.append(
|
||||
{
|
||||
"roadmap_major_step_index": int(major_idx),
|
||||
"action": "restored",
|
||||
"reason": reason,
|
||||
"restored_exercise_id": old_eid,
|
||||
"restored_title": old.get("title"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
goal = (stage_spec.learning_goal or "").strip()
|
||||
major = None
|
||||
if roadmap_ctx.roadmap:
|
||||
major = next(
|
||||
(m for m in roadmap_ctx.roadmap.major_steps if int(m.index) == int(major_idx)),
|
||||
None,
|
||||
)
|
||||
steps_by_major[int(major_idx)] = {
|
||||
"exercise_id": None,
|
||||
"variant_id": None,
|
||||
"title": goal or f"Slot {major_idx + 1}",
|
||||
"is_ai_proposal": False,
|
||||
"roadmap_major_step_index": int(major_idx),
|
||||
"roadmap_phase": major.phase if major else None,
|
||||
"roadmap_learning_goal": goal or None,
|
||||
"roadmap_match_source": "unfilled",
|
||||
"slot_status": "unfilled",
|
||||
"reasons": ["Keine passende Übung für Roadmap-Stufe"],
|
||||
}
|
||||
if unfilled_spec is not None:
|
||||
new_unfilled.append((step_index, unfilled_spec))
|
||||
elif stage_spec is not None:
|
||||
new_unfilled.append((step_index, stage_spec))
|
||||
rematch_log.append(
|
||||
{
|
||||
"roadmap_major_step_index": int(major_idx),
|
||||
"action": "rematch_unfilled",
|
||||
"reason": reason,
|
||||
"replaced_exercise_id": old.get("exercise_id") if old else None,
|
||||
"replaced_title": old.get("title") if old else None,
|
||||
}
|
||||
)
|
||||
|
||||
ordered: List[Dict[str, Any]] = []
|
||||
for spec in sorted(stage_specs, key=lambda s: s.major_step_index):
|
||||
midx = int(spec.major_step_index)
|
||||
if midx in steps_by_major:
|
||||
ordered.append(steps_by_major[midx])
|
||||
|
||||
return ordered, rematch_log, new_unfilled
|
||||
|
||||
|
||||
def prune_stripped_after_rematch(
|
||||
stripped_off_topic: Sequence[Mapping[str, Any]],
|
||||
rematch_log: Sequence[Mapping[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Entfernt aus stripped_off_topic Slots, die per Rematch ersetzt wurden."""
|
||||
replaced: Set[int] = set()
|
||||
for entry in rematch_log or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if str(entry.get("action") or "") != "replaced":
|
||||
continue
|
||||
midx = entry.get("roadmap_major_step_index")
|
||||
if midx is not None:
|
||||
replaced.add(int(midx))
|
||||
if not replaced:
|
||||
return list(stripped_off_topic or [])
|
||||
out: List[Dict[str, Any]] = []
|
||||
for item in stripped_off_topic or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
midx = item.get("roadmap_major_step_index")
|
||||
if midx is not None and int(midx) in replaced:
|
||||
continue
|
||||
out.append(dict(item))
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"collect_rematch_slot_indices",
|
||||
"filter_rematch_slot_indices",
|
||||
"prune_stripped_after_rematch",
|
||||
"rematch_roadmap_slots",
|
||||
]
|
||||
1340
backend/planning_progression_roadmap.py
Normal file
1340
backend/planning_progression_roadmap.py
Normal file
File diff suppressed because it is too large
Load Diff
334
backend/planning_skill_expectations.py
Normal file
334
backend/planning_skill_expectations.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"""
|
||||
Wiederverwendbare Fähigkeiten-Erwartungen für Planungs-KI.
|
||||
|
||||
Domänen-Scopes (gleiches Input-/Output-Modell):
|
||||
- ``progression_stage`` — ein Major Step / stage_spec im Progressionsgraphen
|
||||
- ``progression_path`` — gesamter Pfad (Ziel + Start/Ziel)
|
||||
- ``training_section`` — Abschnitt einer Trainingseinheit (Phase G, später)
|
||||
- ``framework_slot`` — Rahmen-Session-Slot (Phase G, später)
|
||||
|
||||
Konsumenten mergen ``skill_weights`` in ``PlanningTargetProfile`` (Retrieval)
|
||||
oder liefern ``expected_skills`` an Übungs-KI (planning_context).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from planning_exercise_profiles import _merge_weight_maps, _normalize_weight_map
|
||||
from planning_exercise_semantics import PlanningSemanticBrief, resolve_semantic_skill_weights
|
||||
from planning_exercise_text_signals import _load_skills_for_text_match, _match_skills_in_text
|
||||
|
||||
# Scope-Strings — stabil für API/UI und spätere Trainingsplanung
|
||||
SCOPE_PROGRESSION_STAGE = "progression_stage"
|
||||
SCOPE_PROGRESSION_PATH = "progression_path"
|
||||
SCOPE_TRAINING_SECTION = "training_section"
|
||||
SCOPE_FRAMEWORK_SLOT = "framework_slot"
|
||||
|
||||
_LOAD_PROFILE_SKILL_TERMS: Dict[str, Tuple[str, ...]] = {
|
||||
"koordination": ("Koordination",),
|
||||
"präzision": ("Präzision",),
|
||||
"praezision": ("Präzision",),
|
||||
"kraft": ("Kraft", "Kime"),
|
||||
"geschwindigkeit": ("Geschwindigkeit", "Schnelligkeit"),
|
||||
"schnelligkeit": ("Schnelligkeit", "Geschwindigkeit"),
|
||||
"timing": ("Timing", "Reaktion"),
|
||||
"reaktion": ("Reaktion", "Timing"),
|
||||
"distanz": ("Distanz",),
|
||||
"raum": ("Raum", "Distanz"),
|
||||
"gleichgewicht": ("Gleichgewicht",),
|
||||
"kime": ("Kime",),
|
||||
"ausdauer": ("Ausdauer",),
|
||||
"beweglichkeit": ("Beweglichkeit",),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanningSkillExpectationInput:
|
||||
scope: str = SCOPE_PROGRESSION_STAGE
|
||||
primary_topic: str = ""
|
||||
goal_query: str = ""
|
||||
start_situation: str = ""
|
||||
target_state: str = ""
|
||||
stage_learning_goal: str = ""
|
||||
roadmap_notes: str = ""
|
||||
load_profile: List[str] = field(default_factory=list)
|
||||
phase: str = ""
|
||||
skill_hints: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanningSkillExpectationItem:
|
||||
skill_id: int
|
||||
skill_name: str
|
||||
weight: float
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanningSkillExpectations:
|
||||
scope: str
|
||||
skill_weights: Dict[int, float]
|
||||
items: List[PlanningSkillExpectationItem]
|
||||
sources: List[str]
|
||||
|
||||
def to_api_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"scope": self.scope,
|
||||
"sources": list(self.sources),
|
||||
"expected_skills": [
|
||||
{
|
||||
"skill_id": it.skill_id,
|
||||
"skill_name": it.skill_name,
|
||||
"weight": round(it.weight, 4),
|
||||
"source": it.source,
|
||||
}
|
||||
for it in self.items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _norm_load_key(s: str) -> str:
|
||||
return (s or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
|
||||
|
||||
|
||||
def _text_blob(inp: PlanningSkillExpectationInput) -> str:
|
||||
parts = [
|
||||
inp.primary_topic,
|
||||
inp.goal_query,
|
||||
inp.start_situation,
|
||||
inp.target_state,
|
||||
inp.stage_learning_goal,
|
||||
inp.roadmap_notes,
|
||||
inp.phase,
|
||||
" ".join(inp.load_profile or []),
|
||||
" ".join(inp.skill_hints or []),
|
||||
]
|
||||
return "\n".join(p for p in parts if (p or "").strip()).lower()
|
||||
|
||||
|
||||
def _resolve_skills_by_name_terms(
|
||||
cur,
|
||||
terms: Sequence[str],
|
||||
*,
|
||||
weight: float = 0.9,
|
||||
source: str,
|
||||
weights: Dict[int, float],
|
||||
items: Dict[int, PlanningSkillExpectationItem],
|
||||
) -> bool:
|
||||
found = False
|
||||
for name in terms:
|
||||
if not name:
|
||||
continue
|
||||
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"%{name.lower()}%", name.lower(), f"{name.lower()}%"),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
continue
|
||||
sid = int(row["id"])
|
||||
w = max(weights.get(sid, 0.0), weight)
|
||||
weights[sid] = w
|
||||
items[sid] = PlanningSkillExpectationItem(
|
||||
skill_id=sid,
|
||||
skill_name=str(row.get("name") or "").strip(),
|
||||
weight=w,
|
||||
source=source,
|
||||
)
|
||||
found = True
|
||||
return found
|
||||
|
||||
|
||||
def _merge_weights_into(
|
||||
weights: Dict[int, float],
|
||||
items: Dict[int, PlanningSkillExpectationItem],
|
||||
incoming: Dict[int, float],
|
||||
*,
|
||||
source: str,
|
||||
skill_rows: Sequence[Tuple[int, str, int]],
|
||||
) -> None:
|
||||
name_by_id = {sid: name for sid, name, _ in skill_rows}
|
||||
for sid, w in incoming.items():
|
||||
if w <= 0:
|
||||
continue
|
||||
merged = max(weights.get(sid, 0.0), float(w))
|
||||
weights[sid] = merged
|
||||
items[sid] = PlanningSkillExpectationItem(
|
||||
skill_id=sid,
|
||||
skill_name=name_by_id.get(sid, f"Skill #{sid}"),
|
||||
weight=merged,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def build_planning_skill_expectations(
|
||||
cur,
|
||||
inp: PlanningSkillExpectationInput,
|
||||
*,
|
||||
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
||||
) -> PlanningSkillExpectations:
|
||||
"""Deterministisch: Thema + Text + load_profile → skill_weights."""
|
||||
weights: Dict[int, float] = {}
|
||||
items: Dict[int, PlanningSkillExpectationItem] = {}
|
||||
sources: List[str] = []
|
||||
|
||||
skill_rows = _load_skills_for_text_match(cur)
|
||||
|
||||
if semantic_brief is not None:
|
||||
topic_weights = resolve_semantic_skill_weights(cur, semantic_brief)
|
||||
if topic_weights:
|
||||
sources.append("semantic_topic")
|
||||
_merge_weights_into(
|
||||
weights, items, topic_weights, source="semantic_topic", skill_rows=skill_rows
|
||||
)
|
||||
|
||||
blob = _text_blob(inp)
|
||||
if blob:
|
||||
text_weights = _match_skills_in_text(blob, skill_rows)
|
||||
if text_weights:
|
||||
sources.append("text_match")
|
||||
_merge_weights_into(
|
||||
weights, items, text_weights, source="text_match", skill_rows=skill_rows
|
||||
)
|
||||
|
||||
load_found = False
|
||||
for raw in inp.load_profile or []:
|
||||
key = _norm_load_key(str(raw))
|
||||
terms = _LOAD_PROFILE_SKILL_TERMS.get(key, (str(raw).strip(),) if key else ())
|
||||
if _resolve_skills_by_name_terms(
|
||||
cur, terms, weight=0.88, source="load_profile", weights=weights, items=items
|
||||
):
|
||||
load_found = True
|
||||
if load_found and "load_profile" not in sources:
|
||||
sources.append("load_profile")
|
||||
|
||||
normalized = _normalize_weight_map(weights)
|
||||
out_items = sorted(
|
||||
[
|
||||
PlanningSkillExpectationItem(
|
||||
skill_id=sid,
|
||||
skill_name=items[sid].skill_name,
|
||||
weight=normalized[sid],
|
||||
source=items[sid].source,
|
||||
)
|
||||
for sid in normalized
|
||||
],
|
||||
key=lambda x: (-x.weight, x.skill_name.lower()),
|
||||
)[:8]
|
||||
|
||||
return PlanningSkillExpectations(
|
||||
scope=inp.scope or SCOPE_PROGRESSION_STAGE,
|
||||
skill_weights=normalized,
|
||||
items=out_items,
|
||||
sources=sources,
|
||||
)
|
||||
|
||||
|
||||
def expectation_input_from_progression_stage(
|
||||
*,
|
||||
goal_query: str,
|
||||
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||
stage_spec: Optional[Mapping[str, Any]] = None,
|
||||
semantic_brief_summary: Optional[Mapping[str, Any]] = None,
|
||||
major_step: Optional[Mapping[str, Any]] = None,
|
||||
) -> PlanningSkillExpectationInput:
|
||||
"""Roadmap-Stufe → PlanningSkillExpectationInput (Progressionsgraph)."""
|
||||
ga = dict(goal_analysis or {})
|
||||
rs = dict(resolved_structured or {})
|
||||
spec = dict(stage_spec or {})
|
||||
brief = dict(semantic_brief_summary or {})
|
||||
major = dict(major_step or {})
|
||||
|
||||
skill_hints: List[str] = []
|
||||
for item in (brief.get("must_phrases") or [])[:4]:
|
||||
s = str(item or "").strip()
|
||||
if s:
|
||||
skill_hints.append(s)
|
||||
|
||||
return PlanningSkillExpectationInput(
|
||||
scope=SCOPE_PROGRESSION_STAGE,
|
||||
primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(),
|
||||
goal_query=(goal_query or "").strip(),
|
||||
start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(),
|
||||
target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(),
|
||||
stage_learning_goal=str(
|
||||
spec.get("learning_goal") or major.get("learning_goal") or ""
|
||||
).strip(),
|
||||
roadmap_notes=str(rs.get("roadmap_notes") or "").strip(),
|
||||
load_profile=[str(x).strip() for x in (spec.get("load_profile") or []) if str(x).strip()],
|
||||
phase=str(spec.get("phase") or major.get("phase") or "").strip(),
|
||||
skill_hints=skill_hints,
|
||||
)
|
||||
|
||||
|
||||
def expectation_input_from_progression_path(
|
||||
*,
|
||||
goal_query: str,
|
||||
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||
semantic_brief_summary: Optional[Mapping[str, Any]] = None,
|
||||
) -> PlanningSkillExpectationInput:
|
||||
"""Gesamtpfad-Kontext (z. B. einmaliges Pfad-Profil)."""
|
||||
ga = dict(goal_analysis or {})
|
||||
rs = dict(resolved_structured or {})
|
||||
brief = dict(semantic_brief_summary or {})
|
||||
skill_hints: List[str] = []
|
||||
for item in (brief.get("must_phrases") or [])[:4]:
|
||||
s = str(item or "").strip()
|
||||
if s:
|
||||
skill_hints.append(s)
|
||||
return PlanningSkillExpectationInput(
|
||||
scope=SCOPE_PROGRESSION_PATH,
|
||||
primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(),
|
||||
goal_query=(goal_query or "").strip(),
|
||||
start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(),
|
||||
target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(),
|
||||
roadmap_notes=str(rs.get("roadmap_notes") or "").strip(),
|
||||
skill_hints=skill_hints,
|
||||
)
|
||||
|
||||
|
||||
def apply_expectations_to_target(target, expectations: PlanningSkillExpectations):
|
||||
"""Merge Erwartungs-Skills in PlanningTargetProfile (Retrieval)."""
|
||||
from planning_exercise_semantics import enrich_target_with_semantic_expectations
|
||||
|
||||
if not expectations.skill_weights:
|
||||
return target
|
||||
return enrich_target_with_semantic_expectations(
|
||||
target, skill_weights=dict(expectations.skill_weights)
|
||||
)
|
||||
|
||||
|
||||
def merge_expectation_skill_weights(
|
||||
base: Dict[int, float],
|
||||
extra: Dict[int, float],
|
||||
*,
|
||||
extra_scale: float = 1.0,
|
||||
) -> Dict[int, float]:
|
||||
merged = _merge_weight_maps(base, extra, scale=extra_scale)
|
||||
return _normalize_weight_map(merged)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SCOPE_FRAMEWORK_SLOT",
|
||||
"SCOPE_PROGRESSION_PATH",
|
||||
"SCOPE_PROGRESSION_STAGE",
|
||||
"SCOPE_TRAINING_SECTION",
|
||||
"PlanningSkillExpectationInput",
|
||||
"PlanningSkillExpectationItem",
|
||||
"PlanningSkillExpectations",
|
||||
"apply_expectations_to_target",
|
||||
"build_planning_skill_expectations",
|
||||
"expectation_input_from_progression_path",
|
||||
"expectation_input_from_progression_stage",
|
||||
"merge_expectation_skill_weights",
|
||||
]
|
||||
140
backend/planning_stage_context.py
Normal file
140
backend/planning_stage_context.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""
|
||||
Stufen-Kontext im Gesamtziel — Start/Ziel pro Roadmap-Stufe für Matching und QS.
|
||||
|
||||
Übertragbar auf Trainingsplanung: Abschnitt-Soll (= Ende Vorabschnitt), Abschnitt-Ziel.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
from planning_progression_roadmap import (
|
||||
GoalAnalysisArtifact,
|
||||
MajorStep,
|
||||
RoadmapStructuredInput,
|
||||
StageSpecArtifact,
|
||||
)
|
||||
|
||||
|
||||
def build_contextualized_stage_goal(
|
||||
*,
|
||||
learning_goal: str,
|
||||
start_state: str = "",
|
||||
target_state: str = "",
|
||||
path_target_state: str = "",
|
||||
path_start_state: str = "",
|
||||
stage_index: int = 0,
|
||||
stage_count: int = 1,
|
||||
) -> str:
|
||||
"""Stufen-Lernziel eingebettet in Übergang und Gesamtziel (für Brief/Retrieval)."""
|
||||
lg = (learning_goal or "").strip()
|
||||
if not lg:
|
||||
return ""
|
||||
|
||||
parts: List[str] = []
|
||||
start = (start_state or "").strip()
|
||||
target = (target_state or "").strip()
|
||||
path_end = (path_target_state or "").strip()
|
||||
path_begin = (path_start_state or "").strip()
|
||||
|
||||
if start:
|
||||
parts.append(f"Soll-Start: {start[:220]}")
|
||||
elif path_begin and stage_index == 0:
|
||||
parts.append(f"Pfad-Start: {path_begin[:220]}")
|
||||
if target:
|
||||
parts.append(f"Stufen-Ziel: {target[:220]}")
|
||||
parts.append(f"Lernziel: {lg[:280]}")
|
||||
if path_end:
|
||||
if stage_index >= max(0, stage_count - 1):
|
||||
parts.append(f"Gesamtziel: {path_end[:220]}")
|
||||
else:
|
||||
parts.append(f"Gesamtziel (Kontext): {path_end[:180]}")
|
||||
|
||||
return " | ".join(parts)[:900]
|
||||
|
||||
|
||||
def derive_stage_specs_transition_states(
|
||||
stage_specs: Sequence[StageSpecArtifact],
|
||||
major_steps: Sequence[MajorStep],
|
||||
*,
|
||||
path_start: str = "",
|
||||
path_target: str = "",
|
||||
goal_analysis: Optional[GoalAnalysisArtifact] = None,
|
||||
) -> List[StageSpecArtifact]:
|
||||
"""
|
||||
Verkettete Soll-/Zielzustände je Stufe.
|
||||
|
||||
- Stufe 0 start = Pfad-Start
|
||||
- Stufe n start = Zielzustand Stufe n-1 (Ziel des vorherigen Schritts)
|
||||
- Letzte Stufe target = Pfad-Gesamtziel (falls gesetzt)
|
||||
"""
|
||||
start_path = (path_start or "").strip()
|
||||
end_path = (path_target or "").strip()
|
||||
if goal_analysis:
|
||||
if not start_path:
|
||||
start_path = (goal_analysis.start_assumption or "").strip()
|
||||
if not end_path:
|
||||
end_path = (goal_analysis.target_state or "").strip()
|
||||
|
||||
by_idx = {int(s.major_step_index): s for s in stage_specs}
|
||||
majors = sorted(major_steps, key=lambda m: m.index)
|
||||
if not majors:
|
||||
return list(stage_specs)
|
||||
|
||||
out: List[StageSpecArtifact] = []
|
||||
prev_target = start_path
|
||||
last_idx = majors[-1].index
|
||||
|
||||
for major in majors:
|
||||
spec = by_idx.get(major.index)
|
||||
if spec is None:
|
||||
spec = StageSpecArtifact(
|
||||
major_step_index=major.index,
|
||||
learning_goal=major.learning_goal,
|
||||
)
|
||||
|
||||
explicit_start = (spec.start_state or "").strip()
|
||||
explicit_target = (spec.target_state or "").strip()
|
||||
stage_start = explicit_start or prev_target or start_path
|
||||
if explicit_target:
|
||||
stage_target = explicit_target
|
||||
elif major.index == last_idx and end_path:
|
||||
stage_target = end_path
|
||||
else:
|
||||
stage_target = (major.learning_goal or spec.learning_goal or "").strip()
|
||||
|
||||
prev_target = stage_target
|
||||
out.append(
|
||||
spec.model_copy(
|
||||
update={
|
||||
"start_state": (stage_start or "")[:500],
|
||||
"target_state": (stage_target or "")[:500],
|
||||
}
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_path_start_target(
|
||||
*,
|
||||
structured: Optional[RoadmapStructuredInput] = None,
|
||||
goal_analysis: Optional[GoalAnalysisArtifact] = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Pfadweiter Start- und Zielzustand für Stufen-Verkettung."""
|
||||
start = ""
|
||||
target = ""
|
||||
if structured:
|
||||
start = (structured.start_situation or "").strip()
|
||||
target = (structured.target_state or "").strip()
|
||||
if goal_analysis:
|
||||
if not start:
|
||||
start = (goal_analysis.start_assumption or "").strip()
|
||||
if not target:
|
||||
target = (goal_analysis.target_state or "").strip()
|
||||
return start, target
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_contextualized_stage_goal",
|
||||
"derive_stage_specs_transition_states",
|
||||
"resolve_path_start_target",
|
||||
]
|
||||
78
backend/progression_graph_planning_artifact.py
Normal file
78
backend/progression_graph_planning_artifact.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Validierung und Normalisierung des Planungs-Artefakts am Progressionsgraph."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
ARTIFACT_SCHEMA_VERSION = 1
|
||||
_MAX_JSON_BYTES = 64_000
|
||||
|
||||
|
||||
class SlotExerciseContent(BaseModel):
|
||||
kind: str = Field(default="empty", pattern=r"^(empty|library|proposal)$")
|
||||
exercise_id: Optional[int] = Field(default=None, ge=1)
|
||||
variant_id: Optional[int] = Field(default=None, ge=1)
|
||||
title: Optional[str] = Field(default=None, max_length=500)
|
||||
variant_name: Optional[str] = Field(default=None, max_length=200)
|
||||
proposal_key: Optional[str] = Field(default=None, max_length=120)
|
||||
ai_suggestion: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class SlotContentEntry(BaseModel):
|
||||
major_step_index: int = Field(ge=0, le=20)
|
||||
primary: SlotExerciseContent = Field(default_factory=SlotExerciseContent)
|
||||
siblings: List[SlotExerciseContent] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GraphPlanningRoadmapArtifact(BaseModel):
|
||||
schema_version: int = Field(default=ARTIFACT_SCHEMA_VERSION, ge=1, le=1)
|
||||
goal_query: str = Field(default="", max_length=2000)
|
||||
start_situation: Optional[str] = Field(default=None, max_length=2000)
|
||||
target_state: Optional[str] = Field(default=None, max_length=2000)
|
||||
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
|
||||
max_steps: int = Field(default=5, ge=2, le=10)
|
||||
progression_roadmap: Optional[Dict[str, Any]] = None
|
||||
path_skill_expectations: Optional[Dict[str, Any]] = None
|
||||
slot_contents: Optional[List[SlotContentEntry]] = None
|
||||
last_findings: Optional[Dict[str, Any]] = None
|
||||
findings_stale: bool = Field(default=False)
|
||||
planning_catalog_context: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", "planning_catalog_context", mode="before")
|
||||
@classmethod
|
||||
def _empty_dict_to_none(cls, v):
|
||||
if v == {}:
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator("slot_contents", mode="before")
|
||||
@classmethod
|
||||
def _empty_slot_list_to_none(cls, v):
|
||||
if v == []:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def normalize_planning_roadmap_payload(raw: Any) -> Optional[Dict[str, Any]]:
|
||||
"""None erlaubt (löschen); sonst validiertes Dict."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("planning_roadmap muss ein JSON-Objekt sein")
|
||||
artifact = GraphPlanningRoadmapArtifact.model_validate(raw)
|
||||
out = artifact.model_dump(exclude_none=True)
|
||||
blob = json.dumps(out, ensure_ascii=False)
|
||||
if len(blob.encode("utf-8")) > _MAX_JSON_BYTES:
|
||||
raise ValueError("planning_roadmap ist zu groß (max. 64 KB)")
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ARTIFACT_SCHEMA_VERSION",
|
||||
"GraphPlanningRoadmapArtifact",
|
||||
"SlotContentEntry",
|
||||
"SlotExerciseContent",
|
||||
"normalize_planning_roadmap_payload",
|
||||
]
|
||||
140
backend/prompt_resolver.py
Normal file
140
backend/prompt_resolver.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""
|
||||
Mustache-aehnliche Platzhalter {{schluessel}} fuer KI-Templates aus ai_prompts.
|
||||
|
||||
Kein Vereinsbezug — reine Textersetzung; Aufrufe aus exercise_ai und Admin-Vorschau.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Mapping
|
||||
|
||||
_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
|
||||
|
||||
|
||||
def _placeholder_pattern_for_key(key: str) -> re.Pattern[str]:
|
||||
return re.compile(r"\{\{\s*" + re.escape(str(key).strip()) + r"\s*\}\}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MustacheRenderResult:
|
||||
"""Ergebnis von render_mustache_template."""
|
||||
|
||||
text: str
|
||||
keys_in_template: List[str]
|
||||
keys_substituted: List[str]
|
||||
keys_missing_variables: List[str]
|
||||
placeholders_remaining: List[str]
|
||||
|
||||
|
||||
def extract_mustache_keys(template: str) -> List[str]:
|
||||
"""Platzhalter-Namen in Vorkommensreihenfolge, ohne erstes Duplikat."""
|
||||
seen: set[str] = set()
|
||||
ordered: List[str] = []
|
||||
for m in _PLACEHOLDER_RE.finditer(template or ""):
|
||||
k = str(m.group(1) or "").strip()
|
||||
if not k or k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
ordered.append(k)
|
||||
return ordered
|
||||
|
||||
|
||||
def render_mustache_template(template: str, variables: Mapping[str, str]) -> MustacheRenderResult:
|
||||
"""
|
||||
Ersetzt {{keys}} durch die passenden Strings.
|
||||
Variablen, die fuer einen im Template genutzten Key fehlen, werden als Leerstring ersetzt.
|
||||
|
||||
Rueckgabe-liste keys_missing_variables: Keys, die im Template vorkommen, aber nicht als Map-Keys
|
||||
uebergeben wurden (oder None-Wert entsprachen Leerung).
|
||||
"""
|
||||
tpl_in = template or ""
|
||||
vars_norm: Dict[str, str] = {}
|
||||
for k, v in variables.items():
|
||||
vars_norm[str(k)] = "" if v is None else str(v)
|
||||
keys_in = extract_mustache_keys(tpl_in)
|
||||
missing_known: List[str] = []
|
||||
out = tpl_in
|
||||
substituted: List[str] = []
|
||||
|
||||
for key in keys_in:
|
||||
pat = _placeholder_pattern_for_key(key)
|
||||
repl = vars_norm.get(key)
|
||||
if key not in vars_norm:
|
||||
missing_known.append(key)
|
||||
repl = ""
|
||||
substituted.append(key)
|
||||
out = pat.sub(repl, out)
|
||||
|
||||
still = extract_mustache_keys(out)
|
||||
|
||||
return MustacheRenderResult(
|
||||
text=out,
|
||||
keys_in_template=keys_in,
|
||||
keys_substituted=substituted,
|
||||
keys_missing_variables=missing_known,
|
||||
placeholders_remaining=still,
|
||||
)
|
||||
|
||||
|
||||
def exercise_placeholder_catalog() -> dict:
|
||||
"""
|
||||
Statischer Platzhalter-Katalog fuer Uebungs-KI-Templates — deckt aktuelle Seeds ab.
|
||||
(Erweiterung andere Kontexte: matrix/import folgen separat.)
|
||||
"""
|
||||
defs = [
|
||||
{
|
||||
"key": "exercise_title",
|
||||
"placeholder": "{{exercise_title}}",
|
||||
"description": "Titel der Uebung (oder Platzhalter, wenn leer).",
|
||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
|
||||
},
|
||||
{
|
||||
"key": "exercise_focus_area",
|
||||
"placeholder": "{{exercise_focus_area}}",
|
||||
"description": "Fokuskontext (Text-Hinweis aus Formular, optional).",
|
||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
|
||||
},
|
||||
{
|
||||
"key": "exercise_goal",
|
||||
"placeholder": "{{exercise_goal}}",
|
||||
"description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.",
|
||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
|
||||
},
|
||||
{
|
||||
"key": "exercise_execution",
|
||||
"placeholder": "{{exercise_execution}}",
|
||||
"description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.",
|
||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
|
||||
},
|
||||
{
|
||||
"key": "exercise_preparation",
|
||||
"placeholder": "{{exercise_preparation}}",
|
||||
"description": "Vorbereitung/Aufbau als Plaintext ohne HTML.",
|
||||
"used_by_slugs": ["exercise_instruction_rewrite"],
|
||||
},
|
||||
{
|
||||
"key": "exercise_trainer_notes",
|
||||
"placeholder": "{{exercise_trainer_notes}}",
|
||||
"description": "Trainer-Hinweise als Plaintext ohne HTML.",
|
||||
"used_by_slugs": ["exercise_instruction_rewrite"],
|
||||
},
|
||||
{
|
||||
"key": "skills_catalog",
|
||||
"placeholder": "{{skills_catalog}}",
|
||||
"description": (
|
||||
"Gewichtete, kontextbezogene Liste aus dem Skill-Katalog (retrieval_profiles). "
|
||||
"Nur fuer exercise_skill_suggestions."
|
||||
),
|
||||
"used_by_slugs": ["exercise_skill_suggestions"],
|
||||
},
|
||||
]
|
||||
return {"context": "exercise", "placeholders": defs}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MustacheRenderResult",
|
||||
"exercise_placeholder_catalog",
|
||||
"extract_mustache_keys",
|
||||
"render_mustache_template",
|
||||
]
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
httpx==0.27.2
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
anthropic==0.26.0
|
||||
|
|
@ -9,5 +10,5 @@ bcrypt==4.1.3
|
|||
slowapi==0.1.9
|
||||
psycopg2-binary==2.9.9
|
||||
python-dateutil==2.9.0
|
||||
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
||||
tzdata>=2024.1; sys_platform == "win32" # ZoneInfo lokal; Linux/Docker: apt tzdata
|
||||
sqlparse>=0.5.0 # Migrationen: Statements splitten (Fallback ohne psql)
|
||||
|
|
|
|||
9
backend/rights_registrations/__init__.py
Normal file
9
backend/rights_registrations/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
Modul-Registrierungen für Rechte & Kontingente.
|
||||
|
||||
Neues Feature: eigene Datei oder Eintrag hier importieren — kein Eintrag in 079-Katalog-Migration.
|
||||
"""
|
||||
from rights_registrations import club_creation # noqa: F401
|
||||
from rights_registrations import exercises # noqa: F401
|
||||
from rights_registrations import planning # noqa: F401
|
||||
from rights_registrations import platform # noqa: F401
|
||||
38
backend/rights_registrations/club_creation.py
Normal file
38
backend/rights_registrations/club_creation.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from rights_registry import CapabilityRegistration, register_capability
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="club.creation_request.create",
|
||||
name="Vereinsgründung beantragen",
|
||||
domain="club",
|
||||
module="club_creation_requests",
|
||||
min_account_state="verified_pending_club",
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="club.creation_request.read_own",
|
||||
name="Eigene Gründungsanträge",
|
||||
domain="club",
|
||||
module="club_creation_requests",
|
||||
min_account_state="verified_pending_club",
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="club.creation_request.withdraw",
|
||||
name="Gründungsantrag zurückziehen",
|
||||
domain="club",
|
||||
module="club_creation_requests",
|
||||
min_account_state="verified_pending_club",
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="platform.club_creation.approve",
|
||||
name="Vereinsgründung freigeben",
|
||||
domain="platform",
|
||||
module="club_creation_requests",
|
||||
min_account_state="platform_admin",
|
||||
)
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user