diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index c4b10db..dfcc1aa 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -2,7 +2,7 @@ **Version:** 0.2 **Datum:** 2026-05-23 -**Status:** P0–P2 ✅ · Phase A/B/B2 ✅ · **Phase C1 ✅** (Graph auto-match + variantenbewusste Nachfolger) · C2–C3 geplant +**Status:** P0–P2 ✅ · Phase A/B/B2 ✅ · **Phase C1–C3 ✅** (Progressionsgraph + Varianten + Pfad-Builder) **Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph) --- @@ -190,7 +190,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: | **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** | | **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** | | **C2** | Varianten in Trefferliste / Picker | ✅ **0.8.184** | -| **C3** | Graph-Builder (Ziel → Pfad → speichern) | 🔲 | +| **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** | | **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 | --- @@ -211,7 +211,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: - **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage). - **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen. - **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer (**C2** — Dropdown + Graph-Vorschlag). -- **Graph-Builder:** Ziel eingeben → aufbauende Übungen → in Graph speichern (**C3**) — Compound-Nutzen über viele Pläne. +- **Graph-Builder (C3):** Ziel → Pfad vorschlagen → in Graph speichern — ✅ **0.8.185** - **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**). - **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score. - **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073). @@ -427,6 +427,23 @@ Treffer: optional `hits[].suggested_variant_id`. --- -## 21. Phase C3 — Graph-Builder (Roadmap, offen) +## 21. Phase C3 — Graph-Builder (0.8.185) ✅ -Ziel eingeben → aufbauende Übungen vorschlagen → nach Review in Graph speichern (`POST …/edges/sequence`). Nutzen über viele Pläne hinweg. +**API:** `POST /api/planning/progression-path-suggest` + +| Feld | Bedeutung | +|------|-----------| +| `query` | Ziel / Entwicklungsrichtung (Freitext, min. 3 Zeichen) | +| `max_steps` | 2–10, Default 5 | +| `progression_graph_id` | optional — Graph-Kontext für Nachfolger ab Schritt 2 | +| `include_llm_intent` | LLM nur Schritt 1 (Budget) | + +**Response:** `steps[]` mit `exercise_id`, `variant_id`, `title`, `reasons`, `variants`; `retrieval_phase: …+path_builder`. + +**Algorithmus:** Iterativ Hybrid-Ranking — Schritt 1 aus Zielprofil, Folgeschritte mit Anker = letzte Übung, ohne Duplikate. + +**UI:** `ExerciseProgressionPathBuilder` im Progressionsgraph-Panel — Review, Varianten, `POST …/edges/sequence`. + +--- + +## 22. Backlog (offen) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py new file mode 100644 index 0000000..bce5b6b --- /dev/null +++ b/backend/planning_exercise_path_builder.py @@ -0,0 +1,252 @@ +""" +Planungs-KI Phase C3: Pfad-Vorschläge für Progressionsgraphen. + +Ziel-Freitext → iterative Hybrid-Suche (Schritt 1 mit optional LLM-Profil, Folgeschritte deterministisch). +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Set, Tuple + +from fastapi import HTTPException +from pydantic import BaseModel, Field + +from tenant_context import TenantContext, library_content_visibility_sql +from planning_exercise_profiles import PlanningTargetProfile +from planning_exercise_retrieval import run_multistage_planning_retrieval +from planning_exercise_target_pipeline import build_planning_target_with_query_pipeline +from planning_exercise_progression import apply_progression_context_to_pack +from planning_exercise_suggest import ( + INTENT_SUGGEST_NEXT, + _enrich_planning_hits_with_variant_meta, + _intent_weights, + _load_skill_ids_for_exercise, + _normalize_query, + resolve_planning_exercise_intent, +) +from routers.training_planning import _has_planning_role + + +class ProgressionPathSuggestRequest(BaseModel): + query: str = Field(..., min_length=3, max_length=2000) + max_steps: int = Field(default=5, ge=2, le=10) + include_llm_intent: bool = True + progression_graph_id: Optional[int] = Field(default=None, ge=1) + exercise_kind_any: Optional[List[str]] = None + + +def _pick_next_path_hit( + hits: List[Dict[str, Any]], + used_exercise_ids: Set[int], +) -> Optional[Dict[str, Any]]: + for hit in hits: + eid = int(hit["id"]) + if eid in used_exercise_ids: + continue + return hit + return None + + +def _hit_to_path_step(hit: Dict[str, Any]) -> Dict[str, Any]: + raw_vid = hit.get("suggested_variant_id") + variant_id: Optional[int] = None + if raw_vid is not None: + try: + vid = int(raw_vid) + if vid > 0: + variant_id = vid + except (TypeError, ValueError): + variant_id = None + return { + "exercise_id": int(hit["id"]), + "variant_id": variant_id, + "title": hit.get("title"), + "summary": hit.get("summary"), + "score": hit.get("score"), + "reasons": list(hit.get("reasons") or []), + "variants": hit.get("variants") or [], + "suggested_variant_id": hit.get("suggested_variant_id"), + "suggested_variant_name": hit.get("suggested_variant_name"), + } + + +def _run_path_step_retrieval( + cur, + *, + tenant: TenantContext, + goal_query: str, + step_index: int, + planned_ids: List[int], + anchor_id: Optional[int], + anchor_variant_id: Optional[int], + progression_graph_id: Optional[int], + include_llm_intent: bool, + exercise_kind_any: Optional[List[str]], +) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]: + pack: Dict[str, Any] = { + "unit_id": None, + "unit": { + "id": None, + "framework_slot_id": None, + "origin_framework_slot_id": None, + }, + "unit_title": None, + "group_id": None, + "group_name": None, + "section_order_index": None, + "section_title": None, + "section_guidance_notes": goal_query if step_index == 0 else None, + "planned_exercise_ids": list(planned_ids), + "anchor_exercise_id": anchor_id, + "anchor_title": None, + "anchor_skill_ids": sorted(_load_skill_ids_for_exercise(cur, anchor_id)), + "group_recent_exercise_ids": [], + "context_mode": "progression_path", + "has_planning_reference": bool(planned_ids or anchor_id), + } + pack = apply_progression_context_to_pack( + cur, + tenant, + pack, + explicit_graph_id=progression_graph_id, + anchor_variant_id=anchor_variant_id, + ) + + if step_index == 0: + heuristic_intent = resolve_planning_exercise_intent(goal_query, "free_search") + step_query = goal_query + else: + heuristic_intent = INTENT_SUGGEST_NEXT + step_query = "nächste sinnvolle übung im pfad" + + has_plan_ref = bool(pack.get("has_planning_reference")) or step_index > 0 + pipeline_context = { + "unit_title": None, + "group_name": None, + "section_title": pack.get("section_title"), + "section_guidance_notes": pack.get("section_guidance_notes"), + "section_exercise_count": len(planned_ids), + "planned_count": len(planned_ids), + "anchor_title": pack.get("anchor_title"), + "anchor_exercise_id": pack.get("anchor_exercise_id"), + "last_section_exercise_title": None, + "progression_graph_id": pack.get("progression_graph_id"), + "unit_skill_profile": None, + "section_skill_profile": None, + "has_planning_reference": has_plan_ref, + "expectation_mode": "query_only" if step_index == 0 and not planned_ids else "planning_hybrid", + } + + target_profile, intent, _scenario, query_intent_summary = build_planning_target_with_query_pipeline( + cur, + unit=pack["unit"], + planned_exercise_ids=pack["planned_exercise_ids"], + section_planned_exercise_ids=[], + anchor_exercise_id=pack.get("anchor_exercise_id"), + query=goal_query if step_index == 0 else step_query, + heuristic_intent=heuristic_intent, + include_llm_intent=include_llm_intent and step_index == 0, + context_summary=pipeline_context, + has_planning_reference=has_plan_ref, + ) + + weights = _intent_weights(intent) + profile_id = tenant.profile_id + role = tenant.global_role + vis_sql, vis_params = library_content_visibility_sql( + alias="e", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + + hits, _skills_by_ex, _full_lib = run_multistage_planning_retrieval( + cur, + vis_sql=vis_sql, + vis_params=vis_params, + query=step_query if step_index > 0 else goal_query, + exercise_kind_any=exercise_kind_any, + target=target_profile, + intent=intent, + intent_weights=weights, + pack=pack, + ) + hits = _enrich_planning_hits_with_variant_meta(cur, hits[:32]) + return hits, target_profile, query_intent_summary, intent + + +def suggest_progression_path( + cur, + *, + tenant: TenantContext, + body: ProgressionPathSuggestRequest, +) -> Dict[str, Any]: + role = tenant.global_role + if not _has_planning_role(role): + raise HTTPException(status_code=403, detail="Nur Trainer dürfen Pfad-Vorschläge abrufen") + + goal_query = _normalize_query(body.query) + if len(goal_query) < 3: + raise HTTPException(status_code=400, detail="Ziel-Anfrage: mindestens 3 Zeichen") + + max_steps = int(body.max_steps) + used: Set[int] = set() + steps: List[Dict[str, Any]] = [] + planned_ids: List[int] = [] + anchor_id: Optional[int] = None + anchor_variant_id: Optional[int] = None + target_profile: Optional[PlanningTargetProfile] = None + first_intent_summary: Dict[str, Any] = {} + + for step_index in range(max_steps): + hits, target_profile, query_intent_summary, _intent = _run_path_step_retrieval( + cur, + tenant=tenant, + goal_query=goal_query, + step_index=step_index, + planned_ids=planned_ids, + anchor_id=anchor_id, + anchor_variant_id=anchor_variant_id, + progression_graph_id=body.progression_graph_id, + include_llm_intent=body.include_llm_intent, + exercise_kind_any=body.exercise_kind_any, + ) + if step_index == 0: + first_intent_summary = query_intent_summary + + hit = _pick_next_path_hit(hits, used) + if not hit: + break + + step = _hit_to_path_step(hit) + steps.append(step) + eid = int(step["exercise_id"]) + used.add(eid) + planned_ids.append(eid) + anchor_id = eid + anchor_variant_id = step.get("variant_id") + + if len(steps) < 2: + raise HTTPException( + status_code=422, + detail="Zu wenig passende Übungen für einen Pfad (mindestens 2 Schritte). Ziel präzisieren oder max_steps senken.", + ) + + target_profile_summary = target_profile.to_summary_dict(cur) if target_profile else None + + return { + "goal_query": goal_query, + "max_steps_requested": max_steps, + "steps": steps, + "step_count": len(steps), + "target_profile_summary": target_profile_summary, + "query_intent_summary": first_intent_summary, + "progression_graph_id": body.progression_graph_id, + "retrieval_phase": "profile_v1+full_library+path_builder", + } + + +__all__ = [ + "ProgressionPathSuggestRequest", + "suggest_progression_path", + "_pick_next_path_hit", +] diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index 310fd5f..d1f33af 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends from db import get_db, get_cursor from tenant_context import TenantContext, get_tenant_context from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises +from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"]) @@ -18,3 +19,13 @@ def post_planning_exercise_suggest( with get_db() as conn: cur = get_cursor(conn) return suggest_planning_exercises(cur, tenant=tenant, body=body) + + +@router.post("/progression-path-suggest") +def post_progression_path_suggest( + body: ProgressionPathSuggestRequest, + tenant: TenantContext = Depends(get_tenant_context), +): + with get_db() as conn: + cur = get_cursor(conn) + return suggest_progression_path(cur, tenant=tenant, body=body) diff --git a/backend/tests/test_planning_exercise_path_builder.py b/backend/tests/test_planning_exercise_path_builder.py new file mode 100644 index 0000000..2d45692 --- /dev/null +++ b/backend/tests/test_planning_exercise_path_builder.py @@ -0,0 +1,25 @@ +"""Tests Planungs-KI Phase C3 — Pfad-Vorschläge.""" +from planning_exercise_path_builder import _pick_next_path_hit, _hit_to_path_step + + +def test_pick_next_path_hit_skips_used(): + hits = [{"id": 1, "title": "A"}, {"id": 2, "title": "B"}, {"id": 3, "title": "C"}] + assert _pick_next_path_hit(hits, {1})["id"] == 2 + assert _pick_next_path_hit(hits, {1, 2, 3}) is None + + +def test_hit_to_path_step_maps_variant(): + step = _hit_to_path_step( + { + "id": 10, + "title": "Test", + "score": 0.8, + "reasons": ["Graph"], + "suggested_variant_id": 7, + "suggested_variant_name": "Leicht", + "variants": [{"id": 7, "variant_name": "Leicht"}], + } + ) + assert step["exercise_id"] == 10 + assert step["variant_id"] == 7 + assert step["suggested_variant_name"] == "Leicht" diff --git a/backend/version.py b/backend/version.py index 6736e14..7ae0322 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.184" +APP_VERSION = "0.8.185" BUILD_DATE = "2026-05-23" DB_SCHEMA_VERSION = "20260531074" @@ -29,7 +29,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay - "planning_exercise_suggest": "0.12.0", # Phase C2: Varianten in Treffern + Übernahme + "planning_exercise_suggest": "0.13.0", # Phase C3: progression-path-suggest für Graph-Builder "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 @@ -44,6 +44,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.185", + "date": "2026-05-23", + "changes": [ + "Planungs-KI Phase C3: POST /api/planning/progression-path-suggest — Ziel → aufbauender Übungspfad.", + "Progressionsgraph-UI: KI-Pfad-Builder mit Review und Speichern via edges/sequence.", + ], + }, { "version": "0.8.184", "date": "2026-05-23", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index ac77fa4..907bb66 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.184`** (Planungs-KI Phase C2); DB **`20260531074`** — maßgeblich **`backend/version.py`**. +**App-Version / DB-Schema:** App **`0.8.185`** (Planungs-KI Phase C3); DB **`20260531074`** — 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**. @@ -103,7 +103,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl | **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** | | **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** | | **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** | -| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | 🔲 | +| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** | | **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` @@ -247,10 +247,10 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl ### Planungs-KI (priorisiert) -1. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review. -2. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen. -3. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking). -4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`. +1. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen. +2. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking). +3. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`. +4. **C3 Feinschliff:** Einzelschritte im Pfad manuell ersetzen (Picker); Pfad an bestehende Kette anhängen. ### Allgemein diff --git a/frontend/src/api/planning.js b/frontend/src/api/planning.js index f67e49b..08b9144 100644 --- a/frontend/src/api/planning.js +++ b/frontend/src/api/planning.js @@ -84,6 +84,14 @@ export async function suggestPlanningExercises(body = {}) { }) } +/** Planungs-KI Phase C3: aufbauender Übungspfad für Progressionsgraphen. */ +export async function suggestProgressionPath(body = {}) { + return request('/api/planning/progression-path-suggest', { + method: 'POST', + body: JSON.stringify(body), + }) +} + /** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */ export async function createTrainingUnitFromFrameworkSlot(data) { return request('/api/training-units/from-framework-slot', { diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 81e4d77..cd92620 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -9,6 +9,7 @@ import SkillProfilePanel from './skills/SkillProfilePanel' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' import ExercisePickerModal from './ExercisePickerModal' +import ExerciseProgressionPathBuilder from './ExerciseProgressionPathBuilder' import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' const VIS_OPTIONS = [ @@ -696,6 +697,13 @@ export default function ExerciseProgressionGraphPanel({ defaultExpanded artifactType="progression_graph" /> + { + await refreshEdges(selectedGraphId) + }} + />

Sequenz / Reihe anlegen

diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx new file mode 100644 index 0000000..90ae429 --- /dev/null +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -0,0 +1,313 @@ +/** + * Planungs-KI Phase C3: Ziel → Übungspfad vorschlagen → in Progressionsgraph speichern. + */ +import React, { useCallback, useState } from 'react' +import api from '../utils/api' + +function emptyPathStep() { + return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] } +} + +function mapApiStepToRow(step) { + const variants = Array.isArray(step?.variants) ? step.variants : [] + const rawVid = step?.variant_id ?? step?.suggested_variant_id ?? null + const variantId = + rawVid != null && Number.isFinite(Number(rawVid)) && Number(rawVid) > 0 ? Number(rawVid) : null + return { + exerciseId: step?.exercise_id != null ? Number(step.exercise_id) : null, + exerciseTitle: (step?.title || '').trim() || (step?.exercise_id ? `Übung #${step.exercise_id}` : ''), + variantId, + variants, + reasons: Array.isArray(step?.reasons) ? step.reasons : [], + } +} + +export default function ExerciseProgressionPathBuilder({ + graphId, + disabled = false, + onSaved, +}) { + const [goalQuery, setGoalQuery] = useState('') + const [maxSteps, setMaxSteps] = useState(5) + const [segmentNotes, setSegmentNotes] = useState('') + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [targetSummary, setTargetSummary] = useState(null) + const [pathSteps, setPathSteps] = useState([]) + + const patchStep = useCallback((idx, patch) => { + setPathSteps((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row))) + }, []) + + const removeStep = useCallback((idx) => { + setPathSteps((prev) => (prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx))) + }, []) + + const moveStep = useCallback((idx, dir) => { + setPathSteps((prev) => { + const j = idx + dir + if (j < 0 || j >= prev.length) return prev + const next = [...prev] + const t = next[idx] + next[idx] = next[j] + next[j] = t + return next + }) + }, []) + + const suggestPath = async () => { + const q = (goalQuery || '').trim() + if (q.length < 3) { + alert('Ziel-Anfrage: mindestens 3 Zeichen.') + return + } + if (!graphId) { + alert('Zuerst einen Graphen wählen.') + return + } + setLoading(true) + setError('') + try { + const res = await api.suggestProgressionPath({ + query: q, + max_steps: Number(maxSteps), + include_llm_intent: true, + progression_graph_id: Number(graphId), + }) + const rows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow) + if (rows.length < 2) { + throw new Error('Zu wenig Schritte im Vorschlag.') + } + setPathSteps(rows) + setTargetSummary(res?.target_profile_summary || null) + if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400)) + } catch (e) { + console.error(e) + setError(e.message || 'Pfad-Vorschlag fehlgeschlagen') + setPathSteps([]) + setTargetSummary(null) + } finally { + setLoading(false) + } + } + + const savePathToGraph = async () => { + if (!graphId) { + alert('Zuerst einen Graphen wählen.') + return + } + const steps = pathSteps.filter((s) => s.exerciseId != null) + if (steps.length < 2) { + alert('Mindestens zwei Schritte mit Übung nötig.') + return + } + const n = steps.length - 1 + const noteRaw = segmentNotes.trim() + const segment_notes = Array.from({ length: n }, (_, i) => { + const reasons = (steps[i + 1]?.reasons || []).slice(0, 2).join(' · ') + if (reasons) return reasons + return noteRaw || null + }) + + setSaving(true) + setError('') + try { + await api.createExerciseProgressionSequence(Number(graphId), { + steps: steps.map((s) => ({ + exercise_id: s.exerciseId, + variant_id: s.variantId || null, + })), + segment_notes, + }) + setPathSteps([]) + setTargetSummary(null) + if (typeof onSaved === 'function') await onSaved() + alert(`${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.`) + } catch (e) { + console.error(e) + setError(e.message || 'Speichern fehlgeschlagen') + } finally { + setSaving(false) + } + } + + return ( +

+

KI: Pfad zum Ziel

+

+ Ziel in Freitext formulieren — die Planungs-KI schlägt eine aufbauende Übungsreihe vor. Nach Review als + Nachfolger-Ketten in den aktiven Graph speichern (über mehrere Trainingspläne hinweg nutzbar). +

+
+
+ + setGoalQuery(e.target.value)} + placeholder="z. B. sichere Reaktion im Partnertraining aufbauen …" + disabled={disabled || loading || saving} + /> +
+
+ + setMaxSteps(Math.max(2, Math.min(10, Number(e.target.value) || 5)))} + disabled={disabled || loading || saving} + /> +
+ +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {targetSummary && pathSteps.length > 0 ? ( +
+ {Array.isArray(targetSummary.focus_areas) && + targetSummary.focus_areas.slice(0, 2).map((fa) => ( + + Fokus: {fa} + + ))} + {Array.isArray(targetSummary.top_skills) && + targetSummary.top_skills.slice(0, 2).map((sk) => ( + + {sk.name} + + ))} +
+ ) : null} + + {pathSteps.length > 0 ? ( + <> +
+ {pathSteps.map((step, idx) => ( +
+
+ +
+ {step.exerciseTitle} + (#{step.exerciseId}) +
+ {step.reasons?.length ? ( +
    + {step.reasons.slice(0, 2).map((r) => ( +
  • {r}
  • + ))} +
+ ) : null} +
+
+ + +
+
+ + + +
+
+ ))} +
+
+ +