From d4e9bded23375ee464d0a6844349d4bdc65b9dfe Mon Sep 17 00:00:00 2001
From: Lars
Date: Mon, 8 Jun 2026 12:40:17 +0200
Subject: [PATCH] Implement Roadmap-First Retrieval and Enhance Planning AI
Features
- Introduced a roadmap-first approach for retrieval, allowing for structured exercise suggestions based on stage specifications and major steps.
- Added functionality to generate gap-fill offers for unfilled roadmap stages, improving the relevance of exercise recommendations.
- Updated the `ExerciseProgressionPathBuilder` to support the new roadmap-first feature, enhancing user experience with clearer exercise paths.
- Incremented application version to 0.8.206 and updated the database schema version to reflect these changes.
---
.../PLANNING_EXERCISE_SUGGEST_CONTEXT.md | 2 +-
.../PLANNING_PROGRESSION_ROADMAP_SPEC.md | 2 +-
backend/planning_exercise_path_builder.py | 267 +++++++++++++++---
backend/planning_progression_roadmap.py | 107 ++++++-
.../test_planning_exercise_path_builder.py | 23 +-
.../test_planning_progression_roadmap.py | 47 +++
backend/version.py | 13 +-
docs/HANDOVER.md | 11 +-
docs/architecture/PLANNING_KI_ROADMAP.md | 7 +-
.../ExerciseProgressionPathBuilder.jsx | 4 +-
10 files changed, 431 insertions(+), 52 deletions(-)
diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
index e4a383e..7d3dbbe 100644
--- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
+++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
@@ -506,7 +506,7 @@ Nach Pfad-Bildung:
| Prompts | Migration **078/079** — Slugs in `ai_prompts` (Admin), **kein** Template im Python-Code |
| UI | `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) |
-**Übergang:** `include_roadmap_preview=true` liefert `progression_roadmap` **parallel** zum retrieval-first Pfad. **Ziel F3:** `roadmap_first=true` steuert Retrieval.
+**F3 (0.8.206):** `roadmap_first=true` (Default im UI) — Retrieval pro `stage_spec`/Major Step; `roadmap_unfilled` Gap-Angebote. Ohne Flag: retrieval-first wie bisher, Roadmap nur Preview.
**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung.
diff --git a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
index 5961298..785373e 100644
--- a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
+++ b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
@@ -187,7 +187,7 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-
| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | 🔄 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 |
-| **F3** | Retrieval aus `stage_specs` (roadmap_first) | 🔲 |
+| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 |
| **F4** | UI Roadmap-Review | 🔲 |
| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 |
diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py
index c0f1022..799c25a 100644
--- a/backend/planning_exercise_path_builder.py
+++ b/backend/planning_exercise_path_builder.py
@@ -23,7 +23,11 @@ from planning_exercise_path_qa import (
strip_off_topic_steps_from_path,
try_llm_qa_progression_path,
)
-from planning_exercise_path_ai_fill import apply_gap_fill_after_qa, collect_gap_fill_specs
+from planning_exercise_path_ai_fill import (
+ apply_gap_fill_after_qa,
+ build_gap_fill_offer,
+ collect_gap_fill_specs,
+)
from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_semantics import (
PlanningSemanticBrief,
@@ -47,8 +51,14 @@ from planning_exercise_suggest import (
resolve_planning_exercise_intent,
)
from planning_progression_roadmap import (
+ MajorStep,
+ ProgressionRoadmapContext,
+ StageSpecArtifact,
+ build_roadmap_unfilled_gap_specs,
progression_roadmap_to_api_dict,
+ resolve_step_exercise_kind_filter,
run_progression_roadmap_pipeline,
+ stage_spec_retrieval_query,
)
from routers.training_planning import _has_planning_role
@@ -169,8 +179,12 @@ def _run_path_step_retrieval(
step_b: Optional[Dict[str, Any]] = None,
path_target_profile: Optional[PlanningTargetProfile] = None,
path_intent: Optional[str] = None,
+ step_query_override: Optional[str] = None,
+ step_phase_override: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
- step_query = step_retrieval_query(semantic_brief, goal_query, step_index, max_steps)
+ step_query = step_query_override or step_retrieval_query(
+ semantic_brief, goal_query, step_index, max_steps
+ )
if bridge_mode and step_a and step_b:
phase = step_phase_for_index(semantic_brief, step_index, max_steps)
parts = [semantic_brief.primary_topic or semantic_brief.retrieval_query or goal_query]
@@ -200,7 +214,8 @@ def _run_path_step_retrieval(
"has_planning_reference": bool(planned_ids or anchor_id or bridge_mode),
"semantic_brief": semantic_brief,
"retrieval_query": step_query,
- "path_step_phase": step_phase_for_index(semantic_brief, step_index, max_steps),
+ "path_step_phase": step_phase_override
+ or step_phase_for_index(semantic_brief, step_index, max_steps),
}
pack = apply_progression_context_to_pack(
cur,
@@ -331,6 +346,130 @@ def _make_bridge_search_fn(
return _bridge_search
+def _annotate_roadmap_step(
+ step: Dict[str, Any],
+ *,
+ stage_spec: StageSpecArtifact,
+ major_step: Optional[MajorStep],
+) -> Dict[str, Any]:
+ reasons = list(step.get("reasons") or [])
+ learning_goal = (stage_spec.learning_goal or "").strip()
+ if learning_goal:
+ roadmap_reason = f"Roadmap: {learning_goal[:120]}"
+ if roadmap_reason not in reasons:
+ reasons.insert(0, roadmap_reason)
+ step["reasons"] = reasons[:4]
+ step["roadmap_major_step_index"] = stage_spec.major_step_index
+ step["roadmap_phase"] = major_step.phase if major_step else None
+ step["roadmap_learning_goal"] = learning_goal or None
+ step["roadmap_match_source"] = "stage_spec"
+ return step
+
+
+def _build_steps_roadmap_first(
+ cur,
+ *,
+ tenant: TenantContext,
+ body: ProgressionPathSuggestRequest,
+ goal_query: str,
+ max_steps: int,
+ semantic_brief: PlanningSemanticBrief,
+ path_target_profile: PlanningTargetProfile,
+ path_intent: str,
+ roadmap_ctx: ProgressionRoadmapContext,
+) -> Tuple[List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
+ """Retrieval pro stage_spec statt iterativem Pfad-Bau (Phase F3)."""
+ stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
+ if not stage_specs and roadmap_ctx.roadmap:
+ stage_specs = [
+ StageSpecArtifact(
+ major_step_index=m.index,
+ learning_goal=m.learning_goal,
+ )
+ for m in roadmap_ctx.roadmap.major_steps[:max_steps]
+ ]
+
+ major_by_index: Dict[int, MajorStep] = {}
+ if roadmap_ctx.roadmap:
+ major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_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
+ unfilled: List[Tuple[int, StageSpecArtifact]] = []
+
+ for step_index, stage_spec in enumerate(stage_specs):
+ major = major_by_index.get(stage_spec.major_step_index)
+ step_query = stage_spec_retrieval_query(
+ semantic_brief=semantic_brief,
+ goal_query=goal_query,
+ stage_spec=stage_spec,
+ major_step=major,
+ )
+ step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
+
+ hits, _, _, _ = _run_path_step_retrieval(
+ cur,
+ tenant=tenant,
+ goal_query=goal_query,
+ step_index=step_index,
+ max_steps=max_steps,
+ 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 and step_index == 0,
+ exercise_kind_any=step_kind,
+ semantic_brief=semantic_brief,
+ path_target_profile=path_target_profile,
+ path_intent=path_intent,
+ step_query_override=step_query,
+ step_phase_override=major.phase if major else None,
+ )
+
+ hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
+ if not hit and step_query != goal_query:
+ hits, _, _, _ = _run_path_step_retrieval(
+ cur,
+ tenant=tenant,
+ goal_query=goal_query,
+ step_index=step_index,
+ max_steps=max_steps,
+ planned_ids=planned_ids,
+ anchor_id=anchor_id,
+ anchor_variant_id=anchor_variant_id,
+ progression_graph_id=body.progression_graph_id,
+ include_llm_intent=False,
+ exercise_kind_any=step_kind,
+ semantic_brief=semantic_brief,
+ path_target_profile=path_target_profile,
+ path_intent=path_intent,
+ step_query_override=goal_query,
+ step_phase_override=major.phase if major else None,
+ )
+ hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
+
+ if not hit:
+ unfilled.append((step_index, stage_spec))
+ continue
+
+ step = _annotate_roadmap_step(
+ _hit_to_path_step(hit),
+ stage_spec=stage_spec,
+ major_step=major,
+ )
+ 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")
+
+ return steps, unfilled
+
+
def suggest_progression_path(
cur,
*,
@@ -360,41 +499,98 @@ def suggest_progression_path(
include_llm_intent=body.include_llm_intent,
)
+ roadmap_first = bool(body.roadmap_first)
+ include_roadmap = roadmap_first or body.include_roadmap_preview
+ progression_roadmap: Optional[Dict[str, Any]] = None
+ roadmap_ctx: Optional[ProgressionRoadmapContext] = None
+ roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
+ roadmap_gap_offers: List[Dict[str, Any]] = []
+
+ if include_roadmap:
+ roadmap_ctx = run_progression_roadmap_pipeline(
+ goal_query,
+ max_steps=max_steps,
+ semantic_brief=semantic_brief,
+ cur=cur,
+ include_llm_roadmap=body.include_llm_roadmap,
+ )
+ progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
+
used: Set[int] = set()
steps: List[Dict[str, Any]] = []
planned_ids: List[int] = []
anchor_id: Optional[int] = None
anchor_variant_id: Optional[int] = None
- for step_index in range(max_steps):
- hits, _tp, _qis, _intent = _run_path_step_retrieval(
+ if roadmap_first and roadmap_ctx is not None:
+ steps, roadmap_unfilled = _build_steps_roadmap_first(
cur,
tenant=tenant,
+ body=body,
goal_query=goal_query,
- step_index=step_index,
max_steps=max_steps,
- 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,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
+ roadmap_ctx=roadmap_ctx,
)
+ planned_ids = [int(s["exercise_id"]) for s in steps if s.get("exercise_id") is not None]
+ if planned_ids:
+ anchor_id = planned_ids[-1]
+ anchor_variant_id = steps[-1].get("variant_id")
+ if body.include_ai_gap_fill and roadmap_unfilled:
+ major_by_index = (
+ {m.index: m for m in roadmap_ctx.roadmap.major_steps}
+ if roadmap_ctx.roadmap
+ else {}
+ )
+ roadmap_gap_specs = build_roadmap_unfilled_gap_specs(
+ unfilled_specs=roadmap_unfilled,
+ major_steps_by_index=major_by_index,
+ steps=steps,
+ brief=semantic_brief,
+ goal_query=goal_query,
+ )
+ for spec in roadmap_gap_specs:
+ roadmap_gap_offers.append(
+ build_gap_fill_offer(
+ spec=spec,
+ steps=steps,
+ goal_query=goal_query,
+ brief=semantic_brief,
+ proposal=None,
+ )
+ )
+ else:
+ for step_index in range(max_steps):
+ hits, _tp, _qis, _intent = _run_path_step_retrieval(
+ cur,
+ tenant=tenant,
+ goal_query=goal_query,
+ step_index=step_index,
+ max_steps=max_steps,
+ 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,
+ semantic_brief=semantic_brief,
+ path_target_profile=path_target_profile,
+ path_intent=path_intent,
+ )
- hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
- if not hit:
- break
+ hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
+ 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")
+ 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(
@@ -490,6 +686,12 @@ def suggest_progression_path(
auto_insert_proposals=False,
)
+ if roadmap_gap_offers:
+ seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers}
+ for offer in roadmap_gap_offers:
+ if offer.get("offer_id") not in seen_offer_ids:
+ gap_fill_offers.append(offer)
+
path_qa = build_path_qa_summary(
gaps=gaps,
bridge_inserts=bridge_inserts,
@@ -505,6 +707,8 @@ def suggest_progression_path(
target_profile_summary = path_target_profile.to_summary_dict(cur)
retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"]
+ if roadmap_first:
+ retrieval_parts.append("roadmap_first")
if body.include_path_qa:
retrieval_parts.append("path_qa")
if llm_qa_applied:
@@ -515,20 +719,10 @@ def suggest_progression_path(
retrieval_parts.append("ai_gap_fill")
if gap_fill_offers:
retrieval_parts.append("gap_fill_offers")
-
- progression_roadmap: Optional[Dict[str, Any]] = None
- if body.include_roadmap_preview or body.roadmap_first:
- roadmap_ctx = run_progression_roadmap_pipeline(
- goal_query,
- max_steps=max_steps,
- semantic_brief=semantic_brief,
- cur=cur,
- include_llm_roadmap=body.include_llm_roadmap,
- )
- progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
+ if include_roadmap:
retrieval_parts.append("roadmap_preview")
- if body.roadmap_first:
- retrieval_parts.append("roadmap_first_pending")
+ if roadmap_unfilled:
+ retrieval_parts.append("roadmap_unfilled")
return {
"goal_query": goal_query,
@@ -543,7 +737,8 @@ def suggest_progression_path(
"path_qa": path_qa,
"gap_fill_offers": gap_fill_offers,
"progression_roadmap": progression_roadmap,
- "roadmap_first": body.roadmap_first,
+ "roadmap_first": roadmap_first,
+ "roadmap_unfilled_count": len(roadmap_unfilled),
"retrieval_phase": "+".join(retrieval_parts),
}
diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py
index c8c90a8..2db9735 100644
--- a/backend/planning_progression_roadmap.py
+++ b/backend/planning_progression_roadmap.py
@@ -13,7 +13,7 @@ from __future__ import annotations
import json
import logging
import re
-from typing import Any, Dict, List, Optional, Sequence, Tuple
+from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from pydantic import BaseModel, Field, ValidationError
@@ -421,6 +421,107 @@ def consolidate_micro_to_major(
return majors, notes
+def _normalize_query(query: Optional[str]) -> str:
+ return re.sub(r"\s+", " ", (query or "").strip())
+
+
+def stage_spec_retrieval_query(
+ *,
+ semantic_brief: PlanningSemanticBrief,
+ goal_query: str,
+ stage_spec: StageSpecArtifact,
+ major_step: Optional[MajorStep] = None,
+) -> str:
+ """Retrieval-Query für einen Roadmap-Major-Step (Phase F3)."""
+ parts: List[str] = []
+ topic = (semantic_brief.primary_topic or semantic_brief.retrieval_query or goal_query).strip()
+ if topic:
+ parts.append(topic)
+ learning_goal = (stage_spec.learning_goal or "").strip()
+ if learning_goal:
+ parts.append(learning_goal)
+ phase = (major_step.phase if major_step else "").strip().lower()
+ if phase:
+ parts.append(phase)
+ if stage_spec.load_profile:
+ parts.extend(str(x).strip() for x in stage_spec.load_profile[:2] if str(x).strip())
+ exercise_type = (stage_spec.exercise_type or "").strip().lower()
+ if exercise_type == "partner_drill":
+ parts.append("partner")
+ elif exercise_type == "kombination":
+ parts.append("kombination")
+ return _normalize_query(" ".join(parts)) or _normalize_query(goal_query)
+
+
+def stage_spec_exercise_kind_filter(stage_spec: StageSpecArtifact) -> Optional[List[str]]:
+ """Mappt didaktischen exercise_type auf DB exercise_kind (simple/combination)."""
+ et = (stage_spec.exercise_type or "").strip().lower()
+ if et == "kombination":
+ return ["combination"]
+ if et in ("kihon_einzel", "partner_drill", "grundtechnik"):
+ return ["simple"]
+ return None
+
+
+def resolve_step_exercise_kind_filter(
+ stage_spec: StageSpecArtifact,
+ request_filter: Optional[Sequence[str]],
+) -> Optional[List[str]]:
+ """Schnittmenge aus Roadmap-Stufe und optionalem Request-Filter."""
+ stage_filter = stage_spec_exercise_kind_filter(stage_spec)
+ if not request_filter:
+ return stage_filter
+ req = [str(x).strip().lower() for x in request_filter if str(x).strip()]
+ if not stage_filter:
+ return req or None
+ merged = [k for k in stage_filter if k in req]
+ return merged or req
+
+
+def build_roadmap_unfilled_gap_specs(
+ *,
+ unfilled_specs: Sequence[Tuple[int, StageSpecArtifact]],
+ major_steps_by_index: Mapping[int, MajorStep],
+ steps: Sequence[Mapping[str, Any]],
+ brief: PlanningSemanticBrief,
+ goal_query: str,
+) -> List[Dict[str, Any]]:
+ """Gap-Fill-Angebote für Roadmap-Stufen ohne Bibliothekstreffer."""
+ topic = (brief.primary_topic or "Technik").strip()
+ specs: List[Dict[str, Any]] = []
+ for roadmap_idx, stage_spec in unfilled_specs:
+ major = major_steps_by_index.get(stage_spec.major_step_index)
+ phase = (major.phase if major else "vertiefung").strip().lower()
+ insert_after = min(max(roadmap_idx - 1, -1), max(len(steps) - 1, -1))
+ title_hint = (stage_spec.learning_goal or f"{topic} — {phase}").strip()[:120]
+ sketch_parts = [
+ f"Planungsziel: {goal_query}",
+ f"Roadmap-Stufe {stage_spec.major_step_index + 1} ({phase}): {stage_spec.learning_goal}",
+ ]
+ if stage_spec.success_criteria:
+ sketch_parts.append(f"Erfolgskriterien: {', '.join(stage_spec.success_criteria[:3])}")
+ specs.append(
+ {
+ "source": "roadmap_unfilled",
+ "insert_after_index": insert_after,
+ "gap": {
+ "expected_phase": phase,
+ "roadmap_major_step_index": stage_spec.major_step_index,
+ "learning_goal": stage_spec.learning_goal,
+ },
+ "phase": phase,
+ "title_hint": title_hint,
+ "sketch": "\n".join(sketch_parts),
+ "rationale": (
+ f"Keine passende Bibliotheks-Übung für Roadmap-Stufe "
+ f"{stage_spec.major_step_index + 1} ({phase})."
+ ),
+ "roadmap_major_step_index": stage_spec.major_step_index,
+ }
+ )
+ return specs[:5]
+
+
def build_stage_specs(
major_steps: Sequence[MajorStep],
*,
@@ -553,7 +654,11 @@ __all__ = [
"RoadmapArtifact",
"StageSpecArtifact",
"build_goal_analysis",
+ "build_roadmap_unfilled_gap_specs",
"build_stage_specs",
+ "resolve_step_exercise_kind_filter",
+ "stage_spec_exercise_kind_filter",
+ "stage_spec_retrieval_query",
"consolidate_micro_to_major",
"develop_micro_objectives",
"progression_roadmap_to_api_dict",
diff --git a/backend/tests/test_planning_exercise_path_builder.py b/backend/tests/test_planning_exercise_path_builder.py
index 12a2a3d..dc9d598 100644
--- a/backend/tests/test_planning_exercise_path_builder.py
+++ b/backend/tests/test_planning_exercise_path_builder.py
@@ -1,5 +1,10 @@
-"""Tests Planungs-KI Phase C3/E — Pfad-Vorschläge."""
-from planning_exercise_path_builder import _pick_best_path_hit, _hit_to_path_step
+"""Tests Planungs-KI Phase C3/E/F — Pfad-Vorschläge."""
+from planning_exercise_path_builder import (
+ _annotate_roadmap_step,
+ _hit_to_path_step,
+ _pick_best_path_hit,
+)
+from planning_progression_roadmap import MajorStep, StageSpecArtifact
def test_pick_next_path_hit_skips_used():
@@ -23,3 +28,17 @@ def test_hit_to_path_step_maps_variant():
assert step["exercise_id"] == 10
assert step["variant_id"] == 7
assert step["suggested_variant_name"] == "Leicht"
+
+
+def test_annotate_roadmap_step_adds_metadata():
+ spec = StageSpecArtifact(major_step_index=1, learning_goal="Grundstellung Mae Geri")
+ major = MajorStep(index=1, phase="grundlage", learning_goal=spec.learning_goal, consolidates=["m1"])
+ step = _annotate_roadmap_step(
+ {"exercise_id": 5, "title": "Test", "reasons": ["Bibliothek"]},
+ stage_spec=spec,
+ major_step=major,
+ )
+ assert step["roadmap_major_step_index"] == 1
+ assert step["roadmap_phase"] == "grundlage"
+ assert step["roadmap_match_source"] == "stage_spec"
+ assert any("Roadmap:" in r for r in step["reasons"])
diff --git a/backend/tests/test_planning_progression_roadmap.py b/backend/tests/test_planning_progression_roadmap.py
index 645f605..d2ea1d5 100644
--- a/backend/tests/test_planning_progression_roadmap.py
+++ b/backend/tests/test_planning_progression_roadmap.py
@@ -3,11 +3,17 @@ from planning_progression_roadmap import (
PROMPT_SLUG_GOAL_ANALYSIS,
PROMPT_SLUG_ROADMAP,
PROMPT_SLUG_STAGE_SPEC,
+ MajorStep,
+ StageSpecArtifact,
build_goal_analysis,
+ build_roadmap_unfilled_gap_specs,
consolidate_micro_to_major,
develop_micro_objectives,
progression_roadmap_to_api_dict,
+ resolve_step_exercise_kind_filter,
run_progression_roadmap_pipeline,
+ stage_spec_exercise_kind_filter,
+ stage_spec_retrieval_query,
)
from planning_exercise_semantics import build_semantic_brief
@@ -43,6 +49,47 @@ def test_major_steps_have_learning_goals():
assert step.consolidates
+def test_stage_spec_retrieval_query_includes_learning_goal():
+ brief = build_semantic_brief("Mae Geri Perfektion")
+ spec = StageSpecArtifact(
+ major_step_index=1,
+ learning_goal="Koordination und Präzision vertiefen",
+ load_profile=["präzision"],
+ exercise_type="kihon_einzel",
+ )
+ major = MajorStep(index=1, phase="vertiefung", learning_goal=spec.learning_goal, consolidates=["m3"])
+ q = stage_spec_retrieval_query(
+ semantic_brief=brief,
+ goal_query="Mae Geri Perfektion",
+ stage_spec=spec,
+ major_step=major,
+ )
+ assert "vertiefung" in q.lower()
+ assert "Koordination" in q or "Präzision" in q
+
+
+def test_stage_spec_exercise_kind_filter_maps_combination():
+ spec = StageSpecArtifact(major_step_index=0, exercise_type="kombination")
+ assert stage_spec_exercise_kind_filter(spec) == ["combination"]
+ assert resolve_step_exercise_kind_filter(spec, ["simple"]) == ["simple"]
+
+
+def test_build_roadmap_unfilled_gap_specs():
+ brief = build_semantic_brief("Mae Geri")
+ spec = StageSpecArtifact(major_step_index=2, learning_goal="Anwendung im Partnerdrill")
+ major = MajorStep(index=2, phase="anwendung", learning_goal=spec.learning_goal, consolidates=["m5"])
+ specs = build_roadmap_unfilled_gap_specs(
+ unfilled_specs=[(2, spec)],
+ major_steps_by_index={2: major},
+ steps=[{"exercise_id": 1, "title": "A"}, {"exercise_id": 2, "title": "B"}],
+ brief=brief,
+ goal_query="Mae Geri",
+ )
+ assert len(specs) == 1
+ assert specs[0]["source"] == "roadmap_unfilled"
+ assert specs[0]["phase"] == "anwendung"
+
+
def test_api_dict_exposes_prompt_slug_catalog():
ctx = run_progression_roadmap_pipeline("Mae Geri", max_steps=3, include_llm_roadmap=False)
api = progression_roadmap_to_api_dict(ctx)
diff --git a/backend/version.py b/backend/version.py
index 30c02de..68803c1 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.205"
+APP_VERSION = "0.8.206"
BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260606086"
@@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
- "planning_exercise_suggest": "0.17.1", # F2: Roadmap-LLM via ai_prompts-Slugs, kein Hardcoding
+ "planning_exercise_suggest": "0.18.0", # F3: roadmap_first Retrieval pro stage_spec
"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
@@ -53,6 +53,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.206",
+ "date": "2026-06-07",
+ "changes": [
+ "Phase F3: roadmap_first — Bibliotheks-Match pro stage_spec/Major Step statt iterativem Pfad.",
+ "Gap-Angebote für unbesetzte Roadmap-Stufen (roadmap_unfilled).",
+ "UI: Pfad-Builder sendet roadmap_first; Übungen an Roadmap gekoppelt.",
+ ],
+ },
{
"version": "0.8.205",
"date": "2026-06-07",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 9fb25b6..1e1e110 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-06-07
-**App-Version / DB-Schema:** App **`0.8.204`** (Planungs-KI Phase F0); DB **`20260606085`** — maßgeblich **`backend/version.py`**.
+**App-Version / DB-Schema:** App **`0.8.206`** (Planungs-KI Phase F3); 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**.
@@ -107,18 +107,19 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **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** |
| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** |
-| **F0–F1** | **Roadmap-first** Progressionsgraph (A→B→C), Workflow-lite, API-Preview | 🔄 **0.8.204** |
+| **F0–F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
+| **F3** | `roadmap_first` — Retrieval pro `stage_spec` | ✅ **0.8.206** |
| **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`**
-**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_path_builder.py`, **`planning_progression_roadmap.py`** · Router `POST /api/planning/exercise-suggest`, `POST /api/planning/progression-path-suggest` (`include_roadmap_preview`)
+**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_path_builder.py`, **`planning_progression_roadmap.py`** · Router `POST /api/planning/exercise-suggest`, `POST /api/planning/progression-path-suggest` (`roadmap_first`, `include_roadmap_preview`)
-**Frontend:** `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) + bestehender Pfad-Review · `ExercisePickerModal` (Planung)
+**Frontend:** `ExerciseProgressionPathBuilder` — Roadmap-Box + Pfad je Major Step (`roadmap_first`) · `ExercisePickerModal` (Planung)
**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow
-**Offen (F2+):** LLM Roadmap (Prompts **078**), `roadmap_first` Retrieval, Roadmap-UI editierbar; Trainingsplanung eigene Pipeline (Gruppenkontext); Enrichment
+**Offen (F4+):** Roadmap-UI editierbar; Trainingsplanung eigene Pipeline (Gruppenkontext); Enrichment
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
diff --git a/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md
index d8979b1..64c2fd3 100644
--- a/docs/architecture/PLANNING_KI_ROADMAP.md
+++ b/docs/architecture/PLANNING_KI_ROADMAP.md
@@ -58,10 +58,11 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
- [x] Deterministischer Fallback wenn Prompt/OpenRouter fehlt
- [ ] Response/UI: genutzte `prompt_slugs` sichtbar machen (Admin-Hinweis)
-### F3 — roadmap-first
+### F3 — roadmap-first (0.8.206)
-- [ ] Retrieval pro `major_step` + `stage_spec` statt iterativem Pfad-Bau
-- [ ] QA/Lücken an Roadmap koppeln
+- [x] Retrieval pro `major_step` + `stage_spec` statt iterativem Pfad-Bau
+- [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`)
+- [ ] QA/Lücken vollständig an Roadmap koppeln (Brücken optional reduzieren)
### F4 — UI
diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
index 2808018..9a082d6 100644
--- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx
+++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
@@ -58,6 +58,7 @@ const OFFER_SOURCE_LABELS = {
unfilled_gap: 'Lücke',
off_topic: 'Themenfremd',
llm_suggested: 'QS-Empfehlung',
+ roadmap_unfilled: 'Roadmap-Stufe',
}
function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
@@ -349,6 +350,7 @@ export default function ExerciseProgressionPathBuilder({
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: true,
+ roadmap_first: true,
progression_graph_id: Number(graphId),
})
const qa = res?.path_qa || null
@@ -531,7 +533,7 @@ export default function ExerciseProgressionPathBuilder({
{progressionRoadmap.llm_roadmap_applied
? ' (KI-Prompts aus Admin-Konfiguration)'
: ' (heuristischer Fallback — KI-Prompts in ai_prompts)'}
- . Übungen unten: Bibliothekssuche (Übergangsphase).
+ . Übungen unten: je Major Step aus der Bibliothek (roadmap-first).
{progressionRoadmap.roadmap.major_steps.map((step) => (