Implement Phase E2 Enhancements for Planning Exercise Suggestion
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 51s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m20s

- Introduced path reordering functionality using LLM with `ordered_step_indices`, allowing for dynamic adjustment of exercise progression paths.
- Added AI gap filling capabilities, enabling the system to propose new exercises when unbridgeable gaps are detected.
- Updated the backend to support new request parameters for path reordering and AI gap filling.
- Enhanced frontend components to reflect these new features, including alerts for AI proposals and adjustments in exercise display.
- Incremented version to 0.8.187 and updated changelog to document these significant enhancements in planning AI functionality.
This commit is contained in:
Lars 2026-05-23 12:32:14 +02:00
parent c6b8c396ad
commit c2c736dafc
9 changed files with 447 additions and 35 deletions

View File

@ -192,6 +192,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
| **C2** | Varianten in Trefferliste / Picker | ✅ **0.8.184** |
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** |
| **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** |
| **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** |
| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 |
---
@ -477,6 +478,12 @@ Nach Pfad-Bildung:
**Pfad-Schritte:** Semantic Brief + Entwicklungsphase in **allen** Schritten (nicht nur Schritt 1).
### Phase E2 (0.8.187)
- **LLM-QS → Neuordnung:** `ordered_step_indices` im Prompt `planning_exercise_path_qa` (Migration **076**)
- **KI-Lückenfüller:** `planning_exercise_path_ai_fill.py``is_ai_proposal` wenn Bibliothek keine Brücke liefert
- Request: `include_path_reorder`, `include_ai_gap_fill`
---
## 23. Backlog (offen)

View File

@ -0,0 +1,60 @@
-- Migration 076: Planungs-Pfad-QA — Neuordnung + KI-Lückenfüller (Phase E2)
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""]
}$t$,
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""]
}$t$
WHERE slug = 'planning_exercise_path_qa';

View File

@ -0,0 +1,196 @@
"""
Planungs-KI Phase E2: KI-Neuanlage-Vorschläge für unüberbrückbare Pfad-Lücken.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Mapping, Optional
import uuid
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_semantics import PlanningSemanticBrief
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
def _build_gap_ai_context(
*,
goal_query: str,
brief: PlanningSemanticBrief,
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
) -> ExerciseFormAiPromptContext:
topic = (brief.primary_topic or "Technik").strip()
phase = gap.get("expected_phase") or "vertiefung"
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
title = f"Brücke {topic} ({phase})"
goal = (
f"Planungsziel: {goal_query}\n\n"
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.\n"
f"Phase: {phase}. Thema: {topic}. "
f"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor."
)
focus_hint = topic if brief.topic_type == "technique" else None
if brief.must_phrases:
focus_hint = ", ".join(brief.must_phrases[:2])
return ExerciseFormAiPromptContext(
title=title[:280],
goal=goal[:8000],
execution=None,
focus_hint=focus_hint,
)
def ai_proposal_to_path_step(
*,
ai_payload: Mapping[str, Any],
ctx_title: str,
gap: Mapping[str, Any],
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
) -> Dict[str, Any]:
summary_text = ""
summary_obj = ai_payload.get("summary")
if isinstance(summary_obj, dict):
summary_text = str(summary_obj.get("text") or "").strip()
elif isinstance(summary_obj, str):
summary_text = summary_obj.strip()
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
title = (ctx_title or "").strip() or "KI-Vorschlag (Brücke)"
reasons = ["KI-Neuanlage-Vorschlag — Lücke ohne passende Bibliotheks-Übung"]
return {
"exercise_id": None,
"proposal_key": proposal_key,
"variant_id": None,
"title": title,
"summary": summary_text or None,
"score": None,
"semantic_score": None,
"reasons": reasons,
"variants": [],
"is_bridge": True,
"is_ai_proposal": True,
"ai_suggestion": dict(ai_payload),
"bridge_for_gap": {
"from_exercise_id": int(step_a["exercise_id"]),
"to_exercise_id": int(step_b["exercise_id"]),
"gap_score": gap.get("gap_score"),
"expected_phase": gap.get("expected_phase"),
},
}
def try_suggest_ai_bridge_step(
cur,
*,
goal_query: str,
brief: PlanningSemanticBrief,
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
) -> Optional[Dict[str, Any]]:
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
ctx = _build_gap_ai_context(
goal_query=goal_query,
brief=brief,
step_a=step_a,
step_b=step_b,
gap=gap,
)
g_plain = strip_html_to_plain(ctx.goal)
if not g_plain.strip() and not (ctx.title or "").strip():
return None
try:
payload = run_exercise_form_ai_suggestion(
cur,
ctx,
want_summary=True,
want_skills=True,
want_instructions=False,
)
except Exception as exc:
_logger.warning("KI-Lückenfüller fehlgeschlagen: %s", exc)
return None
if not payload:
return None
return ai_proposal_to_path_step(
ai_payload=payload,
ctx_title=ctx.title or "",
gap=gap,
step_a=step_a,
step_b=step_b,
)
def insert_ai_proposals_for_gaps(
cur,
steps: list,
unfilled_gaps: list,
*,
goal_query: str,
brief: PlanningSemanticBrief,
max_proposals: int = 2,
) -> tuple[list, list]:
"""Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
if not unfilled_gaps:
return steps, []
out = list(steps)
proposals: list = []
gap_by_pair = {
(int(g["from_exercise_id"]), int(g["to_exercise_id"])): g for g in unfilled_gaps
}
i = 0
while i < len(out) - 1 and len(proposals) < max_proposals:
a = out[i]
b = out[i + 1]
if a.get("is_ai_proposal") or b.get("is_ai_proposal"):
i += 1
continue
key = (int(a["exercise_id"]), int(b["exercise_id"]))
gap = gap_by_pair.get(key)
if not gap:
i += 1
continue
proposal = try_suggest_ai_bridge_step(
cur,
goal_query=goal_query,
brief=brief,
step_a=a,
step_b=b,
gap=gap,
)
if not proposal:
i += 1
continue
out.insert(i + 1, proposal)
proposals.append(
{
"inserted_after_index": i,
"proposal_key": proposal.get("proposal_key"),
"proposal_title": proposal.get("title"),
"gap": gap,
}
)
i += 2
return out, proposals
__all__ = [
"insert_ai_proposals_for_gaps",
"try_suggest_ai_bridge_step",
]

View File

@ -13,11 +13,13 @@ from pydantic import BaseModel, Field
from tenant_context import TenantContext, library_content_visibility_sql
from planning_exercise_profiles import PlanningTargetProfile
from planning_exercise_path_qa import (
apply_llm_path_reorder,
build_path_qa_summary,
detect_path_gaps,
insert_bridge_exercises,
try_llm_qa_progression_path,
)
from planning_exercise_path_ai_fill import insert_ai_proposals_for_gaps
from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_semantics import (
PlanningSemanticBrief,
@ -47,6 +49,8 @@ class ProgressionPathSuggestRequest(BaseModel):
include_llm_intent: bool = True
include_path_qa: bool = True
include_llm_path_qa: bool = True
include_path_reorder: bool = True
include_ai_gap_fill: bool = True
progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None
@ -325,11 +329,15 @@ def suggest_progression_path(
gaps: List[Dict[str, Any]] = []
bridge_inserts: List[Dict[str, Any]] = []
ai_proposals: List[Dict[str, Any]] = []
llm_qa: Optional[Dict[str, Any]] = None
llm_qa_applied = False
reorder_applied = False
reorder_notes: List[str] = []
if body.include_path_qa:
gaps = detect_path_gaps(cur, steps, brief=semantic_brief)
unfilled_gaps: List[Dict[str, Any]] = []
if gaps:
bridge_fn = _make_bridge_search_fn(
cur,
@ -342,7 +350,7 @@ def suggest_progression_path(
semantic_brief=semantic_brief,
planned_ids=planned_ids,
)
steps, bridge_inserts = insert_bridge_exercises(
steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises(
cur,
steps,
gaps,
@ -350,6 +358,15 @@ def suggest_progression_path(
bridge_search_fn=bridge_fn,
)
if body.include_ai_gap_fill and unfilled_gaps:
steps, ai_proposals = insert_ai_proposals_for_gaps(
cur,
steps,
unfilled_gaps,
goal_query=goal_query,
brief=semantic_brief,
)
if body.include_llm_path_qa:
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur,
@ -360,11 +377,17 @@ def suggest_progression_path(
bridge_inserts=bridge_inserts,
)
if body.include_path_reorder and llm_qa_applied and llm_qa:
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
path_qa = build_path_qa_summary(
gaps=gaps,
bridge_inserts=bridge_inserts,
ai_proposals=ai_proposals,
llm_qa=llm_qa,
llm_applied=llm_qa_applied,
reorder_applied=reorder_applied,
reorder_notes=reorder_notes,
)
target_profile_summary = target_profile.to_summary_dict(cur) if target_profile else None
@ -373,6 +396,10 @@ def suggest_progression_path(
retrieval_parts.append("path_qa")
if llm_qa_applied:
retrieval_parts.append("llm_path_qa")
if reorder_applied:
retrieval_parts.append("path_reorder")
if ai_proposals:
retrieval_parts.append("ai_gap_fill")
return {
"goal_query": goal_query,

View File

@ -187,16 +187,18 @@ def insert_bridge_exercises(
brief: PlanningSemanticBrief,
bridge_search_fn: Callable[..., List[Dict[str, Any]]],
max_inserts: int = _MAX_BRIDGE_INSERTS,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Fügt zwischen großen Lücken Brücken-Übungen ein.
bridge_search_fn(from_step, to_step, gap) -> hits
Returns: (steps, bridge_inserts, unfilled_gaps)
"""
if not gaps:
return steps, []
return steps, [], []
used_ids = {int(s["exercise_id"]) for s in steps}
used_ids = {int(s["exercise_id"]) for s in steps if s.get("exercise_id") is not None}
inserts: List[Dict[str, Any]] = []
unfilled: List[Dict[str, Any]] = []
out = list(steps)
gap_by_pair = {
@ -207,6 +209,9 @@ def insert_bridge_exercises(
while i < len(out) - 1 and len(inserts) < max_inserts:
a = out[i]
b = out[i + 1]
if a.get("exercise_id") is None or b.get("exercise_id") is None:
i += 1
continue
key = (int(a["exercise_id"]), int(b["exercise_id"]))
gap = gap_by_pair.get(key)
if not gap:
@ -221,6 +226,7 @@ def insert_bridge_exercises(
step_b_id=int(b["exercise_id"]),
)
if not bridge_hit:
unfilled.append(gap)
i += 1
continue
@ -253,7 +259,7 @@ def insert_bridge_exercises(
)
i += 2
return out, inserts
return out, inserts, unfilled
def try_llm_qa_progression_path(
@ -271,14 +277,29 @@ def try_llm_qa_progression_path(
step_payload = []
for idx, step in enumerate(steps):
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
step_payload.append(
{
"index": idx + 1,
"proposal_key": step.get("proposal_key"),
"title": step.get("title"),
"summary": strip_html_to_plain(step.get("summary"), max_len=400),
"is_bridge": bool(step.get("is_bridge")),
"is_ai_proposal": True,
"reasons": list(step.get("reasons") or [])[:3],
}
)
continue
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
step_payload.append(
{
"index": idx + 1,
"exercise_id": int(step["exercise_id"]),
"proposal_key": step.get("proposal_key"),
"title": step.get("title") or bundle["title"],
"goal": strip_html_to_plain(bundle["goal"], max_len=400),
"is_bridge": bool(step.get("is_bridge")),
"is_ai_proposal": False,
"reasons": list(step.get("reasons") or [])[:3],
}
)
@ -304,19 +325,52 @@ def try_llm_qa_progression_path(
return None, False
def apply_llm_path_reorder(
steps: List[Dict[str, Any]],
llm_qa: Mapping[str, Any],
) -> Tuple[List[Dict[str, Any]], bool, List[str]]:
"""
Wendet LLM-Neuordnung an (ordered_step_indices = Permutation der aktuellen Indizes).
"""
raw = llm_qa.get("ordered_step_indices")
if not isinstance(raw, list) or len(raw) != len(steps):
return steps, False, []
try:
indices = [int(x) for x in raw]
except (TypeError, ValueError):
return steps, False, ["Neuordnung: ungültige Indizes"]
if sorted(indices) != list(range(len(steps))):
return steps, False, ["Neuordnung: keine gültige Permutation — ignoriert"]
if indices == list(range(len(steps))):
return steps, False, []
notes = [str(n) for n in (llm_qa.get("sequence_notes") or []) if str(n).strip()]
return [steps[i] for i in indices], True, notes
def build_path_qa_summary(
*,
gaps: Sequence[Mapping[str, Any]],
bridge_inserts: Sequence[Mapping[str, Any]],
ai_proposals: Sequence[Mapping[str, Any]],
llm_qa: Optional[Mapping[str, Any]],
llm_applied: bool,
reorder_applied: bool = False,
reorder_notes: Optional[Sequence[str]] = None,
) -> Dict[str, Any]:
summary: Dict[str, Any] = {
"gap_count": len(gaps),
"large_gaps": list(gaps),
"bridge_insert_count": len(bridge_inserts),
"bridge_inserts": list(bridge_inserts),
"ai_proposal_count": len(ai_proposals),
"ai_proposals": list(ai_proposals),
"llm_qa_applied": llm_applied,
"reorder_applied": reorder_applied,
"reorder_notes": list(reorder_notes or []),
}
if llm_qa:
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
@ -335,6 +389,7 @@ def build_path_qa_summary(
__all__ = [
"apply_llm_path_reorder",
"build_path_qa_summary",
"detect_path_gaps",
"insert_bridge_exercises",

View File

@ -1,5 +1,6 @@
"""Tests Planungs-KI Phase E — Pfad-QA."""
from planning_exercise_path_builder import _pick_best_path_hit
from planning_exercise_path_qa import apply_llm_path_reorder
def test_pick_best_path_hit_prefers_semantic_score():
@ -14,3 +15,21 @@ def test_pick_best_path_hit_prefers_semantic_score():
def test_pick_best_path_hit_skips_used():
hits = [{"id": 1, "title": "A", "score": 0.5, "semantic_score": 0.5}]
assert _pick_best_path_hit(hits, {1}) is None
def test_apply_llm_path_reorder_permutation():
steps = [{"exercise_id": 1}, {"exercise_id": 2}, {"exercise_id": 3}]
reordered, applied, notes = apply_llm_path_reorder(
steps,
{"ordered_step_indices": [0, 2, 1], "sequence_notes": ["Vertiefung vor Anwendung"]},
)
assert applied is True
assert [s["exercise_id"] for s in reordered] == [1, 3, 2]
assert notes
def test_apply_llm_path_reorder_invalid_ignored():
steps = [{"exercise_id": 1}, {"exercise_id": 2}]
reordered, applied, _ = apply_llm_path_reorder(steps, {"ordered_step_indices": [0, 0]})
assert applied is False
assert reordered == steps

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.186"
APP_VERSION = "0.8.187"
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.14.0", # Phase E: Semantik-Schicht + Pfad-QA
"planning_exercise_suggest": "0.15.0", # Phase E2: Pfad-Neuordnung + KI-Lückenfüller
"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.187",
"date": "2026-05-23",
"changes": [
"Planungs-KI Phase E2: LLM-Pfad-QS kann Reihenfolge per ordered_step_indices anpassen.",
"Unüberbrückbare Lücken: KI-Neuanlage-Vorschläge (is_ai_proposal) statt nur Bibliotheks-Brücken.",
],
},
{
"version": "0.8.186",
"date": "2026-05-23",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-23
**App-Version / DB-Schema:** App **`0.8.186`** (Planungs-KI Phase E Semantik + Pfad-QA); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
**App-Version / DB-Schema:** App **`0.8.187`** (Planungs-KI Phase E2); 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**.
@ -105,6 +105,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** |
| **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** |
| **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`
@ -251,7 +252,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
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. **E Feinschliff:** Pfad-QA → automatische Neuordnung; fehlende Schritte als KI-Neuanlage vorschlagen.
4. **E3:** KI-Vorschlag im UI direkt anlegen (Modal) · Embeddings für Freitext.
### Allgemein

View File

@ -13,13 +13,19 @@ function mapApiStepToRow(step) {
const rawVid = step?.variant_id ?? step?.suggested_variant_id ?? null
const variantId =
rawVid != null && Number.isFinite(Number(rawVid)) && Number(rawVid) > 0 ? Number(rawVid) : null
const isAiProposal = Boolean(step?.is_ai_proposal) || step?.exercise_id == 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,
proposalKey: step?.proposal_key || null,
exerciseTitle:
(step?.title || '').trim() ||
(step?.exercise_id ? `Übung #${step.exercise_id}` : 'KI-Vorschlag'),
variantId: isAiProposal ? null : variantId,
variants: isAiProposal ? [] : variants,
reasons: Array.isArray(step?.reasons) ? step.reasons : [],
isBridge: Boolean(step?.is_bridge),
isAiProposal,
aiSuggestion: step?.ai_suggestion || null,
semanticScore: step?.semantic_score,
}
}
@ -108,8 +114,13 @@ export default function ExerciseProgressionPathBuilder({
return
}
const steps = pathSteps.filter((s) => s.exerciseId != null)
const skippedAi = pathSteps.filter((s) => s.isAiProposal).length
if (steps.length < 2) {
alert('Mindestens zwei Schritte mit Übung nötig.')
alert(
skippedAi > 0
? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.'
: 'Mindestens zwei Schritte mit Übung nötig.'
)
return
}
const n = steps.length - 1
@ -135,7 +146,11 @@ export default function ExerciseProgressionPathBuilder({
setSemanticBrief(null)
setPathQa(null)
if (typeof onSaved === 'function') await onSaved()
alert(`${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.`)
const msg =
skippedAi > 0
? `${n} Kante(n) gespeichert. ${skippedAi} KI-Vorschlag/Vorschläge nicht im Graph (noch nicht angelegt).`
: `${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.`
alert(msg)
} catch (e) {
console.error(e)
setError(e.message || 'Speichern fehlgeschlagen')
@ -245,7 +260,20 @@ export default function ExerciseProgressionPathBuilder({
) : null}
{Number(pathQa.bridge_insert_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
{pathQa.bridge_insert_count} Brücken-Übung(en) eingefügt (Lückenfüller).
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
</p>
) : null}
{Number(pathQa.ai_proposal_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
{pathQa.ai_proposal_count} KI-Neuanlage-Vorschlag/Vorschläge vor dem Speichern als Übung anlegen.
</p>
) : null}
{pathQa.reorder_applied ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
Reihenfolge nach QS angepasst.
{Array.isArray(pathQa.reorder_notes) && pathQa.reorder_notes[0]
? ` ${pathQa.reorder_notes[0]}`
: ''}
</p>
) : null}
{Array.isArray(targetSummary?.top_skills) &&
@ -276,12 +304,17 @@ export default function ExerciseProgressionPathBuilder({
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">
Schritt {idx + 1}
{step.isBridge ? ' (Brücke)' : ''}
{idx === 0 ? ' (Einstieg)' : idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
{!step.isAiProposal && idx === 0 ? ' (Einstieg)' : ''}
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
</label>
<div style={{ fontSize: '13px' }}>
<strong>{step.exerciseTitle}</strong>
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
{step.exerciseId ? (
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
) : (
<span style={{ color: 'var(--text3)' }}> noch nicht in Bibliothek</span>
)}
</div>
{step.reasons?.length ? (
<ul
@ -300,23 +333,29 @@ export default function ExerciseProgressionPathBuilder({
</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>
{step.isAiProposal ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Nach Anlage der Übung im Graph wählbar.
</p>
) : (
<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)}>