From dd0fae4bf5e9fb3c5d0a0c5aa66b176686e73e8a Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 8 Jun 2026 08:10:53 +0200 Subject: [PATCH] Enhance Planning AI with Roadmap-First Architecture and New Features - 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. --- .../MEMBERSHIP_RBAC_DECISIONS_2026-06.md | 8 +- .../AI_PLANNING_KI_MULTISTAGE_FORECAST.md | 14 +- .../PLANNING_EXERCISE_SUGGEST_CONTEXT.md | 28 +- .../PLANNING_PROGRESSION_ROADMAP_SPEC.md | 198 ++++++ CLAUDE.md | 1 + ...ai_prompt_planning_progression_roadmap.sql | 74 +++ ...prompt_planning_progression_stage_spec.sql | 43 ++ backend/planning_exercise_path_ai_fill.py | 68 ++- backend/planning_exercise_path_builder.py | 43 +- backend/planning_exercise_path_qa.py | 30 + backend/planning_progression_roadmap.py | 564 ++++++++++++++++++ .../test_planning_exercise_path_ai_fill.py | 33 +- .../test_planning_progression_roadmap.py | 52 ++ backend/version.py | 34 +- docs/HANDOVER.md | 23 +- docs/architecture/PLANNING_KI_ROADMAP.md | 86 +++ docs/architecture/README.md | 2 + docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 2 + docs/working/RBAC_ENFORCEMENT_ROADMAP.md | 41 +- .../ExerciseProgressionPathBuilder.jsx | 241 ++++++-- 20 files changed, 1491 insertions(+), 94 deletions(-) create mode 100644 .claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md create mode 100644 backend/migrations/078_ai_prompt_planning_progression_roadmap.sql create mode 100644 backend/migrations/079_ai_prompt_planning_progression_stage_spec.sql create mode 100644 backend/planning_progression_roadmap.py create mode 100644 backend/tests/test_planning_progression_roadmap.py create mode 100644 docs/architecture/PLANNING_KI_ROADMAP.md diff --git a/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md b/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md index f86ac2f..07f8d43 100644 --- a/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md +++ b/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md @@ -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). diff --git a/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md b/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md index a950e32..942fc64 100644 --- a/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md +++ b/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md @@ -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. diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index e2d8e9c..e4a383e 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -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) diff --git a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md new file mode 100644 index 0000000..5961298 --- /dev/null +++ b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index ebea17c..f8d36f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/backend/migrations/078_ai_prompt_planning_progression_roadmap.sql b/backend/migrations/078_ai_prompt_planning_progression_roadmap.sql new file mode 100644 index 0000000..10b5b30 --- /dev/null +++ b/backend/migrations/078_ai_prompt_planning_progression_roadmap.sql @@ -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) = ''); diff --git a/backend/migrations/079_ai_prompt_planning_progression_stage_spec.sql b/backend/migrations/079_ai_prompt_planning_progression_stage_spec.sql new file mode 100644 index 0000000..099140a --- /dev/null +++ b/backend/migrations/079_ai_prompt_planning_progression_stage_spec.sql @@ -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) = ''); diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py index a1bd294..6034203 100644 --- a/backend/planning_exercise_path_ai_fill.py +++ b/backend/planning_exercise_path_ai_fill.py @@ -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", diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 42455c1..c0f1022 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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), } diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 8ea7c8f..aad489e 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -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", diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py new file mode 100644 index 0000000..c8c90a8 --- /dev/null +++ b/backend/planning_progression_roadmap.py @@ -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", +] diff --git a/backend/tests/test_planning_exercise_path_ai_fill.py b/backend/tests/test_planning_exercise_path_ai_fill.py index a94a4f8..8f7d1ca 100644 --- a/backend/tests/test_planning_exercise_path_ai_fill.py +++ b/backend/tests/test_planning_exercise_path_ai_fill.py @@ -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 diff --git a/backend/tests/test_planning_progression_roadmap.py b/backend/tests/test_planning_progression_roadmap.py new file mode 100644 index 0000000..645f605 --- /dev/null +++ b/backend/tests/test_planning_progression_roadmap.py @@ -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"] == [] diff --git a/backend/version.py b/backend/version.py index 8fdb24c..30c02de 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index e81096b..9fb25b6 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -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 diff --git a/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md new file mode 100644 index 0000000..d8979b1 --- /dev/null +++ b/docs/architecture/PLANNING_KI_ROADMAP.md @@ -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`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 9a96ddf..40061f6 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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) diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md index b63ed74..9e4c043 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -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) diff --git a/docs/working/RBAC_ENFORCEMENT_ROADMAP.md b/docs/working/RBAC_ENFORCEMENT_ROADMAP.md index 288fbbf..b813739 100644 --- a/docs/working/RBAC_ENFORCEMENT_ROADMAP.md +++ b/docs/working/RBAC_ENFORCEMENT_ROADMAP.md @@ -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. diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index 3dc6833..2808018 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -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,25 +148,41 @@ export default function ExerciseProgressionPathBuilder({ return rows.map((row, idx) => ({ ...row, isOffTopic: indices.has(idx) })) } - const insertExerciseFromOffer = useCallback((created, offer) => { - const row = mapCreatedExerciseToRow(created, offer) - setPathSteps((prev) => { - let next = [...prev] - const afterIdx = Number(offer?.insert_after_index) - const replaceIdx = - offer?.replace_step_index != null ? Number(offer.replace_step_index) : null - - if (Number.isFinite(replaceIdx) && replaceIdx >= 0 && replaceIdx < next.length) { - next.splice(replaceIdx, 1, row) - } else if (Number.isFinite(afterIdx) && afterIdx >= 0 && afterIdx < next.length) { - next.splice(afterIdx + 1, 0, row) - } else { - next.push(row) + 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 } - return applyOffTopicFlags(next, pathQa) - }) - setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) - }, [pathQa]) + next.pop() + } + return next.map((r) => ({ ...r, isOffTopic: false })) + }, []) + + const insertExerciseFromOffer = useCallback( + (created, offer) => { + const row = mapCreatedExerciseToRow(created, offer) + setPathSteps((prev) => { + let next = [...prev] + const afterIdx = Number(offer?.insert_after_index) + const replaceIdx = + offer?.replace_step_index != null ? Number(offer.replace_step_index) : null + + if (Number.isFinite(replaceIdx) && replaceIdx >= 0 && replaceIdx < next.length) { + next.splice(replaceIdx, 1, row) + } else if (Number.isFinite(afterIdx) && afterIdx >= 0 && afterIdx < next.length) { + next.splice(afterIdx + 1, 0, row) + } else { + next.push(row) + } + return trimPathToMaxSteps(next, maxSteps) + }) + setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) + }, + [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()) - setQuickFocusAreaId('') - setQuickCreateDraft(null) - setQuickAiError('') - - if (offer?.has_ai_payload && offer?.ai_suggestion) { - const preview = buildQuickCreateAiPreview(offer.ai_suggestion, { - sketchPlain: (offer?.sketch || '').trim(), - }) - if (preview.hasSummaryProposal || preview.hasSkillChoices || preview.hasInstructionChoices) { - const focusId = focusAreas[0]?.id ? String(focusAreas[0].id) : '' - setQuickFocusAreaId(focusId) - setQuickCreateDraft( - aiPreviewToQuickCreateDraft(preview, { - title: (offer?.title_hint || '').trim(), - focusAreaId: focusId ? Number(focusId) : '', - sketchPlain: (offer?.sketch || '').trim(), - }), - ) - setQuickCreateOpen(false) - return - } + 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('') + setActiveOffer(offer) + setQuickCreateOpen(true) + return + } + const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId) + const focusHint = (focusRow?.name || offer?.primary_topic || '').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, + }) + 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, + focusAreaId: focusId, + sketchPlain: goalText, + }), + ) + setQuickCreateOpen(false) + } catch (e) { + console.error(e) + const msg = e?.message || String(e) + setQuickAiError(msg) + setQuickCreateOpen(true) + } finally { + setQuickSaving(false) + setGeneratingOfferId(null) } - setQuickCreateOpen(true) } 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({ ) : null} + {progressionRoadmap?.roadmap?.major_steps?.length > 0 ? ( +
+ Didaktische Roadmap (Phase F) +

+ 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). +

+
    + {progressionRoadmap.roadmap.major_steps.map((step) => ( +
  1. + + {step.phase} + + {step.learning_goal} +
  2. + ))} +
+
+ ) : null} + {pathQa && pathSteps.length > 0 ? (
) : null} - {Number(pathQa.off_topic_count) > 0 ? ( + {Array.isArray(pathQa.stripped_off_topic_steps) && pathQa.stripped_off_topic_steps.length > 0 ? ( +

+ {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(', ')}. +

+ ) : Number(pathQa.off_topic_count) > 0 ? (

{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — siehe Lücken-Angebote unten.

@@ -495,7 +609,8 @@ export default function ExerciseProgressionPathBuilder({ > Fehlende Schritte — mit KI anlegen

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

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