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

- 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:
Lars 2026-06-08 08:10:53 +02:00
parent a9a6153ed5
commit dd0fae4bf5
20 changed files with 1491 additions and 94 deletions

View File

@ -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 M1M3; Roadmap AF.
- 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation.
- 2026-06-07: M4M6 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).

View File

@ -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 S0S4), 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.

View File

@ -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)

View 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: 812 `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.

View File

@ -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

View File

@ -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: 812 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 812 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps.
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion in sinnvoller Reihenfolge (Grundlagen vor Perfektion).
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) = '');

View File

@ -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) = '');

View File

@ -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",

View File

@ -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 (ABC) 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),
}

View File

@ -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",

View 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",
]

View File

@ -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

View 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"] == []

View File

@ -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",

View File

@ -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** |
| **F0F1** | **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

View 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` S0S4.
4. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`); Mitai Workflow-Engine **später**, wenn 23 Pipelines stabil sind.
---
## Phasen-Übersicht
| Phase | Domäne | Kurzbeschreibung | Status |
|-------|--------|------------------|--------|
| P0P2 | Übungssuche | Kontext-Pack, Hybrid-Score, LLM-Rerank | ✅ |
| AC2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
| EE3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
| **F0F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
| **F2F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 |
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | 🔲 |
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0S4 | 🔲 |
| 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`.

View File

@ -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)

View File

@ -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)

View File

@ -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 (C3C4)
@ -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 16, Superadmin-Klärung, Matrix-Semantik.
- 2026-06-08: Phase 5b / M9 — Kontingent-Verteilung durch Vereinsadmins, personenbezogene Prüfung (`profile_id`); M5 Enforce Dev verifiziert.

View File

@ -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'}