Implement Phase C3 Enhancements for Progression Path Suggestion
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
- Incremented version to 0.8.185, reflecting the implementation of Phase C3 features. - Introduced the `POST /api/planning/progression-path-suggest` endpoint for generating exercise progression paths. - Enhanced the ExerciseProgressionGraphPanel with a new ExerciseProgressionPathBuilder for reviewing and saving paths. - Updated changelog to document the new capabilities in planning AI functionality.
This commit is contained in:
parent
a34e748be5
commit
a19ed02300
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Version:** 0.2
|
**Version:** 0.2
|
||||||
**Datum:** 2026-05-23
|
**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)
|
**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** |
|
| **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** |
|
||||||
| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
||||||
| **C2** | Varianten in Trefferliste / Picker | ✅ **0.8.184** |
|
| **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` | 🔲 |
|
| **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).
|
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
|
||||||
- **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen.
|
- **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).
|
- **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**).
|
- **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.
|
- **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).
|
- **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)
|
||||||
|
|
|
||||||
252
backend/planning_exercise_path_builder.py
Normal file
252
backend/planning_exercise_path_builder.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
from tenant_context import TenantContext, get_tenant_context
|
from tenant_context import TenantContext, get_tenant_context
|
||||||
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
|
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"])
|
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
|
||||||
|
|
||||||
|
|
@ -18,3 +19,13 @@ def post_planning_exercise_suggest(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
return suggest_planning_exercises(cur, tenant=tenant, body=body)
|
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)
|
||||||
|
|
|
||||||
25
backend/tests/test_planning_exercise_path_builder.py
Normal file
25
backend/tests/test_planning_exercise_path_builder.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.184"
|
APP_VERSION = "0.8.185"
|
||||||
BUILD_DATE = "2026-05-23"
|
BUILD_DATE = "2026-05-23"
|
||||||
DB_SCHEMA_VERSION = "20260531074"
|
DB_SCHEMA_VERSION = "20260531074"
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
"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_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -44,6 +44,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.184",
|
||||||
"date": "2026-05-23",
|
"date": "2026-05-23",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-23
|
**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**.
|
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** |
|
| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** |
|
||||||
| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
||||||
| **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** |
|
| **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) | 🔲 |
|
| **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`
|
**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)
|
### Planungs-KI (priorisiert)
|
||||||
|
|
||||||
1. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review.
|
1. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen.
|
||||||
2. **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. **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. **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
|
### Allgemein
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
|
||||||
export async function createTrainingUnitFromFrameworkSlot(data) {
|
export async function createTrainingUnitFromFrameworkSlot(data) {
|
||||||
return request('/api/training-units/from-framework-slot', {
|
return request('/api/training-units/from-framework-slot', {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import SkillProfilePanel from './skills/SkillProfilePanel'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import ExercisePickerModal from './ExercisePickerModal'
|
import ExercisePickerModal from './ExercisePickerModal'
|
||||||
|
import ExerciseProgressionPathBuilder from './ExerciseProgressionPathBuilder'
|
||||||
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
|
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
|
||||||
|
|
||||||
const VIS_OPTIONS = [
|
const VIS_OPTIONS = [
|
||||||
|
|
@ -696,6 +697,13 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
defaultExpanded
|
defaultExpanded
|
||||||
artifactType="progression_graph"
|
artifactType="progression_graph"
|
||||||
/>
|
/>
|
||||||
|
<ExerciseProgressionPathBuilder
|
||||||
|
graphId={selectedGraphId}
|
||||||
|
disabled={busy}
|
||||||
|
onSaved={async () => {
|
||||||
|
await refreshEdges(selectedGraphId)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="card" style={{ marginBottom: '12px' }}>
|
<div className="card" style={{ marginBottom: '12px' }}>
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>
|
||||||
|
|
|
||||||
313
frontend/src/components/ExerciseProgressionPathBuilder.jsx
Normal file
313
frontend/src/components/ExerciseProgressionPathBuilder.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: '12px',
|
||||||
|
borderColor: 'color-mix(in srgb, var(--accent) 35%, var(--border))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
||||||
|
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Ziel / Entwicklungsrichtung</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={goalQuery}
|
||||||
|
onChange={(e) => setGoalQuery(e.target.value)}
|
||||||
|
placeholder="z. B. sichere Reaktion im Partnertraining aufbauen …"
|
||||||
|
disabled={disabled || loading || saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ flex: '0 1 120px', marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Schritte</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={10}
|
||||||
|
className="form-input"
|
||||||
|
value={maxSteps}
|
||||||
|
onChange={(e) => setMaxSteps(Math.max(2, Math.min(10, Number(e.target.value) || 5)))}
|
||||||
|
disabled={disabled || loading || saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={disabled || loading || saving || !graphId}
|
||||||
|
onClick={suggestPath}
|
||||||
|
>
|
||||||
|
{loading ? 'Vorschlag …' : 'Pfad vorschlagen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="form-error" style={{ marginTop: '10px' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{targetSummary && pathSteps.length > 0 ? (
|
||||||
|
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
|
{Array.isArray(targetSummary.focus_areas) &&
|
||||||
|
targetSummary.focus_areas.slice(0, 2).map((fa) => (
|
||||||
|
<span key={fa} className="exercise-tag">
|
||||||
|
Fokus: {fa}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{Array.isArray(targetSummary.top_skills) &&
|
||||||
|
targetSummary.top_skills.slice(0, 2).map((sk) => (
|
||||||
|
<span key={sk.skill_id} className="exercise-tag">
|
||||||
|
{sk.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pathSteps.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div style={{ marginTop: '14px' }}>
|
||||||
|
{pathSteps.map((step, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${step.exerciseId}-${idx}`}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
gap: '10px',
|
||||||
|
alignItems: 'end',
|
||||||
|
marginBottom: '12px',
|
||||||
|
paddingBottom: '12px',
|
||||||
|
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">
|
||||||
|
Schritt {idx + 1}
|
||||||
|
{idx === 0 ? ' (Einstieg)' : idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
|
||||||
|
</label>
|
||||||
|
<div style={{ fontSize: '13px' }}>
|
||||||
|
<strong>{step.exerciseTitle}</strong>
|
||||||
|
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
|
||||||
|
</div>
|
||||||
|
{step.reasons?.length ? (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: '6px 0 0',
|
||||||
|
paddingLeft: '16px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--accent-dark)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.reasons.slice(0, 2).map((r) => (
|
||||||
|
<li key={r}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Variante</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={step.variantId ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchStep(idx, {
|
||||||
|
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!step.exerciseId}
|
||||||
|
>
|
||||||
|
<option value="">Gesamte Übung</option>
|
||||||
|
{(step.variants || []).map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.variant_name || `Variante #${v.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||||
|
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveStep(idx, -1)}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveStep(idx, 1)}>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeStep(idx)}>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Notiz für Kanten (Fallback, optional)</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={segmentNotes}
|
||||||
|
onChange={(e) => setSegmentNotes(e.target.value)}
|
||||||
|
placeholder="Wird pro Kante genutzt, wenn keine KI-Begründung vorliegt."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={disabled || saving || pathSteps.filter((s) => s.exerciseId).length < 2}
|
||||||
|
onClick={savePathToGraph}
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern …' : 'Pfad in Graph speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() => {
|
||||||
|
setPathSteps([])
|
||||||
|
setTargetSummary(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Vorschlag verwerfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user