Enhance Planning AI with Roadmap-First Architecture and New Features
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 44s
Test Suite / playwright-tests (push) Successful in 1m15s
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 44s
Test Suite / playwright-tests (push) Successful in 1m15s
- Introduced a roadmap-first approach for the planning AI, allowing for a structured progression graph that aligns with the overall project roadmap. - Added new functionality to strip off-topic steps from exercise paths, improving the relevance of generated exercise suggestions. - Implemented a detailed goal text generation for AI proposals, enhancing the context provided for new exercises. - Updated the ExerciseProgressionPathBuilder component to support new features, including roadmap previews and improved focus area handling. - Incremented application version to 0.8.205 and updated database schema version to 20260606086 to reflect these changes.
This commit is contained in:
parent
a9a6153ed5
commit
dd0fae4bf5
|
|
@ -79,10 +79,12 @@ 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) → Vereins-Kontingent.
|
||||
**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)
|
||||
|
|
@ -202,7 +204,8 @@ Request
|
|||
| **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** | `co_trainer` + Member-Budgets (v2) | Entscheidung 1.4 |
|
||||
| **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.
|
||||
|
||||
|
|
@ -237,3 +240,4 @@ Siehe **`docs/working/RBAC_ENFORCEMENT_ROADMAP.md` §4**: Plattform-Admin (`admi
|
|||
- 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).
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
**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).
|
||||
|
||||
---
|
||||
|
|
@ -107,6 +109,16 @@ So bleibt dieselbe fachliche Tiefe erreichbar ohne Kontext-Explosion.
|
|||
|
||||
---
|
||||
|
||||
## 9. Changelog
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -486,4 +486,30 @@ Nach Pfad-Bildung:
|
|||
|
||||
---
|
||||
|
||||
## 23. Backlog (offen)
|
||||
## 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+) 🔄
|
||||
|
||||
**Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung.
|
||||
|
||||
**Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md`
|
||||
|
||||
| Teil | Modul / API |
|
||||
|------|-------------|
|
||||
| Pipeline | `planning_progression_roadmap.py` (Workflow-lite) |
|
||||
| API | `include_roadmap_preview`, `include_llm_roadmap`, `roadmap_first` auf `progression-path-suggest` |
|
||||
| Prompts | Migration **078/079** — Slugs in `ai_prompts` (Admin), **kein** Template im Python-Code |
|
||||
| UI | `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) |
|
||||
|
||||
**Übergang:** `include_roadmap_preview=true` liefert `progression_roadmap` **parallel** zum retrieval-first Pfad. **Ziel F3:** `roadmap_first=true` steuert Retrieval.
|
||||
|
||||
**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung.
|
||||
|
||||
---
|
||||
|
||||
## 25. Backlog (offen)
|
||||
|
|
|
|||
198
.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
Normal file
198
.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Planungs-KI — Progressions-Roadmap (Phase F)
|
||||
|
||||
**Version:** 0.1
|
||||
**Datum:** 2026-06-07
|
||||
**Status:** VERBINDLICHE ZIELARCHITEKTUR — Umsetzung gestartet (0.8.204+)
|
||||
**Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse
|
||||
|
||||
**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_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) | 🔲 |
|
||||
| **F4** | UI Roadmap-Review | 🔲 |
|
||||
| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Changelog
|
||||
|
||||
- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite.
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
> | 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 Progressions-Roadmap (Phase F) | **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
|
||||
|
||||
## Projekt-Übersicht
|
||||
|
||||
|
|
|
|||
|
|
@ -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) = '');
|
||||
|
|
@ -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) = '');
|
||||
|
|
@ -258,14 +258,59 @@ def collect_gap_fill_specs(
|
|||
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,
|
||||
) -> str:
|
||||
"""Ausführlicher Zieltext für KI-Neuanlage aus dem Pfad-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"
|
||||
parts = [
|
||||
f"Planungsziel (gesamter Pfad): {goal_query}",
|
||||
f"Hauptthema: {topic}",
|
||||
f"Entwicklungsphase dieser Übung: {phase}",
|
||||
f"Erwarteter Entwicklungsbogen: {arc}",
|
||||
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
|
||||
]
|
||||
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 einen klaren, trainierbaren Bezug zum Hauptthema haben — "
|
||||
"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,
|
||||
) -> Dict[str, Any]:
|
||||
idx = int(spec.get("insert_after_index") or 0)
|
||||
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||
step_a = steps[idx] if idx < len(steps) else None
|
||||
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
||||
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,
|
||||
)
|
||||
offer: Dict[str, Any] = {
|
||||
"offer_id": offer_id,
|
||||
"source": spec.get("source"),
|
||||
|
|
@ -273,11 +318,13 @@ def build_gap_fill_offer(
|
|||
"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"),
|
||||
"phase": spec.get("phase"),
|
||||
"rationale": spec.get("rationale"),
|
||||
"has_ai_payload": False,
|
||||
"from_title": (steps[idx].get("title") if idx < len(steps) else None),
|
||||
"to_title": (steps[idx + 1].get("title") if idx + 1 < len(steps) else None),
|
||||
"from_title": (step_a or {}).get("title"),
|
||||
"to_title": (step_b or {}).get("title"),
|
||||
"primary_topic": (brief.primary_topic if brief else None),
|
||||
}
|
||||
if proposal:
|
||||
offer["has_ai_payload"] = True
|
||||
|
|
@ -317,7 +364,13 @@ def apply_gap_fill_after_qa(
|
|||
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, proposal=None)
|
||||
offer = build_gap_fill_offer(
|
||||
spec=spec,
|
||||
steps=out,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
proposal=None,
|
||||
)
|
||||
offers.append(offer)
|
||||
continue
|
||||
|
||||
|
|
@ -338,7 +391,13 @@ def apply_gap_fill_after_qa(
|
|||
sketch_hint=str(spec.get("sketch") or ""),
|
||||
)
|
||||
|
||||
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=proposal)
|
||||
offer = build_gap_fill_offer(
|
||||
spec=spec,
|
||||
steps=out,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
proposal=proposal,
|
||||
)
|
||||
offers.append(offer)
|
||||
|
||||
if proposal and auto_insert_proposals:
|
||||
|
|
@ -389,6 +448,7 @@ def insert_ai_proposals_for_gaps(
|
|||
|
||||
__all__ = [
|
||||
"apply_gap_fill_after_qa",
|
||||
"build_gap_fill_goal_text",
|
||||
"build_gap_fill_offer",
|
||||
"collect_gap_fill_specs",
|
||||
"insert_ai_proposals_for_gaps",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""
|
||||
Planungs-KI Phase C3/E: Pfad-Vorschläge für Progressionsgraphen.
|
||||
Planungs-KI Phase C3/E/F: Pfad-Vorschläge für Progressionsgraphen.
|
||||
|
||||
Ziel-Freitext → semantisch gewichtete Schritte → Lücken/Brücken → optional LLM-QA.
|
||||
Legacy: retrieval-first. Phase F: optional Roadmap-Preview (A→B→C) parallel — siehe
|
||||
planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ from planning_exercise_path_qa import (
|
|||
detect_path_gaps,
|
||||
insert_bridge_exercises,
|
||||
parse_llm_suggested_new_exercises,
|
||||
strip_off_topic_steps_from_path,
|
||||
try_llm_qa_progression_path,
|
||||
)
|
||||
from planning_exercise_path_ai_fill import apply_gap_fill_after_qa, collect_gap_fill_specs
|
||||
|
|
@ -44,6 +46,10 @@ from planning_exercise_suggest import (
|
|||
_normalize_query,
|
||||
resolve_planning_exercise_intent,
|
||||
)
|
||||
from planning_progression_roadmap import (
|
||||
progression_roadmap_to_api_dict,
|
||||
run_progression_roadmap_pipeline,
|
||||
)
|
||||
from routers.training_planning import _has_planning_role
|
||||
|
||||
|
||||
|
|
@ -55,6 +61,9 @@ class ProgressionPathSuggestRequest(BaseModel):
|
|||
include_llm_path_qa: bool = True
|
||||
include_path_reorder: bool = True
|
||||
include_ai_gap_fill: bool = True
|
||||
include_roadmap_preview: bool = False
|
||||
include_llm_roadmap: bool = True
|
||||
roadmap_first: bool = False
|
||||
progression_graph_id: Optional[int] = Field(default=None, ge=1)
|
||||
exercise_kind_any: Optional[List[str]] = None
|
||||
|
||||
|
|
@ -398,6 +407,7 @@ def suggest_progression_path(
|
|||
ai_proposals: List[Dict[str, Any]] = []
|
||||
gap_fill_offers: List[Dict[str, Any]] = []
|
||||
off_topic_steps: List[Dict[str, Any]] = []
|
||||
stripped_off_topic: List[Dict[str, Any]] = []
|
||||
llm_qa: Optional[Dict[str, Any]] = None
|
||||
llm_qa_applied = False
|
||||
reorder_applied = False
|
||||
|
|
@ -448,6 +458,11 @@ def suggest_progression_path(
|
|||
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
|
||||
|
||||
off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief)
|
||||
steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps)
|
||||
if stripped_off_topic:
|
||||
off_topic_steps = []
|
||||
gaps = detect_path_gaps(cur, steps, brief=semantic_brief)
|
||||
|
||||
llm_gap_specs = parse_llm_suggested_new_exercises(
|
||||
llm_qa,
|
||||
brief=semantic_brief,
|
||||
|
|
@ -455,9 +470,10 @@ def suggest_progression_path(
|
|||
)
|
||||
|
||||
if body.include_ai_gap_fill:
|
||||
fresh_large_gaps = [g for g in gaps if g.get("is_large_gap")]
|
||||
gap_specs = collect_gap_fill_specs(
|
||||
steps=steps,
|
||||
unfilled_gaps=unfilled_gaps,
|
||||
unfilled_gaps=fresh_large_gaps or unfilled_gaps,
|
||||
off_topic_steps=off_topic_steps,
|
||||
llm_specs=llm_gap_specs,
|
||||
brief=semantic_brief,
|
||||
|
|
@ -469,8 +485,8 @@ def suggest_progression_path(
|
|||
gap_specs,
|
||||
goal_query=goal_query,
|
||||
brief=semantic_brief,
|
||||
include_ai_calls=True,
|
||||
max_ai_proposals=3,
|
||||
include_ai_calls=False,
|
||||
max_ai_proposals=0,
|
||||
auto_insert_proposals=False,
|
||||
)
|
||||
|
||||
|
|
@ -480,6 +496,7 @@ def suggest_progression_path(
|
|||
ai_proposals=ai_proposals,
|
||||
gap_fill_offers=gap_fill_offers,
|
||||
off_topic_steps=off_topic_steps,
|
||||
stripped_off_topic=stripped_off_topic,
|
||||
llm_qa=llm_qa,
|
||||
llm_applied=llm_qa_applied,
|
||||
reorder_applied=reorder_applied,
|
||||
|
|
@ -499,6 +516,20 @@ def suggest_progression_path(
|
|||
if gap_fill_offers:
|
||||
retrieval_parts.append("gap_fill_offers")
|
||||
|
||||
progression_roadmap: Optional[Dict[str, Any]] = None
|
||||
if body.include_roadmap_preview or body.roadmap_first:
|
||||
roadmap_ctx = run_progression_roadmap_pipeline(
|
||||
goal_query,
|
||||
max_steps=max_steps,
|
||||
semantic_brief=semantic_brief,
|
||||
cur=cur,
|
||||
include_llm_roadmap=body.include_llm_roadmap,
|
||||
)
|
||||
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
||||
retrieval_parts.append("roadmap_preview")
|
||||
if body.roadmap_first:
|
||||
retrieval_parts.append("roadmap_first_pending")
|
||||
|
||||
return {
|
||||
"goal_query": goal_query,
|
||||
"max_steps_requested": max_steps,
|
||||
|
|
@ -511,6 +542,8 @@ def suggest_progression_path(
|
|||
"progression_graph_id": body.progression_graph_id,
|
||||
"path_qa": path_qa,
|
||||
"gap_fill_offers": gap_fill_offers,
|
||||
"progression_roadmap": progression_roadmap,
|
||||
"roadmap_first": body.roadmap_first,
|
||||
"retrieval_phase": "+".join(retrieval_parts),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -462,6 +462,33 @@ def parse_llm_suggested_new_exercises(
|
|||
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}
|
||||
indices = sorted(by_index.keys(), reverse=True)
|
||||
if len(steps) - len(indices) < min_remaining:
|
||||
return steps, []
|
||||
|
||||
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,
|
||||
|
|
@ -484,6 +511,7 @@ def build_path_qa_summary(
|
|||
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,
|
||||
|
|
@ -502,6 +530,7 @@ def build_path_qa_summary(
|
|||
"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 []),
|
||||
|
|
@ -533,6 +562,7 @@ __all__ = [
|
|||
"build_path_qa_summary",
|
||||
"detect_off_topic_steps",
|
||||
"detect_path_gaps",
|
||||
"strip_off_topic_steps_from_path",
|
||||
"find_step_pair_index",
|
||||
"insert_bridge_exercises",
|
||||
"measure_step_transition_gap",
|
||||
|
|
|
|||
564
backend/planning_progression_roadmap.py
Normal file
564
backend/planning_progression_roadmap.py
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
"""
|
||||
Planungs-KI Phase F: Roadmap-first Progressionsgraph-Pipeline (Workflow-lite).
|
||||
|
||||
Ziel → Roadmap (micro → major) → Stufenspezifikation → danach Retrieval/KI (Phase D/E).
|
||||
|
||||
Kein Gruppenkontext — siehe PLANNING_PROGRESSION_ROADMAP_SPEC.md.
|
||||
|
||||
Prompt-Texte ausschließlich in ``ai_prompts`` (Admin konfigurierbar). Dieses Modul referenziert
|
||||
nur Slugs — siehe ``PROMPT_SLUG_*`` und Migrationen 078/079.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
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,
|
||||
)
|
||||
from planning_exercise_semantics import (
|
||||
PlanningSemanticBrief,
|
||||
brief_to_summary_dict,
|
||||
build_semantic_brief,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_progression_roadmap")
|
||||
|
||||
# Nur Slugs — Templates in DB (ai_prompts), bearbeitbar im Admin.
|
||||
PROMPT_SLUG_GOAL_ANALYSIS = "planning_progression_goal_analysis"
|
||||
PROMPT_SLUG_ROADMAP = "planning_progression_roadmap"
|
||||
PROMPT_SLUG_STAGE_SPEC = "planning_progression_stage_spec"
|
||||
|
||||
_PHASE_ORDER = {
|
||||
"einstieg": 0,
|
||||
"grundlage": 1,
|
||||
"vertiefung": 2,
|
||||
"anwendung": 3,
|
||||
"perfektion": 4,
|
||||
}
|
||||
|
||||
_DEFAULT_MICRO_TEMPLATES: Sequence[tuple[str, str, float]] = (
|
||||
("einstieg", "Einstieg und Orientierung zum Thema", 0.75),
|
||||
("grundlage", "Grundstellung und Basisbewegung", 0.9),
|
||||
("vertiefung", "Koordination und Präzision vertiefen", 0.85),
|
||||
("vertiefung", "Kraft und Geschwindigkeit mit Technikbezug", 0.8),
|
||||
("anwendung", "Anwendung und Kombination", 0.85),
|
||||
("perfektion", "Perfektion und Qualitätssicherung", 0.7),
|
||||
)
|
||||
|
||||
_EXERCISE_TYPE_BY_PHASE = {
|
||||
"einstieg": "kihon_einzel",
|
||||
"grundlage": "kihon_einzel",
|
||||
"vertiefung": "kihon_einzel",
|
||||
"anwendung": "partner_drill",
|
||||
"perfektion": "kombination",
|
||||
}
|
||||
|
||||
_LOAD_BY_PHASE = {
|
||||
"einstieg": ["koordination"],
|
||||
"grundlage": ["koordination", "gleichgewicht"],
|
||||
"vertiefung": ["präzision", "kraft", "geschwindigkeit"],
|
||||
"anwendung": ["timing", "distanz"],
|
||||
"perfektion": ["präzision", "kime"],
|
||||
}
|
||||
|
||||
|
||||
class GoalAnalysisArtifact(BaseModel):
|
||||
primary_topic: str = ""
|
||||
start_assumption: str = ""
|
||||
target_state: str = ""
|
||||
success_criteria: List[str] = Field(default_factory=list)
|
||||
constraints: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MicroObjective(BaseModel):
|
||||
id: str
|
||||
phase: str
|
||||
title: str
|
||||
weight: float = Field(ge=0.0, le=1.0, default=0.8)
|
||||
depends_on: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MajorStep(BaseModel):
|
||||
index: int = Field(ge=0)
|
||||
phase: str
|
||||
learning_goal: str
|
||||
consolidates: List[str] = Field(default_factory=list)
|
||||
rationale: str = ""
|
||||
|
||||
|
||||
class RoadmapArtifact(BaseModel):
|
||||
micro_objectives: List[MicroObjective] = Field(default_factory=list)
|
||||
major_steps: List[MajorStep] = Field(default_factory=list)
|
||||
consolidation_notes: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StageSpecArtifact(BaseModel):
|
||||
major_step_index: int = Field(ge=0)
|
||||
learning_goal: str = ""
|
||||
load_profile: List[str] = Field(default_factory=list)
|
||||
exercise_type: str = ""
|
||||
success_criteria: List[str] = Field(default_factory=list)
|
||||
anti_patterns: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProgressionRoadmapContext(BaseModel):
|
||||
goal_query: str
|
||||
max_steps: int = Field(ge=2, le=10, default=5)
|
||||
semantic_brief: Optional[Dict[str, Any]] = None
|
||||
goal_analysis: Optional[GoalAnalysisArtifact] = None
|
||||
roadmap: Optional[RoadmapArtifact] = None
|
||||
stage_specs: List[StageSpecArtifact] = Field(default_factory=list)
|
||||
pipeline_phase: str = "roadmap_v1"
|
||||
llm_goal_analysis_applied: bool = False
|
||||
llm_roadmap_applied: bool = False
|
||||
llm_stage_spec_applied: bool = False
|
||||
prompt_slugs: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
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 _run_prompt_json(
|
||||
cur,
|
||||
slug: str,
|
||||
variables: Dict[str, str],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
api_key, _ = normalize_openrouter_env()
|
||||
if not api_key or cur is None:
|
||||
return None
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, slug, variables)
|
||||
model = effective_openrouter_model_for_prompt_row(prow)
|
||||
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||
return _extract_json_object(raw)
|
||||
except AiPromptUnavailableError:
|
||||
return None
|
||||
except Exception as exc:
|
||||
_logger.warning("Roadmap-Prompt %s fehlgeschlagen: %s", slug, exc)
|
||||
return None
|
||||
|
||||
|
||||
def try_llm_goal_analysis(
|
||||
cur,
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
) -> Tuple[Optional[GoalAnalysisArtifact], bool]:
|
||||
obj = _run_prompt_json(
|
||||
cur,
|
||||
PROMPT_SLUG_GOAL_ANALYSIS,
|
||||
{
|
||||
"goal_query": goal_query or "",
|
||||
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
if not obj:
|
||||
return None, False
|
||||
try:
|
||||
return GoalAnalysisArtifact.model_validate(obj), True
|
||||
except ValidationError as exc:
|
||||
_logger.warning("Zielanalyse-JSON ungültig: %s", exc)
|
||||
return None, False
|
||||
|
||||
|
||||
def try_llm_roadmap(
|
||||
cur,
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
goal_analysis: GoalAnalysisArtifact,
|
||||
max_steps: int,
|
||||
) -> Tuple[Optional[RoadmapArtifact], bool]:
|
||||
obj = _run_prompt_json(
|
||||
cur,
|
||||
PROMPT_SLUG_ROADMAP,
|
||||
{
|
||||
"goal_query": goal_query or "",
|
||||
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
||||
"goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False),
|
||||
"max_steps": str(int(max_steps)),
|
||||
},
|
||||
)
|
||||
if not obj:
|
||||
return None, False
|
||||
try:
|
||||
micro = [MicroObjective.model_validate(m) for m in (obj.get("micro_objectives") or [])]
|
||||
majors_raw = obj.get("major_steps") or []
|
||||
majors = [MajorStep.model_validate(m) for m in majors_raw]
|
||||
if len(majors) != max_steps:
|
||||
majors, notes = consolidate_micro_to_major(
|
||||
micro or develop_micro_objectives(brief, goal_analysis=goal_analysis, min_count=max_steps + 1),
|
||||
max_steps=max_steps,
|
||||
)
|
||||
obj["consolidation_notes"] = list(obj.get("consolidation_notes") or []) + notes
|
||||
for i, m in enumerate(majors):
|
||||
m.index = i
|
||||
return RoadmapArtifact(
|
||||
micro_objectives=micro,
|
||||
major_steps=majors,
|
||||
consolidation_notes=[str(n) for n in (obj.get("consolidation_notes") or []) if str(n).strip()],
|
||||
), True
|
||||
except ValidationError as exc:
|
||||
_logger.warning("Roadmap-JSON ungültig: %s", exc)
|
||||
return None, False
|
||||
|
||||
|
||||
def try_llm_stage_specs(
|
||||
cur,
|
||||
*,
|
||||
goal_query: str,
|
||||
goal_analysis: GoalAnalysisArtifact,
|
||||
major_steps: Sequence[MajorStep],
|
||||
) -> Tuple[Optional[List[StageSpecArtifact]], bool]:
|
||||
obj = _run_prompt_json(
|
||||
cur,
|
||||
PROMPT_SLUG_STAGE_SPEC,
|
||||
{
|
||||
"goal_query": goal_query or "",
|
||||
"goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False),
|
||||
"major_steps_json": json.dumps([m.model_dump() for m in major_steps], ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
if not obj:
|
||||
return None, False
|
||||
raw_specs = obj.get("stage_specs")
|
||||
if not isinstance(raw_specs, list):
|
||||
return None, False
|
||||
try:
|
||||
specs = [StageSpecArtifact.model_validate(s) for s in raw_specs]
|
||||
return specs, True
|
||||
except ValidationError as exc:
|
||||
_logger.warning("Stufenspez-JSON ungültig: %s", exc)
|
||||
return None, False
|
||||
|
||||
|
||||
def _phase_sort_key(phase: str) -> int:
|
||||
return _PHASE_ORDER.get((phase or "").strip().lower(), 2)
|
||||
|
||||
|
||||
def _topic_label(brief: PlanningSemanticBrief) -> str:
|
||||
return (brief.primary_topic or brief.retrieval_query or "Technik").strip()
|
||||
|
||||
|
||||
def build_goal_analysis(
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
) -> GoalAnalysisArtifact:
|
||||
"""Phase A — deterministisch aus Anfrage + Semantic Brief."""
|
||||
topic = _topic_label(brief)
|
||||
target = goal_query.strip() or f"Entwicklung {topic}"
|
||||
arc = list(brief.development_arc or [])
|
||||
start_phase = arc[0] if arc else "grundlage"
|
||||
target_phase = arc[-1] if arc else "perfektion"
|
||||
criteria: List[str] = []
|
||||
if brief.must_phrases:
|
||||
criteria.extend(brief.must_phrases[:3])
|
||||
if topic:
|
||||
criteria.append(f"klarer Bezug zu {topic}")
|
||||
|
||||
return GoalAnalysisArtifact(
|
||||
primary_topic=topic,
|
||||
start_assumption=(
|
||||
f"Einstieg auf Niveau „{start_phase}“ — Voraussetzungen der Zielgruppe werden im "
|
||||
"Progressionsgraphen nicht analysiert (erst Trainingsplanung)."
|
||||
),
|
||||
target_state=target,
|
||||
success_criteria=criteria or [f"sichere Entwicklung Richtung {target_phase}"],
|
||||
constraints={"partner_required": False, "group_analysis": False},
|
||||
)
|
||||
|
||||
|
||||
def _micro_title_for_phase(phase: str, topic: str) -> str:
|
||||
p = (phase or "vertiefung").lower()
|
||||
labels = {
|
||||
"einstieg": f"Einstieg {topic}",
|
||||
"grundlage": f"{topic} — Grundstellung und Basis",
|
||||
"vertiefung": f"{topic} — Vertiefung",
|
||||
"anwendung": f"{topic} — Anwendung und Kombination",
|
||||
"perfektion": f"{topic} — Perfektion",
|
||||
}
|
||||
return labels.get(p, f"{topic} — {p}")
|
||||
|
||||
|
||||
def develop_micro_objectives(
|
||||
brief: PlanningSemanticBrief,
|
||||
*,
|
||||
goal_analysis: GoalAnalysisArtifact,
|
||||
min_count: int = 6,
|
||||
) -> List[MicroObjective]:
|
||||
"""Phase B1 — Zwischenziele (heuristisch aus development_arc)."""
|
||||
topic = goal_analysis.primary_topic or _topic_label(brief)
|
||||
arc = [str(p).lower() for p in (brief.development_arc or []) if str(p).strip()]
|
||||
seen_phases: set = set()
|
||||
micro: List[MicroObjective] = []
|
||||
|
||||
for i, phase in enumerate(arc):
|
||||
if phase in seen_phases:
|
||||
continue
|
||||
seen_phases.add(phase)
|
||||
mid = f"m{len(micro) + 1}"
|
||||
deps = [f"m{len(micro)}"] if micro else []
|
||||
micro.append(
|
||||
MicroObjective(
|
||||
id=mid,
|
||||
phase=phase,
|
||||
title=_micro_title_for_phase(phase, topic),
|
||||
weight=0.85 if phase in {"grundlage", "vertiefung", "anwendung"} else 0.75,
|
||||
depends_on=deps,
|
||||
)
|
||||
)
|
||||
|
||||
for phase, title_tpl, weight in _DEFAULT_MICRO_TEMPLATES:
|
||||
if len(micro) >= min_count:
|
||||
break
|
||||
if phase in seen_phases:
|
||||
continue
|
||||
seen_phases.add(phase)
|
||||
mid = f"m{len(micro) + 1}"
|
||||
deps = [micro[-1].id] if micro else []
|
||||
title = title_tpl.replace("Thema", topic) if "Thema" in title_tpl else f"{topic} — {title_tpl}"
|
||||
micro.append(
|
||||
MicroObjective(id=mid, phase=phase, title=title, weight=weight, depends_on=deps)
|
||||
)
|
||||
|
||||
supplement_labels = (
|
||||
("vertiefung", "Präzision und Zielpunkt"),
|
||||
("vertiefung", "Kraft und Schnelligkeit"),
|
||||
("anwendung", "Kombination im Ablauf"),
|
||||
)
|
||||
si = 0
|
||||
while len(micro) < min_count and si < len(supplement_labels) * 3:
|
||||
phase, label = supplement_labels[si % len(supplement_labels)]
|
||||
si += 1
|
||||
deps = [micro[-1].id] if micro else []
|
||||
micro.append(
|
||||
MicroObjective(
|
||||
id=f"m{len(micro) + 1}",
|
||||
phase=phase,
|
||||
title=f"{topic} — {label}",
|
||||
weight=0.8,
|
||||
depends_on=deps,
|
||||
)
|
||||
)
|
||||
|
||||
micro.sort(key=lambda m: _phase_sort_key(m.phase))
|
||||
for i, m in enumerate(micro):
|
||||
m.id = f"m{i + 1}"
|
||||
m.depends_on = [f"m{i}"] if i > 0 else []
|
||||
return micro
|
||||
|
||||
|
||||
def consolidate_micro_to_major(
|
||||
micro_objectives: Sequence[MicroObjective],
|
||||
*,
|
||||
max_steps: int,
|
||||
) -> tuple[List[MajorStep], List[str]]:
|
||||
"""Phase B2 — deterministische Konsolidierung auf N Major Steps."""
|
||||
if not micro_objectives:
|
||||
return [], ["Keine Zwischenziele — Fallback leer"]
|
||||
|
||||
n = max(2, min(10, int(max_steps)))
|
||||
notes: List[str] = []
|
||||
if len(micro_objectives) <= n:
|
||||
majors = [
|
||||
MajorStep(
|
||||
index=i,
|
||||
phase=m.phase,
|
||||
learning_goal=m.title,
|
||||
consolidates=[m.id],
|
||||
rationale=f"1:1 aus Zwischenziel {m.id}",
|
||||
)
|
||||
for i, m in enumerate(micro_objectives)
|
||||
]
|
||||
return majors, notes
|
||||
|
||||
notes.append(
|
||||
f"{len(micro_objectives)} Zwischenziele auf {n} Major Steps reduziert (gleichmäßige Abdeckung des Bogens)."
|
||||
)
|
||||
count = len(micro_objectives)
|
||||
majors: List[MajorStep] = []
|
||||
for j in range(n):
|
||||
start = (j * count) // n
|
||||
end = ((j + 1) * count) // n
|
||||
chunk = list(micro_objectives[start:end])
|
||||
if not chunk:
|
||||
continue
|
||||
phase = chunk[len(chunk) // 2].phase
|
||||
consolidates = [c.id for c in chunk]
|
||||
goal = chunk[0].title if len(chunk) == 1 else f"{chunk[0].title} → {chunk[-1].title}"
|
||||
majors.append(
|
||||
MajorStep(
|
||||
index=len(majors),
|
||||
phase=phase,
|
||||
learning_goal=goal,
|
||||
consolidates=consolidates,
|
||||
rationale=f"Konsolidiert {len(chunk)} Zwischenziele ({consolidates[0]}…{consolidates[-1]})",
|
||||
)
|
||||
)
|
||||
for i, step in enumerate(majors):
|
||||
step.index = i
|
||||
return majors, notes
|
||||
|
||||
|
||||
def build_stage_specs(
|
||||
major_steps: Sequence[MajorStep],
|
||||
*,
|
||||
goal_analysis: GoalAnalysisArtifact,
|
||||
) -> List[StageSpecArtifact]:
|
||||
"""Phase C — Stufenspezifikation je Major Step (heuristisch)."""
|
||||
topic = goal_analysis.primary_topic or "Technik"
|
||||
specs: List[StageSpecArtifact] = []
|
||||
for step in major_steps:
|
||||
phase = (step.phase or "vertiefung").lower()
|
||||
specs.append(
|
||||
StageSpecArtifact(
|
||||
major_step_index=step.index,
|
||||
learning_goal=step.learning_goal,
|
||||
load_profile=list(_LOAD_BY_PHASE.get(phase, ["koordination"])),
|
||||
exercise_type=_EXERCISE_TYPE_BY_PHASE.get(phase, "kihon_einzel"),
|
||||
success_criteria=[
|
||||
f"Bezug zu {topic}",
|
||||
f"Phase {phase} erkennbar im Übungsziel",
|
||||
],
|
||||
anti_patterns=[
|
||||
"reine Kraftübung ohne Technikbezug",
|
||||
f"andere Technik als {topic}" if topic else "themenfremde Übung",
|
||||
],
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def run_progression_roadmap_pipeline(
|
||||
goal_query: str,
|
||||
*,
|
||||
max_steps: int = 5,
|
||||
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
||||
cur=None,
|
||||
include_llm_roadmap: bool = False,
|
||||
) -> ProgressionRoadmapContext:
|
||||
"""Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback."""
|
||||
brief = semantic_brief or build_semantic_brief(goal_query)
|
||||
ctx = ProgressionRoadmapContext(
|
||||
goal_query=goal_query.strip(),
|
||||
max_steps=max_steps,
|
||||
semantic_brief=brief_to_summary_dict(brief),
|
||||
)
|
||||
|
||||
goal_analysis = build_goal_analysis(goal_query, brief)
|
||||
if include_llm_roadmap and cur is not None:
|
||||
llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=goal_query, brief=brief)
|
||||
if ga_ok and llm_ga:
|
||||
goal_analysis = llm_ga
|
||||
ctx.llm_goal_analysis_applied = True
|
||||
ctx.prompt_slugs.append(PROMPT_SLUG_GOAL_ANALYSIS)
|
||||
ctx.goal_analysis = goal_analysis
|
||||
|
||||
roadmap: Optional[RoadmapArtifact] = None
|
||||
if include_llm_roadmap and cur is not None:
|
||||
llm_rm, rm_ok = try_llm_roadmap(
|
||||
cur,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
goal_analysis=goal_analysis,
|
||||
max_steps=max_steps,
|
||||
)
|
||||
if rm_ok and llm_rm:
|
||||
roadmap = llm_rm
|
||||
ctx.llm_roadmap_applied = True
|
||||
ctx.prompt_slugs.append(PROMPT_SLUG_ROADMAP)
|
||||
|
||||
if roadmap is None:
|
||||
micro = develop_micro_objectives(
|
||||
brief,
|
||||
goal_analysis=goal_analysis,
|
||||
min_count=max(max_steps + 1, 6),
|
||||
)
|
||||
majors, notes = consolidate_micro_to_major(micro, max_steps=max_steps)
|
||||
roadmap = RoadmapArtifact(
|
||||
micro_objectives=micro,
|
||||
major_steps=majors,
|
||||
consolidation_notes=notes,
|
||||
)
|
||||
ctx.roadmap = roadmap
|
||||
|
||||
stage_specs = build_stage_specs(roadmap.major_steps, goal_analysis=goal_analysis)
|
||||
if include_llm_roadmap and cur is not None:
|
||||
llm_specs, spec_ok = try_llm_stage_specs(
|
||||
cur,
|
||||
goal_query=goal_query,
|
||||
goal_analysis=goal_analysis,
|
||||
major_steps=roadmap.major_steps,
|
||||
)
|
||||
if spec_ok and llm_specs:
|
||||
stage_specs = llm_specs
|
||||
ctx.llm_stage_spec_applied = True
|
||||
ctx.prompt_slugs.append(PROMPT_SLUG_STAGE_SPEC)
|
||||
ctx.stage_specs = stage_specs
|
||||
|
||||
if ctx.llm_goal_analysis_applied or ctx.llm_roadmap_applied or ctx.llm_stage_spec_applied:
|
||||
ctx.pipeline_phase = "roadmap_v1_llm"
|
||||
return ctx
|
||||
|
||||
|
||||
def progression_roadmap_to_api_dict(ctx: ProgressionRoadmapContext) -> Dict[str, Any]:
|
||||
return {
|
||||
"goal_analysis": ctx.goal_analysis.model_dump() if ctx.goal_analysis else None,
|
||||
"roadmap": ctx.roadmap.model_dump() if ctx.roadmap else None,
|
||||
"stage_specs": [s.model_dump() for s in ctx.stage_specs],
|
||||
"pipeline_phase": ctx.pipeline_phase,
|
||||
"major_step_count": len(ctx.roadmap.major_steps) if ctx.roadmap else 0,
|
||||
"micro_objective_count": len(ctx.roadmap.micro_objectives) if ctx.roadmap else 0,
|
||||
"llm_goal_analysis_applied": ctx.llm_goal_analysis_applied,
|
||||
"llm_roadmap_applied": ctx.llm_roadmap_applied,
|
||||
"llm_stage_spec_applied": ctx.llm_stage_spec_applied,
|
||||
"prompt_slugs": list(ctx.prompt_slugs),
|
||||
"prompt_slug_catalog": {
|
||||
"goal_analysis": PROMPT_SLUG_GOAL_ANALYSIS,
|
||||
"roadmap": PROMPT_SLUG_ROADMAP,
|
||||
"stage_spec": PROMPT_SLUG_STAGE_SPEC,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROMPT_SLUG_GOAL_ANALYSIS",
|
||||
"PROMPT_SLUG_ROADMAP",
|
||||
"PROMPT_SLUG_STAGE_SPEC",
|
||||
"GoalAnalysisArtifact",
|
||||
"MajorStep",
|
||||
"MicroObjective",
|
||||
"ProgressionRoadmapContext",
|
||||
"RoadmapArtifact",
|
||||
"StageSpecArtifact",
|
||||
"build_goal_analysis",
|
||||
"build_stage_specs",
|
||||
"consolidate_micro_to_major",
|
||||
"develop_micro_objectives",
|
||||
"progression_roadmap_to_api_dict",
|
||||
"run_progression_roadmap_pipeline",
|
||||
"try_llm_goal_analysis",
|
||||
"try_llm_roadmap",
|
||||
"try_llm_stage_specs",
|
||||
]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests Planungs-KI Phase E3 — Lücken-Angebote und Off-Topic."""
|
||||
from planning_exercise_path_ai_fill import collect_gap_fill_specs
|
||||
from planning_exercise_path_qa import parse_llm_suggested_new_exercises
|
||||
from planning_exercise_path_ai_fill import build_gap_fill_goal_text, collect_gap_fill_specs
|
||||
from planning_exercise_path_qa import parse_llm_suggested_new_exercises, strip_off_topic_steps_from_path
|
||||
from planning_exercise_semantics import build_semantic_brief
|
||||
|
||||
|
||||
|
|
@ -62,3 +62,32 @@ def test_collect_gap_fill_specs_off_topic_and_unfilled():
|
|||
off = next(s for s in specs if s["source"] == "off_topic")
|
||||
assert off["replace_step_index"] == 2
|
||||
assert off["insert_after_index"] == 1
|
||||
|
||||
|
||||
def test_strip_off_topic_steps_from_path():
|
||||
steps = [
|
||||
{"exercise_id": 1, "title": "A"},
|
||||
{"exercise_id": 2, "title": "B"},
|
||||
{"exercise_id": 3, "title": "One Leg Squat"},
|
||||
{"exercise_id": 4, "title": "D"},
|
||||
]
|
||||
off_topic = [{"step_index": 2, "title": "One Leg Squat", "exercise_id": 3}]
|
||||
out, removed = strip_off_topic_steps_from_path(steps, off_topic)
|
||||
assert len(out) == 3
|
||||
assert len(removed) == 1
|
||||
assert removed[0]["removed_title"] == "One Leg Squat"
|
||||
assert [s["exercise_id"] for s in out] == [1, 2, 4]
|
||||
|
||||
|
||||
def test_build_gap_fill_goal_text_includes_topic():
|
||||
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||
text = build_gap_fill_goal_text(
|
||||
goal_query="Mae Geri Perfektion",
|
||||
brief=brief,
|
||||
spec={"phase": "anwendung", "rationale": "Fehlt Kombinationstraining"},
|
||||
step_a={"title": "Kihon"},
|
||||
step_b={"title": "Kumite"},
|
||||
)
|
||||
assert "Mae Geri" in text or "mae geri" in text.lower()
|
||||
assert "anwendung" in text
|
||||
assert "Kihon" in text
|
||||
|
|
|
|||
52
backend/tests/test_planning_progression_roadmap.py
Normal file
52
backend/tests/test_planning_progression_roadmap.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Tests Planungs-KI Phase F — Progressions-Roadmap Pipeline."""
|
||||
from planning_progression_roadmap import (
|
||||
PROMPT_SLUG_GOAL_ANALYSIS,
|
||||
PROMPT_SLUG_ROADMAP,
|
||||
PROMPT_SLUG_STAGE_SPEC,
|
||||
build_goal_analysis,
|
||||
consolidate_micro_to_major,
|
||||
develop_micro_objectives,
|
||||
progression_roadmap_to_api_dict,
|
||||
run_progression_roadmap_pipeline,
|
||||
)
|
||||
from planning_exercise_semantics import build_semantic_brief
|
||||
|
||||
|
||||
def test_run_progression_roadmap_pipeline_major_step_count():
|
||||
ctx = run_progression_roadmap_pipeline(
|
||||
"Von Erlernen bis zur Perfektion des Fußtritts Mae Geri",
|
||||
max_steps=5,
|
||||
)
|
||||
assert ctx.roadmap is not None
|
||||
assert len(ctx.roadmap.major_steps) == 5
|
||||
assert len(ctx.roadmap.micro_objectives) >= 6
|
||||
assert len(ctx.stage_specs) == 5
|
||||
assert ctx.goal_analysis is not None
|
||||
assert "Mae" in ctx.goal_analysis.primary_topic or "mae" in ctx.goal_analysis.primary_topic.lower()
|
||||
|
||||
|
||||
def test_consolidate_micro_to_major_reduces_count():
|
||||
brief = build_semantic_brief("Mae Geri")
|
||||
ga = build_goal_analysis("Mae Geri Perfektion", brief)
|
||||
micro = develop_micro_objectives(brief, goal_analysis=ga, min_count=8)
|
||||
majors, notes = consolidate_micro_to_major(micro, max_steps=5)
|
||||
assert len(majors) == 5
|
||||
if len(micro) > 5:
|
||||
assert notes
|
||||
assert all(m.learning_goal for m in majors)
|
||||
|
||||
|
||||
def test_major_steps_have_learning_goals():
|
||||
ctx = run_progression_roadmap_pipeline("Mae Geri Grundlagen", max_steps=3)
|
||||
for step in ctx.roadmap.major_steps:
|
||||
assert step.learning_goal.strip()
|
||||
assert step.consolidates
|
||||
|
||||
|
||||
def test_api_dict_exposes_prompt_slug_catalog():
|
||||
ctx = run_progression_roadmap_pipeline("Mae Geri", max_steps=3, include_llm_roadmap=False)
|
||||
api = progression_roadmap_to_api_dict(ctx)
|
||||
assert api["prompt_slug_catalog"]["goal_analysis"] == PROMPT_SLUG_GOAL_ANALYSIS
|
||||
assert api["prompt_slug_catalog"]["roadmap"] == PROMPT_SLUG_ROADMAP
|
||||
assert api["prompt_slug_catalog"]["stage_spec"] == PROMPT_SLUG_STAGE_SPEC
|
||||
assert api["prompt_slugs"] == []
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.202"
|
||||
APP_VERSION = "0.8.205"
|
||||
BUILD_DATE = "2026-06-07"
|
||||
DB_SCHEMA_VERSION = "20260606084"
|
||||
DB_SCHEMA_VERSION = "20260606086"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
|
|||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
|
||||
"planning_exercise_suggest": "0.16.2", # feature_usage in KI-Responses nach consume
|
||||
"planning_exercise_suggest": "0.17.1", # F2: Roadmap-LLM via ai_prompts-Slugs, kein Hardcoding
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
@ -53,6 +53,34 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.205",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Phase F2: Roadmap-LLM über konfigurierbare ai_prompts (078/079) — nur Slugs im Code.",
|
||||
"include_llm_roadmap auf progression-path-suggest; Fallback deterministisch.",
|
||||
"Response: prompt_slugs, prompt_slug_catalog, llm_*_applied.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.204",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Planungs-KI Phase F0: Roadmap-first Architektur — planning_progression_roadmap.py (A→B→C).",
|
||||
"API progression-path-suggest: include_roadmap_preview, progression_roadmap in Response.",
|
||||
"Doku: PLANNING_PROGRESSION_ROADMAP_SPEC, PLANNING_KI_ROADMAP; Migration 078 Prompts.",
|
||||
"UI: Didaktische Roadmap-Box im Pfad-Builder (Übergangsphase parallel zu Retrieval).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.203",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Pfad-Builder E3-Fix: themenfremde Schritte (z. B. One Leg Squat) aus Pfad entfernen.",
|
||||
"Lücken-Angebote: kein Pre-KI-Call — voller Entwurf beim Klick mit goal_for_ai-Kontext.",
|
||||
"UI: Skills-Katalog im Preview, maxSteps beim Einfügen einhalten.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.190",
|
||||
"date": "2026-05-23",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-23
|
||||
**App-Version / DB-Schema:** App **`0.8.187`** (Planungs-KI Phase E2); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
|
||||
**Stand:** 2026-06-07
|
||||
**App-Version / DB-Schema:** App **`0.8.204`** (Planungs-KI Phase F0); DB **`20260606085`** — maßgeblich **`backend/version.py`**.
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||
|
||||
|
|
@ -106,15 +106,19 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** |
|
||||
| **E** | Semantik-Schicht (Brief, Phrasen-Score) + Pfad-QA (Lücken, Brücken, LLM-QS) | ✅ **0.8.186** |
|
||||
| **E2** | Pfad-Neuordnung (LLM) + KI-Neuanlage bei unüberbrückbaren Lücken | ✅ **0.8.187** |
|
||||
| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** |
|
||||
| **F0–F1** | **Roadmap-first** Progressionsgraph (A→B→C), Workflow-lite, API-Preview | 🔄 **0.8.204** |
|
||||
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
|
||||
|
||||
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_profiles.py`, `planning_exercise_target_pipeline.py`, `planning_exercise_progression.py` · Router `POST /api/planning/exercise-suggest`
|
||||
**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`**
|
||||
|
||||
**Frontend:** `ExercisePickerModal` (Planung) · **`ExercisesListPageRoot`** — Schalter „Neu mit KI-Assistent“: Planungs-KI-Suche + Neuanlage-Modal (statt „+ Neu“) · `TrainingUnitEditPage` — `planningContext`
|
||||
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_path_builder.py`, **`planning_progression_roadmap.py`** · Router `POST /api/planning/exercise-suggest`, `POST /api/planning/progression-path-suggest` (`include_roadmap_preview`)
|
||||
|
||||
**Frontend:** `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) + bestehender Pfad-Review · `ExercisePickerModal` (Planung)
|
||||
|
||||
**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow
|
||||
|
||||
**Offen (Qualität):** Bibliothek durchgängig mit Skills (Enrichment-Datenarbeit); manuelle Graph-Auswahl in UI; Progressionsgraph-Builder; Skill-Discovery/Framework-Pfade im Pack (P3)
|
||||
**Offen (F2+):** LLM Roadmap (Prompts **078**), `roadmap_first` Retrieval, Roadmap-UI editierbar; Trainingsplanung eigene Pipeline (Gruppenkontext); Enrichment
|
||||
|
||||
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
||||
|
||||
|
|
@ -249,10 +253,11 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
|
||||
### Planungs-KI (priorisiert)
|
||||
|
||||
1. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen.
|
||||
2. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
|
||||
3. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
||||
4. **E3:** KI-Vorschlag im UI direkt anlegen (Modal) · Embeddings für Freitext.
|
||||
1. **Phase F2:** LLM für Roadmap (Prompts **078**) + `roadmap_first` Retrieval aus `stage_specs`.
|
||||
2. **Phase F4:** Roadmap-Review UI (Major Steps editierbar vor Übungs-Match).
|
||||
3. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
|
||||
4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
||||
5. **Trainingsplanung G:** Kontext-Pack Gruppe/Historie — eigene Pipeline (`AI_PLANNING_KI_MULTISTAGE_FORECAST`); Mitai Workflow-Engine erst danach.
|
||||
|
||||
### Allgemein
|
||||
|
||||
|
|
|
|||
86
docs/architecture/PLANNING_KI_ROADMAP.md
Normal file
86
docs/architecture/PLANNING_KI_ROADMAP.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Planungs-KI — Produkt-Roadmap
|
||||
|
||||
**Stand:** 2026-06-07
|
||||
**App-Version:** ab **0.8.204** — maßgeblich `backend/version.py`
|
||||
|
||||
Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**.
|
||||
|
||||
**Leit-Spec:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`
|
||||
|
||||
---
|
||||
|
||||
## Strategische Entscheidung (verbindlich)
|
||||
|
||||
1. **Progressionsgraph:** Planung **vom Ziel rückwärts** (Roadmap-first), nicht Bibliothek-first.
|
||||
2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Thema, Schrittanzahl, optional Graph-Kanten.
|
||||
3. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline später, **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0–S4.
|
||||
4. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`); Mitai Workflow-Engine **später**, wenn 2–3 Pipelines stabil sind.
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Übersicht
|
||||
|
||||
| Phase | Domäne | Kurzbeschreibung | Status |
|
||||
|-------|--------|------------------|--------|
|
||||
| P0–P2 | Übungssuche | Kontext-Pack, Hybrid-Score, LLM-Rerank | ✅ |
|
||||
| A–C2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
|
||||
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
|
||||
| E–E3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
|
||||
| **F0–F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
|
||||
| **F2–F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 |
|
||||
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | 🔲 |
|
||||
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0–S4 | 🔲 |
|
||||
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog |
|
||||
|
||||
---
|
||||
|
||||
## Phase F — Progressions-Roadmap (aktiver Fokus)
|
||||
|
||||
### F0 — Foundation (0.8.204)
|
||||
|
||||
- [x] Spec `PLANNING_PROGRESSION_ROADMAP_SPEC.md`
|
||||
- [x] Modul `planning_progression_roadmap.py` (Pydantic, Pipeline-Skeleton)
|
||||
- [x] Migration **078** Prompt-Slugs (Zielanalyse, Roadmap)
|
||||
- [x] API: `include_roadmap_preview` auf `progression-path-suggest`
|
||||
- [x] Doku: HANDOVER, PLANNING_EXERCISE_SUGGEST_CONTEXT, MULTISTAGE_FORECAST
|
||||
|
||||
### F1 — Deterministische Roadmap
|
||||
|
||||
- [x] Phase A aus Semantic Brief
|
||||
- [x] Phase B: `micro_objectives` aus `development_arc` + Konsolidierung auf N
|
||||
- [x] Phase C: heuristische `stage_specs`
|
||||
- [ ] pytest für Konsolidierung
|
||||
|
||||
### F2 — LLM Roadmap (0.8.205)
|
||||
|
||||
- [x] Prompts **078/079** in `ai_prompts` — Code nur Slugs (`PROMPT_SLUG_*`)
|
||||
- [x] `include_llm_roadmap` + `load_and_render_ai_prompt` + JSON-Validierung
|
||||
- [x] Deterministischer Fallback wenn Prompt/OpenRouter fehlt
|
||||
- [ ] Response/UI: genutzte `prompt_slugs` sichtbar machen (Admin-Hinweis)
|
||||
|
||||
### F3 — roadmap-first
|
||||
|
||||
- [ ] Retrieval pro `major_step` + `stage_spec` statt iterativem Pfad-Bau
|
||||
- [ ] QA/Lücken an Roadmap koppeln
|
||||
|
||||
### F4 — UI
|
||||
|
||||
- [ ] Roadmap-Review im `ExerciseProgressionPathBuilder`
|
||||
- [ ] Major Steps editierbar vor Übungs-Match
|
||||
|
||||
---
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
| Von | Nach | Hinweis |
|
||||
|-----|------|---------|
|
||||
| F2 | Enrichment / Skills | Bessere Roadmap bei technikspezifischen Skills |
|
||||
| F3 | F2 | LLM-Roadmap oder stabile heuristische B |
|
||||
| G | F4 | Trainingsplanung kann Roadmap aus Graph referenzieren |
|
||||
| H | G + F4 | Workflow-Engine lohnt bei verzweigten Planungsflows |
|
||||
|
||||
---
|
||||
|
||||
## Pflege
|
||||
|
||||
Bei Abschluss einer Teilphase: diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`.
|
||||
|
|
@ -14,6 +14,8 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
|
|||
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
|
||||
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
|
||||
| [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) |
|
||||
| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F Roadmap-first, Abgrenzung Trainingsplanung) |
|
||||
| [Progressions-Roadmap Spec](../../.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md) | Phase F: Artefakte A→B→C, API, Workflow-lite |
|
||||
|
||||
## Tests (E2E / Refaktor-Budget)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
||||
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
|
||||
|
||||
**Planungs-KI (parallel):** [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) — Phase **F** Roadmap-first Progressionsgraph (ab 0.8.204), unabhängig von Architektur-Phase 4 API-Split.
|
||||
|
||||
---
|
||||
|
||||
## Leitplanken (vereinbart)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# RBAC, Kontingente & Enforcement — Roadmap
|
||||
|
||||
**Stand:** 2026-06-07 · App **0.8.199** · Schema **20260606083**
|
||||
**Stand:** 2026-06-08 · App **0.8.202** · Schema **20260606084**
|
||||
**Bezüge:** `MEMBERSHIP_RBAC_DECISIONS_2026-06.md`, `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`
|
||||
|
||||
Diese Roadmap bündelt **was fertig ist**, **was als Standard gilt** und **was noch fehlt** — ohne Insellösungen pro Feature.
|
||||
|
|
@ -21,12 +21,15 @@ Admin-Matrix zeigt nur `capabilities.module IS NOT NULL` — keine vorgetäuscht
|
|||
```
|
||||
Auth → Account-State → TenantContext
|
||||
→ probe_capability (Recht)
|
||||
→ probe_club_feature_access (Kontingent)
|
||||
→ probe_member_feature_access (Person, v2/M9 — falls Sub-Budget gesetzt)
|
||||
→ probe_club_feature_access (Vereins-Kontingent)
|
||||
→ Governance (Objekt)
|
||||
→ Business-Logik
|
||||
→ consume_club_feature_with_usage + merge_feature_usage_into_response
|
||||
→ consume (Verein + Person) + merge_feature_usage_into_response
|
||||
```
|
||||
|
||||
**v1 (aktuell):** nur Vereins-Ebene. **v2 (Phase 5b):** Prüfung und Zählung zusätzlich gegen `profile_id` aus der Session.
|
||||
|
||||
### Frontend-Standard
|
||||
|
||||
- `GET /api/me/entitlements` = einzige Quelle für Rechte + Kontingente in der UI
|
||||
|
|
@ -48,7 +51,7 @@ Auth → Account-State → TenantContext
|
|||
| **M2** | Feature-Probe + JSON-Log | ✅ |
|
||||
| **M3** | Capabilities, Account-Lifecycle, Tenant | ✅ (Legacy parallel) |
|
||||
| **M4** | `/me/entitlements`, Badge (KI) | ✅ teilweise |
|
||||
| **M5** | Hard-Block + vollständiger Consume | ⚠️ nur `ai_calls` consume; Enforce **aus** |
|
||||
| **M5** | Hard-Block + vollständiger Consume | ⚠️ `ai_calls` consume + Enforce auf Dev/Prod (0.8.202); Consume andere Features offen |
|
||||
| **M6** | Admin UI Rollen & Rechte | ⚠️ Matrix + Kontingente; kein Plan-/Rollen-CRUD |
|
||||
| **M7** | Vereinsgründung beantragen | ✅ Basis + Capabilities |
|
||||
| **M8** | Stripe | ❌ |
|
||||
|
|
@ -72,8 +75,8 @@ Auth → Account-State → TenantContext
|
|||
|---|--------|--------------|
|
||||
| 2.1 | **Consume erweitern** | `exercises`, `exercise_media` nach Standard-Helfer |
|
||||
| 2.2 | **Badges** | `FeatureUsageBadge` an Create/Upload, nicht nur KI |
|
||||
| 2.3 | **Dev: Enforce** | `CLUB_FEATURE_ENFORCE=1` auf Dev, Free `ai_calls=0` testen |
|
||||
| 2.4 | **Prod-Rollout** | Enforce schrittweise; Kommunikation an Vereine |
|
||||
| 2.3 | **Dev: Enforce** | `CLUB_FEATURE_ENFORCE=1` auf Dev, Free `ai_calls=0` testen | ✅ verifiziert |
|
||||
| 2.4 | **Prod-Rollout** | Enforce schrittweise; Kommunikation an Vereine | ✅ Default Compose=1 |
|
||||
|
||||
### Phase 3 — Capabilities an alle Endpoints (C3–C4)
|
||||
|
||||
|
|
@ -98,7 +101,26 @@ Auth → Account-State → TenantContext
|
|||
|---|--------|--------------|
|
||||
| 5.1 | **Pläne-CRUD** | Neue Vereinspläne anlegen, nicht nur Seed |
|
||||
| 5.2 | **Systemrolle Co-Trainer** | Seed + Matrix |
|
||||
| 5.3 | **Trainer-Budgets** | v2 — `club_member_feature_budgets` |
|
||||
|
||||
### Phase 5b — Kontingent-Verteilung durch Vereinsadmins (M9, Priorität KI-Kosten)
|
||||
|
||||
**Ziel:** Vereins-Kontingent bleibt Plan-Ebene; **Vereinsadmin** verteilt Teilkontingente auf **einzelne Personen** (`profile_id`). Verbrauch und Hard-Block gelten **pro Person** und gegen den Vereins-Pool.
|
||||
|
||||
| # | Paket | Lieferumfang |
|
||||
|---|--------|--------------|
|
||||
| 5b.1 | **Schema** | `club_member_feature_budgets`, `club_member_feature_usage` (Migration); Events mit `profile_id` (bestehend teilweise) |
|
||||
| 5b.2 | **Prüf-Kette** | `probe_capability` → **Mitglieds-Budget** (`profile_id` aus Session) → Vereins-Kontingent → Governance |
|
||||
| 5b.3 | **Consume** | Zählung auf Verein **und** Person; `consume_club_feature_with_usage` erweitern |
|
||||
| 5b.4 | **Entitlements** | `/me/entitlements`: persönliches Budget + Vereins-Rest (z. B. `ai_calls_personal`, `ai_calls_club`) |
|
||||
| 5b.5 | **Vereinsadmin-UI** | Kontingente auf Mitglieder verteilen (Liste/Formular pro Trainer); nur `club_admin` im eigenen Verein |
|
||||
| 5b.6 | **Auswertung** | Admin/Superadmin: Verbrauch **je Person** einsehbar (Fairness, „Kontingent-Fresser“); Filter `profile_id` |
|
||||
| 5b.7 | **Fairness-Modell** | Harte Sub-Budgets (Modell A, Tendenz): Person darf eigenes Limit nicht überschreiten, auch wenn Verein noch Rest hat |
|
||||
|
||||
**Erstes Feature:** `ai_calls` (OpenRouter-Kosten). Später gleiches Muster für andere registrierte Kontingente.
|
||||
|
||||
**Registry:** `register_member_quota_feature()` oder Erweiterung `FeatureRegistration` mit `supports_member_budget: true`.
|
||||
|
||||
Bezug: `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.4.
|
||||
|
||||
### Phase 6 — Abrechnung (M8)
|
||||
|
||||
|
|
@ -139,12 +161,14 @@ Das ersetzt das frühere Formular „Vereinsrollen-Grant hinzufügen“, das nur
|
|||
## 6. Offene Lücken (Checkliste)
|
||||
|
||||
- [ ] `CAPABILITY_ENFORCE=1` in Produktion
|
||||
- [ ] `CLUB_FEATURE_ENFORCE=1` in Produktion
|
||||
- [x] `CLUB_FEATURE_ENFORCE=1` auf Dev (Deploy 0.8.202 verifiziert)
|
||||
- [ ] `CLUB_FEATURE_ENFORCE=1` in Produktion (nach Prod-Deploy bestätigen)
|
||||
- [ ] Consume für alle Features mit Verbrauch (nicht nur `ai_calls`)
|
||||
- [ ] `probe_capability` auf >90 % der Schreib-Endpoints
|
||||
- [ ] Frontend ohne Legacy-Rollen-Guards
|
||||
- [ ] Multipart-Uploads an `featureUsageSync` anbinden
|
||||
- [ ] Legacy-Löschpfade mit Plattform-Bypass harmonisieren
|
||||
- [ ] **M9:** Kontingent-Verteilung Vereinsadmin → Person (`profile_id`), Prüfung + UI
|
||||
- [ ] `HANDOVER.md` / `PROJECT_STATUS` Versionsstand aktualisieren
|
||||
|
||||
---
|
||||
|
|
@ -161,3 +185,4 @@ Das ersetzt das frühere Formular „Vereinsrollen-Grant hinzufügen“, das nur
|
|||
**Changelog**
|
||||
|
||||
- 2026-06-07: Initial nach Session Rollen/Kontingente — Standard, Roadmap Phasen 1–6, Superadmin-Klärung, Matrix-Semantik.
|
||||
- 2026-06-08: Phase 5b / M9 — Kontingent-Verteilung durch Vereinsadmins, personenbezogene Prüfung (`profile_id`); M5 Enforce Dev verifiziert.
|
||||
|
|
|
|||
|
|
@ -60,6 +60,16 @@ const OFFER_SOURCE_LABELS = {
|
|||
llm_suggested: 'QS-Empfehlung',
|
||||
}
|
||||
|
||||
function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
|
||||
const targetName = targetSummary?.focus_areas?.[0]
|
||||
if (targetName && Array.isArray(focusAreas) && focusAreas.length) {
|
||||
const norm = String(targetName).trim().toLowerCase()
|
||||
const hit = focusAreas.find((fa) => String(fa.name || '').trim().toLowerCase() === norm)
|
||||
if (hit?.id) return Number(hit.id)
|
||||
}
|
||||
return focusAreas?.[0]?.id ? Number(focusAreas[0].id) : null
|
||||
}
|
||||
|
||||
export default function ExerciseProgressionPathBuilder({
|
||||
graphId,
|
||||
disabled = false,
|
||||
|
|
@ -76,7 +86,10 @@ export default function ExerciseProgressionPathBuilder({
|
|||
const [pathQa, setPathQa] = useState(null)
|
||||
const [pathSteps, setPathSteps] = useState([])
|
||||
const [gapFillOffers, setGapFillOffers] = useState([])
|
||||
const [progressionRoadmap, setProgressionRoadmap] = useState(null)
|
||||
const [focusAreas, setFocusAreas] = useState([])
|
||||
const [skillsCatalog, setSkillsCatalog] = useState([])
|
||||
const [generatingOfferId, setGeneratingOfferId] = useState(null)
|
||||
|
||||
const [quickCreateOpen, setQuickCreateOpen] = useState(false)
|
||||
const [activeOffer, setActiveOffer] = useState(null)
|
||||
|
|
@ -89,13 +102,20 @@ export default function ExerciseProgressionPathBuilder({
|
|||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api
|
||||
.listFocusAreas({ status: 'active' })
|
||||
.then((rows) => {
|
||||
if (!cancelled) setFocusAreas(Array.isArray(rows) ? rows : [])
|
||||
Promise.all([
|
||||
api.listFocusAreas({ status: 'active' }),
|
||||
api.listSkillsCatalog({ status: 'active' }),
|
||||
])
|
||||
.then(([fa, sk]) => {
|
||||
if (cancelled) return
|
||||
setFocusAreas(Array.isArray(fa) ? fa : [])
|
||||
setSkillsCatalog(Array.isArray(sk) ? sk : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFocusAreas([])
|
||||
if (!cancelled) {
|
||||
setFocusAreas([])
|
||||
setSkillsCatalog([])
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
|
@ -128,7 +148,21 @@ export default function ExerciseProgressionPathBuilder({
|
|||
return rows.map((row, idx) => ({ ...row, isOffTopic: indices.has(idx) }))
|
||||
}
|
||||
|
||||
const insertExerciseFromOffer = useCallback((created, offer) => {
|
||||
const trimPathToMaxSteps = useCallback((rows, limit) => {
|
||||
let next = [...rows]
|
||||
while (next.length > limit) {
|
||||
const offIdx = next.findIndex((s) => s.isOffTopic)
|
||||
if (offIdx >= 0) {
|
||||
next.splice(offIdx, 1)
|
||||
continue
|
||||
}
|
||||
next.pop()
|
||||
}
|
||||
return next.map((r) => ({ ...r, isOffTopic: false }))
|
||||
}, [])
|
||||
|
||||
const insertExerciseFromOffer = useCallback(
|
||||
(created, offer) => {
|
||||
const row = mapCreatedExerciseToRow(created, offer)
|
||||
setPathSteps((prev) => {
|
||||
let next = [...prev]
|
||||
|
|
@ -143,10 +177,12 @@ export default function ExerciseProgressionPathBuilder({
|
|||
} else {
|
||||
next.push(row)
|
||||
}
|
||||
return applyOffTopicFlags(next, pathQa)
|
||||
return trimPathToMaxSteps(next, maxSteps)
|
||||
})
|
||||
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
||||
}, [pathQa])
|
||||
},
|
||||
[maxSteps, trimPathToMaxSteps],
|
||||
)
|
||||
|
||||
const closeQuickCreate = () => {
|
||||
if (quickSaving) return
|
||||
|
|
@ -156,33 +192,68 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setQuickAiError('')
|
||||
}
|
||||
|
||||
const openOfferQuickCreate = (offer) => {
|
||||
setActiveOffer(offer)
|
||||
setQuickTitle((offer?.title_hint || '').trim())
|
||||
setQuickSketch((offer?.sketch || '').trim())
|
||||
const runGapFillAiSuggest = async (offer) => {
|
||||
const title = (offer?.title_hint || '').trim()
|
||||
if (title.length < 3) {
|
||||
alert('Titel-Hinweis fehlt — bitte Pfad erneut vorschlagen.')
|
||||
return
|
||||
}
|
||||
const goalText = (offer?.goal_for_ai || offer?.sketch || '').trim()
|
||||
const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas)
|
||||
if (!focusId) {
|
||||
alert('Kein Fokusbereich verfügbar — bitte Kataloge laden oder manuell wählen.')
|
||||
setQuickTitle(title)
|
||||
setQuickSketch(goalText)
|
||||
setQuickFocusAreaId('')
|
||||
setQuickCreateDraft(null)
|
||||
setQuickAiError('')
|
||||
setActiveOffer(offer)
|
||||
setQuickCreateOpen(true)
|
||||
return
|
||||
}
|
||||
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
|
||||
const focusHint = (focusRow?.name || offer?.primary_topic || '').trim()
|
||||
|
||||
if (offer?.has_ai_payload && offer?.ai_suggestion) {
|
||||
const preview = buildQuickCreateAiPreview(offer.ai_suggestion, {
|
||||
sketchPlain: (offer?.sketch || '').trim(),
|
||||
setActiveOffer(offer)
|
||||
setQuickTitle(title)
|
||||
setQuickSketch(goalText)
|
||||
setQuickFocusAreaId(String(focusId))
|
||||
setQuickAiError('')
|
||||
setQuickCreateDraft(null)
|
||||
setQuickSaving(true)
|
||||
setGeneratingOfferId(offer?.offer_id || null)
|
||||
try {
|
||||
const aiRes = await api.suggestExerciseAi({
|
||||
title,
|
||||
goal: goalText || undefined,
|
||||
execution: '',
|
||||
preparation: '',
|
||||
trainer_notes: '',
|
||||
focus_area_hint: focusHint || undefined,
|
||||
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
|
||||
include_summary: true,
|
||||
include_skills: true,
|
||||
include_instructions: true,
|
||||
})
|
||||
if (preview.hasSummaryProposal || preview.hasSkillChoices || preview.hasInstructionChoices) {
|
||||
const focusId = focusAreas[0]?.id ? String(focusAreas[0].id) : ''
|
||||
setQuickFocusAreaId(focusId)
|
||||
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: goalText })
|
||||
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
|
||||
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
|
||||
}
|
||||
setQuickCreateDraft(
|
||||
aiPreviewToQuickCreateDraft(preview, {
|
||||
title: (offer?.title_hint || '').trim(),
|
||||
focusAreaId: focusId ? Number(focusId) : '',
|
||||
sketchPlain: (offer?.sketch || '').trim(),
|
||||
title,
|
||||
focusAreaId: focusId,
|
||||
sketchPlain: goalText,
|
||||
}),
|
||||
)
|
||||
setQuickCreateOpen(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
const msg = e?.message || String(e)
|
||||
setQuickAiError(msg)
|
||||
setQuickCreateOpen(true)
|
||||
} finally {
|
||||
setQuickSaving(false)
|
||||
setGeneratingOfferId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runQuickCreateAiSuggest = async () => {
|
||||
|
|
@ -276,13 +347,16 @@ export default function ExerciseProgressionPathBuilder({
|
|||
include_llm_path_qa: true,
|
||||
include_path_reorder: true,
|
||||
include_ai_gap_fill: true,
|
||||
include_roadmap_preview: true,
|
||||
include_llm_roadmap: true,
|
||||
progression_graph_id: Number(graphId),
|
||||
})
|
||||
const qa = res?.path_qa || null
|
||||
const rows = applyOffTopicFlags(
|
||||
(Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow),
|
||||
qa,
|
||||
)
|
||||
const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
|
||||
const rows =
|
||||
Array.isArray(qa?.stripped_off_topic_steps) && qa.stripped_off_topic_steps.length > 0
|
||||
? rawRows
|
||||
: applyOffTopicFlags(rawRows, qa)
|
||||
if (rows.length < 2) {
|
||||
throw new Error('Zu wenig Schritte im Vorschlag.')
|
||||
}
|
||||
|
|
@ -297,6 +371,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
? qa.gap_fill_offers
|
||||
: [],
|
||||
)
|
||||
setProgressionRoadmap(res?.progression_roadmap || null)
|
||||
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
|
@ -306,6 +381,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setSemanticBrief(null)
|
||||
setPathQa(null)
|
||||
setGapFillOffers([])
|
||||
setProgressionRoadmap(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -349,6 +425,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setSemanticBrief(null)
|
||||
setPathQa(null)
|
||||
setGapFillOffers([])
|
||||
setProgressionRoadmap(null)
|
||||
if (typeof onSaved === 'function') await onSaved()
|
||||
const msg =
|
||||
skippedAi > 0
|
||||
|
|
@ -437,6 +514,38 @@ export default function ExerciseProgressionPathBuilder({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{progressionRoadmap?.roadmap?.major_steps?.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 45%, var(--border))',
|
||||
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface))',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: '13px' }}>Didaktische Roadmap (Phase F)</strong>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
|
||||
Ziel-zuerst-Planung: {progressionRoadmap.micro_objective_count ?? '?'} Zwischenziele →{' '}
|
||||
{progressionRoadmap.major_step_count ?? progressionRoadmap.roadmap.major_steps.length} Major Steps.
|
||||
{progressionRoadmap.llm_roadmap_applied
|
||||
? ' (KI-Prompts aus Admin-Konfiguration)'
|
||||
: ' (heuristischer Fallback — KI-Prompts in ai_prompts)'}
|
||||
. Übungen unten: Bibliothekssuche (Übergangsphase).
|
||||
</p>
|
||||
<ol style={{ margin: 0, paddingLeft: '18px', fontSize: '13px', lineHeight: 1.5 }}>
|
||||
{progressionRoadmap.roadmap.major_steps.map((step) => (
|
||||
<li key={step.index} style={{ marginBottom: '6px' }}>
|
||||
<span className="exercise-tag" style={{ marginRight: '6px' }}>
|
||||
{step.phase}
|
||||
</span>
|
||||
{step.learning_goal}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pathQa && pathSteps.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -467,7 +576,12 @@ export default function ExerciseProgressionPathBuilder({
|
|||
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
|
||||
</p>
|
||||
) : null}
|
||||
{Number(pathQa.off_topic_count) > 0 ? (
|
||||
{Array.isArray(pathQa.stripped_off_topic_steps) && pathQa.stripped_off_topic_steps.length > 0 ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
|
||||
{pathQa.stripped_off_topic_steps.length} themenfremde(r) Schritt(e) aus dem Pfad entfernt:{' '}
|
||||
{pathQa.stripped_off_topic_steps.map((s) => s.removed_title || s.title).join(', ')}.
|
||||
</p>
|
||||
) : Number(pathQa.off_topic_count) > 0 ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
|
||||
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — siehe Lücken-Angebote unten.
|
||||
</p>
|
||||
|
|
@ -495,7 +609,8 @@ export default function ExerciseProgressionPathBuilder({
|
|||
>
|
||||
<strong style={{ fontSize: '13px' }}>Fehlende Schritte — mit KI anlegen</strong>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
|
||||
Die QS hat Lücken erkannt. Vorschlag prüfen, als Übung anlegen und in den Pfad einfügen.
|
||||
Die QS hat fehlende Zwischenschritte erkannt — sie sind noch nicht im Pfad ({pathSteps.length}/{maxSteps} Schritte).
|
||||
„Mit KI anlegen“ startet einen vollständigen KI-Entwurf (Ziel, Anleitung, Fähigkeiten) und fügt die Übung ein.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{gapFillOffers.map((offer) => (
|
||||
|
|
@ -529,10 +644,17 @@ export default function ExerciseProgressionPathBuilder({
|
|||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '12px', flexShrink: 0 }}
|
||||
disabled={quickSaving}
|
||||
onClick={() => openOfferQuickCreate(offer)}
|
||||
disabled={quickSaving || pathSteps.length >= maxSteps}
|
||||
onClick={() => runGapFillAiSuggest(offer)}
|
||||
title={
|
||||
pathSteps.length >= maxSteps
|
||||
? `Pfad hat bereits ${maxSteps} Schritte — zuerst einen Schritt entfernen.`
|
||||
: 'KI-Entwurf mit Pfad-Kontext generieren'
|
||||
}
|
||||
>
|
||||
Mit KI anlegen
|
||||
{generatingOfferId === offer.offer_id
|
||||
? 'KI erstellt Entwurf …'
|
||||
: 'Mit KI anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -662,6 +784,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setSemanticBrief(null)
|
||||
setPathQa(null)
|
||||
setGapFillOffers([])
|
||||
setProgressionRoadmap(null)
|
||||
}}
|
||||
>
|
||||
Vorschlag verwerfen
|
||||
|
|
@ -696,7 +819,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
}}
|
||||
onApply={applyQuickCreateDraft}
|
||||
focusAreas={focusAreas}
|
||||
skillsCatalog={[]}
|
||||
skillsCatalog={skillsCatalog}
|
||||
dialogTitle="Pfad-Lücke — KI-Entwurf bearbeiten"
|
||||
hint="Texte anpassen, dann als Übung speichern und in den Pfad einfügen."
|
||||
applyLabel={quickSaving ? 'Wird angelegt …' : 'Anlegen und in Pfad einfügen'}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user