Progressionsgraphen #52

Merged
Lars merged 5 commits from develop into main 2026-05-23 12:56:52 +02:00
9 changed files with 447 additions and 35 deletions
Showing only changes of commit c2c736dafc - Show all commits

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