Progressionsgraph verbessert #54

Merged
Lars merged 30 commits from develop into main 2026-06-09 16:37:22 +02:00
9 changed files with 514 additions and 76 deletions
Showing only changes of commit f074a8bef0 - Show all commits

View File

@ -188,7 +188,7 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-
| **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 | | **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 |
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 | | **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 |
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 | | **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 |
| **F4** | UI Roadmap-Review | 🔲 | | **F4** | UI Roadmap-Review | ✅ 0.8.207 |
| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 | | **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 |
--- ---

View File

@ -53,10 +53,12 @@ from planning_exercise_suggest import (
from planning_progression_roadmap import ( from planning_progression_roadmap import (
MajorStep, MajorStep,
ProgressionRoadmapContext, ProgressionRoadmapContext,
RoadmapOverridePayload,
StageSpecArtifact, StageSpecArtifact,
build_roadmap_unfilled_gap_specs, build_roadmap_unfilled_gap_specs,
progression_roadmap_to_api_dict, progression_roadmap_to_api_dict,
resolve_step_exercise_kind_filter, resolve_step_exercise_kind_filter,
roadmap_context_from_override,
run_progression_roadmap_pipeline, run_progression_roadmap_pipeline,
stage_spec_retrieval_query, stage_spec_retrieval_query,
) )
@ -74,6 +76,8 @@ class ProgressionPathSuggestRequest(BaseModel):
include_roadmap_preview: bool = False include_roadmap_preview: bool = False
include_llm_roadmap: bool = True include_llm_roadmap: bool = True
roadmap_first: bool = False roadmap_first: bool = False
roadmap_only: bool = False
roadmap_override: Optional[RoadmapOverridePayload] = None
progression_graph_id: Optional[int] = Field(default=None, ge=1) progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None exercise_kind_any: Optional[List[str]] = None
@ -492,21 +496,29 @@ def suggest_progression_path(
cur, goal_query, semantic_brief cur, goal_query, semantic_brief
) )
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
include_llm_intent=body.include_llm_intent,
)
roadmap_first = bool(body.roadmap_first) roadmap_first = bool(body.roadmap_first)
include_roadmap = roadmap_first or body.include_roadmap_preview roadmap_only = bool(body.roadmap_only)
include_roadmap = roadmap_first or body.include_roadmap_preview or roadmap_only
progression_roadmap: Optional[Dict[str, Any]] = None progression_roadmap: Optional[Dict[str, Any]] = None
roadmap_ctx: Optional[ProgressionRoadmapContext] = None roadmap_ctx: Optional[ProgressionRoadmapContext] = None
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = [] roadmap_edited = False
roadmap_gap_offers: List[Dict[str, Any]] = []
if include_roadmap: if body.roadmap_override is not None:
try:
roadmap_ctx = roadmap_context_from_override(
goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
override=body.roadmap_override,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
progression_roadmap["roadmap_edited"] = True
roadmap_edited = True
max_steps = int(roadmap_ctx.max_steps)
roadmap_first = True
elif include_roadmap:
roadmap_ctx = run_progression_roadmap_pipeline( roadmap_ctx = run_progression_roadmap_pipeline(
goal_query, goal_query,
max_steps=max_steps, max_steps=max_steps,
@ -516,6 +528,37 @@ def suggest_progression_path(
) )
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx) progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
if roadmap_only:
return {
"goal_query": goal_query,
"max_steps_requested": max_steps,
"steps": [],
"step_count": 0,
"target_profile_summary": None,
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
"semantic_llm_applied": semantic_llm_applied,
"query_intent_summary": {},
"progression_graph_id": body.progression_graph_id,
"path_qa": None,
"gap_fill_offers": [],
"progression_roadmap": progression_roadmap,
"roadmap_first": False,
"roadmap_only": True,
"roadmap_edited": roadmap_edited,
"roadmap_unfilled_count": 0,
"retrieval_phase": "roadmap_only",
}
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
include_llm_intent=body.include_llm_intent,
)
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
roadmap_gap_offers: List[Dict[str, Any]] = []
used: Set[int] = set() used: Set[int] = set()
steps: List[Dict[str, Any]] = [] steps: List[Dict[str, Any]] = []
planned_ids: List[int] = [] planned_ids: List[int] = []
@ -721,6 +764,8 @@ def suggest_progression_path(
retrieval_parts.append("gap_fill_offers") retrieval_parts.append("gap_fill_offers")
if include_roadmap: if include_roadmap:
retrieval_parts.append("roadmap_preview") retrieval_parts.append("roadmap_preview")
if roadmap_edited:
retrieval_parts.append("roadmap_edited")
if roadmap_unfilled: if roadmap_unfilled:
retrieval_parts.append("roadmap_unfilled") retrieval_parts.append("roadmap_unfilled")
@ -738,6 +783,8 @@ def suggest_progression_path(
"gap_fill_offers": gap_fill_offers, "gap_fill_offers": gap_fill_offers,
"progression_roadmap": progression_roadmap, "progression_roadmap": progression_roadmap,
"roadmap_first": roadmap_first, "roadmap_first": roadmap_first,
"roadmap_only": False,
"roadmap_edited": roadmap_edited,
"roadmap_unfilled_count": len(roadmap_unfilled), "roadmap_unfilled_count": len(roadmap_unfilled),
"retrieval_phase": "+".join(retrieval_parts), "retrieval_phase": "+".join(retrieval_parts),
} }

View File

@ -109,6 +109,13 @@ class StageSpecArtifact(BaseModel):
anti_patterns: List[str] = Field(default_factory=list) anti_patterns: List[str] = Field(default_factory=list)
class RoadmapOverridePayload(BaseModel):
"""Vom Trainer bearbeitete Major Steps — steuert Retrieval ohne erneute Roadmap-KI."""
major_steps: List[MajorStep] = Field(..., min_length=2, max_length=10)
stage_specs: Optional[List[StageSpecArtifact]] = None
class ProgressionRoadmapContext(BaseModel): class ProgressionRoadmapContext(BaseModel):
goal_query: str goal_query: str
max_steps: int = Field(ge=2, le=10, default=5) max_steps: int = Field(ge=2, le=10, default=5)
@ -551,6 +558,80 @@ def build_stage_specs(
return specs return specs
def normalize_major_steps_for_override(
major_steps: Sequence[MajorStep],
*,
max_steps: int,
) -> List[MajorStep]:
"""Indizes 0…n-1, mindestens 2, höchstens max_steps Major Steps."""
cleaned: List[MajorStep] = []
for raw in list(major_steps)[:max_steps]:
goal = (raw.learning_goal or "").strip()
phase = (raw.phase or "vertiefung").strip().lower()
if not goal:
continue
cleaned.append(
MajorStep(
index=len(cleaned),
phase=phase,
learning_goal=goal,
consolidates=list(raw.consolidates or []),
rationale=(raw.rationale or "").strip(),
)
)
if len(cleaned) < 2:
raise ValueError("Mindestens zwei Major Steps mit Lernziel nötig")
for i, step in enumerate(cleaned):
step.index = i
return cleaned
def roadmap_context_from_override(
goal_query: str,
*,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
override: RoadmapOverridePayload,
) -> ProgressionRoadmapContext:
"""Phase F4: bearbeitete Roadmap → stage_specs → Retrieval (ohne LLM-Roadmap)."""
majors = normalize_major_steps_for_override(override.major_steps, max_steps=max_steps)
effective_max = len(majors)
goal_analysis = build_goal_analysis(goal_query, semantic_brief)
stage_specs: List[StageSpecArtifact]
if override.stage_specs and len(override.stage_specs) >= effective_max:
stage_specs = []
for i, spec in enumerate(override.stage_specs[:effective_max]):
stage_specs.append(
StageSpecArtifact(
major_step_index=i,
learning_goal=(spec.learning_goal or majors[i].learning_goal).strip(),
load_profile=list(spec.load_profile or []),
exercise_type=(spec.exercise_type or "").strip(),
success_criteria=list(spec.success_criteria or []),
anti_patterns=list(spec.anti_patterns or []),
)
)
if not all(s.exercise_type for s in stage_specs):
rebuilt = build_stage_specs(majors, goal_analysis=goal_analysis)
for i, spec in enumerate(stage_specs):
if not spec.exercise_type:
spec.exercise_type = rebuilt[i].exercise_type
if not spec.load_profile:
spec.load_profile = list(rebuilt[i].load_profile)
else:
stage_specs = build_stage_specs(majors, goal_analysis=goal_analysis)
return ProgressionRoadmapContext(
goal_query=goal_query.strip(),
max_steps=effective_max,
semantic_brief=brief_to_summary_dict(semantic_brief),
goal_analysis=goal_analysis,
roadmap=RoadmapArtifact(major_steps=majors),
stage_specs=stage_specs,
pipeline_phase="roadmap_v1_edited",
)
def run_progression_roadmap_pipeline( def run_progression_roadmap_pipeline(
goal_query: str, goal_query: str,
*, *,
@ -652,6 +733,9 @@ __all__ = [
"MicroObjective", "MicroObjective",
"ProgressionRoadmapContext", "ProgressionRoadmapContext",
"RoadmapArtifact", "RoadmapArtifact",
"RoadmapOverridePayload",
"normalize_major_steps_for_override",
"roadmap_context_from_override",
"StageSpecArtifact", "StageSpecArtifact",
"build_goal_analysis", "build_goal_analysis",
"build_roadmap_unfilled_gap_specs", "build_roadmap_unfilled_gap_specs",

View File

@ -71,6 +71,7 @@ def post_progression_path_suggest(
body.include_llm_intent body.include_llm_intent
or body.include_llm_path_qa or body.include_llm_path_qa
or body.include_ai_gap_fill or body.include_ai_gap_fill
or body.include_llm_roadmap
) )
club_id = resolve_club_id_for_probe(tenant) if uses_ai else None club_id = resolve_club_id_for_probe(tenant) if uses_ai else None
if uses_ai: if uses_ai:

View File

@ -14,6 +14,9 @@ from planning_progression_roadmap import (
run_progression_roadmap_pipeline, run_progression_roadmap_pipeline,
stage_spec_exercise_kind_filter, stage_spec_exercise_kind_filter,
stage_spec_retrieval_query, stage_spec_retrieval_query,
normalize_major_steps_for_override,
roadmap_context_from_override,
RoadmapOverridePayload,
) )
from planning_exercise_semantics import build_semantic_brief from planning_exercise_semantics import build_semantic_brief
@ -90,6 +93,40 @@ def test_build_roadmap_unfilled_gap_specs():
assert specs[0]["phase"] == "anwendung" assert specs[0]["phase"] == "anwendung"
def test_normalize_major_steps_reindexes():
majors = normalize_major_steps_for_override(
[
MajorStep(index=9, phase="einstieg", learning_goal="Einstieg", consolidates=[]),
MajorStep(index=8, phase="perfektion", learning_goal="Ziel", consolidates=[]),
],
max_steps=5,
)
assert len(majors) == 2
assert majors[0].index == 0
assert majors[1].index == 1
def test_roadmap_context_from_override():
brief = build_semantic_brief("Mae Geri Perfektion")
override = RoadmapOverridePayload(
major_steps=[
MajorStep(index=0, phase="einstieg", learning_goal="Mae Geri Einstieg", consolidates=[]),
MajorStep(index=1, phase="grundlage", learning_goal="Stand und Hüfte", consolidates=[]),
MajorStep(index=2, phase="perfektion", learning_goal="Präzision unter Belastung", consolidates=[]),
]
)
ctx = roadmap_context_from_override(
"Mae Geri Perfektion",
max_steps=5,
semantic_brief=brief,
override=override,
)
assert ctx.pipeline_phase == "roadmap_v1_edited"
assert len(ctx.roadmap.major_steps) == 3
assert len(ctx.stage_specs) == 3
assert ctx.stage_specs[1].learning_goal == "Stand und Hüfte"
def test_api_dict_exposes_prompt_slug_catalog(): def test_api_dict_exposes_prompt_slug_catalog():
ctx = run_progression_roadmap_pipeline("Mae Geri", max_steps=3, include_llm_roadmap=False) ctx = run_progression_roadmap_pipeline("Mae Geri", max_steps=3, include_llm_roadmap=False)
api = progression_roadmap_to_api_dict(ctx) api = progression_roadmap_to_api_dict(ctx)

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.206" APP_VERSION = "0.8.207"
BUILD_DATE = "2026-06-07" BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260606086" DB_SCHEMA_VERSION = "20260606086"
@ -38,7 +38,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.1", # KI-Endpoints: feature_usage nach ai_calls consume "exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"planning_exercise_suggest": "0.18.0", # F3: roadmap_first Retrieval pro stage_spec "planning_exercise_suggest": "0.19.0", # F4: roadmap_only + roadmap_override, editierbare Roadmap-UI
"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
@ -53,6 +53,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.207",
"date": "2026-06-07",
"changes": [
"Phase F4: Roadmap-Review — roadmap_only, roadmap_override auf progression-path-suggest.",
"UI: Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match.",
"Zwei-Schritt-Flow: Roadmap vorschlagen → Übungen matchen.",
],
},
{ {
"version": "0.8.206", "version": "0.8.206",
"date": "2026-06-07", "date": "2026-06-07",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-06-07 **Stand:** 2026-06-07
**App-Version / DB-Schema:** App **`0.8.206`** (Planungs-KI Phase F3); DB **`20260606086`** — maßgeblich **`backend/version.py`**. **App-Version / DB-Schema:** App **`0.8.207`** (Planungs-KI Phase F4); DB **`20260606086`** — 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**.
@ -109,6 +109,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** | | **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** |
| **F0F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** | | **F0F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
| **F3** | `roadmap_first` — Retrieval pro `stage_spec` | ✅ **0.8.206** | | **F3** | `roadmap_first` — Retrieval pro `stage_spec` | ✅ **0.8.206** |
| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** |
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 | | **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`** **Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`**

View File

@ -64,10 +64,11 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
- [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`) - [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`)
- [ ] QA/Lücken vollständig an Roadmap koppeln (Brücken optional reduzieren) - [ ] QA/Lücken vollständig an Roadmap koppeln (Brücken optional reduzieren)
### F4 — UI ### F4 — UI (0.8.207)
- [ ] Roadmap-Review im `ExerciseProgressionPathBuilder` - [x] Roadmap-Review im `ExerciseProgressionPathBuilder`
- [ ] Major Steps editierbar vor Übungs-Match - [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
- [x] API `roadmap_only` + `roadmap_override`
--- ---

View File

@ -35,6 +35,10 @@ function mapApiStepToRow(step) {
aiSuggestion: step?.ai_suggestion || null, aiSuggestion: step?.ai_suggestion || null,
semanticScore: step?.semantic_score, semanticScore: step?.semantic_score,
isOffTopic: false, isOffTopic: false,
roadmapMajorStepIndex:
step?.roadmap_major_step_index != null ? Number(step.roadmap_major_step_index) : null,
roadmapPhase: step?.roadmap_phase || null,
roadmapLearningGoal: step?.roadmap_learning_goal || null,
} }
} }
@ -63,6 +67,36 @@ const OFFER_SOURCE_LABELS = {
const PATH_STEPS_HARD_MAX = 10 const PATH_STEPS_HARD_MAX = 10
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
function mapMajorStepsFromApi(apiRoadmap) {
const raw = apiRoadmap?.roadmap?.major_steps
if (!Array.isArray(raw)) return []
return raw.map((s, i) => ({
index: i,
phase: s.phase || 'vertiefung',
learning_goal: (s.learning_goal || '').trim(),
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
rationale: s.rationale || '',
}))
}
function reindexMajorSteps(rows) {
return rows.map((row, i) => ({ ...row, index: i }))
}
function majorStepsToOverridePayload(rows) {
return {
major_steps: reindexMajorSteps(rows).map((row) => ({
index: row.index,
phase: row.phase || 'vertiefung',
learning_goal: row.learning_goal.trim(),
consolidates: row.consolidates || [],
rationale: row.rationale || '',
})),
}
}
/** Einfügen wächst den Pfad; Ersetzen (replace_step_index) nicht. */ /** Einfügen wächst den Pfad; Ersetzen (replace_step_index) nicht. */
function offerGrowsPath(offer) { function offerGrowsPath(offer) {
const replaceIdx = offer?.replace_step_index const replaceIdx = offer?.replace_step_index
@ -121,7 +155,6 @@ export default function ExerciseProgressionPathBuilder({
const [goalQuery, setGoalQuery] = useState('') const [goalQuery, setGoalQuery] = useState('')
const [maxSteps, setMaxSteps] = useState(5) const [maxSteps, setMaxSteps] = useState(5)
const [segmentNotes, setSegmentNotes] = useState('') const [segmentNotes, setSegmentNotes] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [targetSummary, setTargetSummary] = useState(null) const [targetSummary, setTargetSummary] = useState(null)
@ -130,6 +163,11 @@ export default function ExerciseProgressionPathBuilder({
const [pathSteps, setPathSteps] = useState([]) const [pathSteps, setPathSteps] = useState([])
const [gapFillOffers, setGapFillOffers] = useState([]) const [gapFillOffers, setGapFillOffers] = useState([])
const [progressionRoadmap, setProgressionRoadmap] = useState(null) const [progressionRoadmap, setProgressionRoadmap] = useState(null)
const [editableMajorSteps, setEditableMajorSteps] = useState([])
const [roadmapDirty, setRoadmapDirty] = useState(false)
const [loadingRoadmap, setLoadingRoadmap] = useState(false)
const [loadingMatch, setLoadingMatch] = useState(false)
const loading = loadingRoadmap || loadingMatch
const [focusAreas, setFocusAreas] = useState([]) const [focusAreas, setFocusAreas] = useState([])
const [skillsCatalog, setSkillsCatalog] = useState([]) const [skillsCatalog, setSkillsCatalog] = useState([])
const [generatingOfferId, setGeneratingOfferId] = useState(null) const [generatingOfferId, setGeneratingOfferId] = useState(null)
@ -173,6 +211,52 @@ export default function ExerciseProgressionPathBuilder({
setPathSteps((prev) => (prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx))) setPathSteps((prev) => (prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx)))
}, []) }, [])
const patchMajorStep = useCallback((idx, patch) => {
setEditableMajorSteps((prev) =>
reindexMajorSteps(prev.map((row, i) => (i === idx ? { ...row, ...patch } : row))),
)
setRoadmapDirty(true)
}, [])
const moveMajorStep = useCallback((idx, dir) => {
setEditableMajorSteps((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 reindexMajorSteps(next)
})
setRoadmapDirty(true)
}, [])
const removeMajorStep = useCallback((idx) => {
setEditableMajorSteps((prev) => {
if (prev.length <= 2) return prev
return reindexMajorSteps(prev.filter((_, i) => i !== idx))
})
setRoadmapDirty(true)
}, [])
const addMajorStep = useCallback(() => {
setEditableMajorSteps((prev) => {
if (prev.length >= PATH_STEPS_HARD_MAX) return prev
const phase = ROADMAP_PHASES[Math.min(prev.length, ROADMAP_PHASES.length - 1)]
return reindexMajorSteps([
...prev,
{
index: prev.length,
phase,
learning_goal: '',
consolidates: [],
rationale: '',
},
])
})
setRoadmapDirty(true)
}, [])
const moveStep = useCallback((idx, dir) => { const moveStep = useCallback((idx, dir) => {
setPathSteps((prev) => { setPathSteps((prev) => {
const j = idx + dir const j = idx + dir
@ -376,7 +460,33 @@ export default function ExerciseProgressionPathBuilder({
} }
} }
const suggestPath = async () => { const applyPathMatchResponse = (res, q) => {
const qa = res?.path_qa || null
const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
const rows =
Array.isArray(qa?.stripped_off_topic_steps) && qa.stripped_off_topic_steps.length > 0
? rawRows
: applyOffTopicFlags(rawRows, qa)
if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.')
}
setPathSteps(rows)
setTargetSummary(res?.target_profile_summary || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(qa)
setGapFillOffers(
Array.isArray(res?.gap_fill_offers)
? res.gap_fill_offers
: Array.isArray(qa?.gap_fill_offers)
? qa.gap_fill_offers
: [],
)
setProgressionRoadmap(res?.progression_roadmap || null)
setRoadmapDirty(false)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
}
const suggestRoadmap = async () => {
const q = (goalQuery || '').trim() const q = (goalQuery || '').trim()
if (q.length < 3) { if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.') alert('Ziel-Anfrage: mindestens 3 Zeichen.')
@ -386,55 +496,85 @@ export default function ExerciseProgressionPathBuilder({
alert('Zuerst einen Graphen wählen.') alert('Zuerst einen Graphen wählen.')
return return
} }
setLoading(true) setLoadingRoadmap(true)
setError('') setError('')
try { try {
const res = await api.suggestProgressionPath({ const res = await api.suggestProgressionPath({
query: q, query: q,
max_steps: Number(maxSteps), max_steps: Number(maxSteps),
include_llm_intent: false,
include_path_qa: false,
include_llm_path_qa: false,
include_path_reorder: false,
include_ai_gap_fill: false,
include_roadmap_preview: true,
include_llm_roadmap: true,
roadmap_only: true,
progression_graph_id: Number(graphId),
})
const majors = mapMajorStepsFromApi(res?.progression_roadmap)
if (majors.length < 2) {
throw new Error('Roadmap hat zu wenig Major Steps.')
}
setEditableMajorSteps(majors)
setMaxSteps(majors.length)
setProgressionRoadmap(res?.progression_roadmap || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathSteps([])
setTargetSummary(null)
setPathQa(null)
setGapFillOffers([])
setRoadmapDirty(false)
} catch (e) {
console.error(e)
setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen')
setEditableMajorSteps([])
setProgressionRoadmap(null)
} finally {
setLoadingRoadmap(false)
}
}
const matchExercisesFromRoadmap = 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
}
const validSteps = editableMajorSteps.filter((s) => (s.learning_goal || '').trim().length >= 3)
if (validSteps.length < 2) {
alert('Mindestens zwei Major Steps mit Lernziel (je 3+ Zeichen) nötig.')
return
}
setLoadingMatch(true)
setError('')
try {
const override = majorStepsToOverridePayload(validSteps)
const res = await api.suggestProgressionPath({
query: q,
max_steps: validSteps.length,
include_llm_intent: true, include_llm_intent: true,
include_path_qa: true, include_path_qa: true,
include_llm_path_qa: true, include_llm_path_qa: true,
include_path_reorder: true, include_path_reorder: true,
include_ai_gap_fill: true, include_ai_gap_fill: true,
include_roadmap_preview: true, include_roadmap_preview: true,
include_llm_roadmap: true, include_llm_roadmap: false,
roadmap_first: true, roadmap_first: true,
roadmap_override: override,
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
}) })
const qa = res?.path_qa || null applyPathMatchResponse(res, q)
const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow) setMaxSteps(validSteps.length)
const rows =
Array.isArray(qa?.stripped_off_topic_steps) && qa.stripped_off_topic_steps.length > 0
? rawRows
: applyOffTopicFlags(rawRows, qa)
if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.')
}
setPathSteps(rows)
setTargetSummary(res?.target_profile_summary || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(qa)
setGapFillOffers(
Array.isArray(res?.gap_fill_offers)
? res.gap_fill_offers
: Array.isArray(qa?.gap_fill_offers)
? qa.gap_fill_offers
: [],
)
setProgressionRoadmap(res?.progression_roadmap || null)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
} catch (e) { } catch (e) {
console.error(e) console.error(e)
setError(e.message || 'Pfad-Vorschlag fehlgeschlagen') setError(e.message || 'Übungs-Match fehlgeschlagen')
setPathSteps([])
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
setProgressionRoadmap(null)
} finally { } finally {
setLoading(false) setLoadingMatch(false)
} }
} }
@ -477,6 +617,8 @@ export default function ExerciseProgressionPathBuilder({
setPathQa(null) setPathQa(null)
setGapFillOffers([]) setGapFillOffers([])
setProgressionRoadmap(null) setProgressionRoadmap(null)
setEditableMajorSteps([])
setRoadmapDirty(false)
if (typeof onSaved === 'function') await onSaved() if (typeof onSaved === 'function') await onSaved()
const msg = const msg =
skippedAi > 0 skippedAi > 0
@ -501,8 +643,8 @@ export default function ExerciseProgressionPathBuilder({
> >
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3> <h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}> <p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Ziel in Freitext formulieren die Planungs-KI schlägt eine semantisch passende, aufbauende Reihenfolge vor, Zuerst didaktische Roadmap vorschlagen und anpassen, dann Übungen je Major Step aus der Bibliothek matchen.
prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Fehlende Schritte können mit KI als Übung angelegt werden. Lücken können mit KI als Übung angelegt werden.
</p> </p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}> <div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
@ -531,9 +673,30 @@ export default function ExerciseProgressionPathBuilder({
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
disabled={disabled || loading || saving || !graphId} disabled={disabled || loading || saving || !graphId}
onClick={suggestPath} onClick={suggestRoadmap}
> >
{loading ? 'Vorschlag …' : 'Pfad vorschlagen'} {loadingRoadmap ? 'Roadmap …' : 'Roadmap vorschlagen'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={
disabled ||
loading ||
saving ||
!graphId ||
editableMajorSteps.length < 2
}
onClick={matchExercisesFromRoadmap}
title={
editableMajorSteps.length < 2
? 'Zuerst Roadmap vorschlagen'
: roadmapDirty
? 'Roadmap wurde bearbeitet — erneut matchen'
: 'Bibliothek je Major Step durchsuchen'
}
>
{loadingMatch ? 'Match …' : roadmapDirty ? 'Übungen neu matchen' : 'Übungen matchen'}
</button> </button>
</div> </div>
@ -565,7 +728,7 @@ export default function ExerciseProgressionPathBuilder({
</div> </div>
) : null} ) : null}
{progressionRoadmap?.roadmap?.major_steps?.length > 0 ? ( {editableMajorSteps.length > 0 ? (
<div <div
style={{ style={{
marginTop: '12px', marginTop: '12px',
@ -575,25 +738,113 @@ export default function ExerciseProgressionPathBuilder({
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface))', background: 'color-mix(in srgb, var(--accent) 6%, var(--surface))',
}} }}
> >
<strong style={{ fontSize: '13px' }}>Didaktische Roadmap (Phase F)</strong> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}> <strong style={{ fontSize: '13px' }}>Didaktische Roadmap bearbeiten</strong>
Ziel-zuerst-Planung: {progressionRoadmap.micro_objective_count ?? '?'} Zwischenziele {' '} {roadmapDirty ? (
{progressionRoadmap.major_step_count ?? progressionRoadmap.roadmap.major_steps.length} Major Steps. <span className="exercise-tag" style={{ borderColor: 'var(--danger)' }}>
{progressionRoadmap.llm_roadmap_applied Geändert bitte erneut matchen
? ' (KI-Prompts aus Admin-Konfiguration)' </span>
: ' (heuristischer Fallback — KI-Prompts in ai_prompts)'} ) : pathSteps.length > 0 ? (
. Übungen unten: je Major Step aus der Bibliothek (roadmap-first). <span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Gematcht
</span>
) : null}
</div>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.45 }}>
{progressionRoadmap?.micro_objective_count != null
? `${progressionRoadmap.micro_objective_count} Zwischenziele → `
: ''}
{editableMajorSteps.length} Major Steps
{progressionRoadmap?.llm_roadmap_applied
? ' (KI-Roadmap)'
: progressionRoadmap
? ' (heuristisch/KI)'
: ''}
. Phasen und Lernziele anpassen, dann Übungen matchen.
</p> </p>
<ol style={{ margin: 0, paddingLeft: '18px', fontSize: '13px', lineHeight: 1.5 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{progressionRoadmap.roadmap.major_steps.map((step) => ( {editableMajorSteps.map((step, idx) => (
<li key={step.index} style={{ marginBottom: '6px' }}> <div
<span className="exercise-tag" style={{ marginRight: '6px' }}> key={`major-${idx}-${step.index}`}
{step.phase} style={{
</span> padding: '10px 12px',
{step.learning_goal} borderRadius: '8px',
</li> background: 'var(--surface2)',
border: '1px solid var(--border)',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: '10px',
alignItems: 'end',
}}
>
<div className="form-row" style={{ marginBottom: 0, flex: '0 0 120px' }}>
<label className="form-label">Stufe {idx + 1} · Phase</label>
<select
className="form-input"
value={step.phase}
onChange={(e) => patchMajorStep(idx, { phase: e.target.value })}
disabled={disabled || loading || saving}
>
{ROADMAP_PHASES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0, flex: '2 1 240px' }}>
<label className="form-label">Lernziel</label>
<input
className="form-input"
value={step.learning_goal}
onChange={(e) => patchMajorStep(idx, { learning_goal: e.target.value })}
placeholder="z. B. Grundstellung und Hüftmobilität für Mae Geri"
disabled={disabled || loading || saving}
/>
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
onClick={() => moveMajorStep(idx, -1)}
disabled={disabled || loading || saving || idx === 0}
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
onClick={() => moveMajorStep(idx, 1)}
disabled={disabled || loading || saving || idx >= editableMajorSteps.length - 1}
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
onClick={() => removeMajorStep(idx)}
disabled={disabled || loading || saving || editableMajorSteps.length <= 2}
>
Entfernen
</button>
</div>
</div>
))} ))}
</ol> </div>
{editableMajorSteps.length < PATH_STEPS_HARD_MAX ? (
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: '10px', fontSize: '12px' }}
onClick={addMajorStep}
disabled={disabled || loading || saving}
>
Major Step hinzufügen
</button>
) : null}
</div> </div>
) : null} ) : null}
@ -748,11 +999,18 @@ export default function ExerciseProgressionPathBuilder({
<div className="form-row" style={{ marginBottom: 0 }}> <div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label"> <label className="form-label">
Schritt {idx + 1} Schritt {idx + 1}
{step.roadmapMajorStepIndex != null
? ` · Roadmap ${step.roadmapMajorStepIndex + 1}`
: ''}
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
{step.isOffTopic ? ' (themenfremd)' : ''} {step.isOffTopic ? ' (themenfremd)' : ''}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''} {step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
{!step.isAiProposal && !step.isOffTopic && idx === 0 ? ' (Einstieg)' : ''}
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
</label> </label>
{step.roadmapLearningGoal ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
Ziel: {step.roadmapLearningGoal}
</p>
) : null}
<div style={{ fontSize: '13px' }}> <div style={{ fontSize: '13px' }}>
<strong>{step.exerciseTitle}</strong> <strong>{step.exerciseTitle}</strong>
{step.exerciseId ? ( {step.exerciseId ? (