Enhance Planning Context with Progression Gap Snapshot and Start/Target Analysis
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m13s

- Introduced `build_progression_gap_snapshot` function to create a compact roadmap context for gap exercises, integrating start situation, target state, and stage specifications.
- Updated `build_gap_fill_goal_text` to include roadmap snapshot details, enhancing the context for AI-generated exercises.
- Enhanced `ProgressionPathSuggestRequest` and related components to support new structured inputs for start/target analysis, improving user experience and AI suggestions.
- Incremented application version to 0.8.212 to reflect these changes.
This commit is contained in:
Lars 2026-06-09 16:22:16 +02:00
parent fad1058d54
commit f2650dac57
11 changed files with 505 additions and 25 deletions

View File

@ -6,7 +6,7 @@ Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instruct
from __future__ import annotations
import json
from typing import Any, Dict, Mapping, Optional
from typing import Any, Dict, List, Mapping, Optional
_MAX_JSON_CHARS = 6000
_MAX_STRING = 800
@ -85,6 +85,73 @@ def planning_context_prompt_variables(
}
def build_progression_gap_snapshot(
*,
goal_analysis: Optional[Mapping[str, Any]] = None,
resolved_structured: Optional[Mapping[str, Any]] = None,
stage_spec: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
"""Kompakter Roadmap-Kontext für Lücken-Übungen (Start, Ziel, Stufe, Fähigkeiten-Hinweise)."""
ga = dict(goal_analysis or {})
rs = dict(resolved_structured or {})
spec = dict(stage_spec or {})
brief = dict(semantic_brief or {})
start = _trim_str(rs.get("start_situation") or ga.get("start_assumption"))
target = _trim_str(rs.get("target_state") or ga.get("target_state"))
notes = _trim_str(rs.get("roadmap_notes"))
topic = _trim_str(ga.get("primary_topic") or brief.get("primary_topic"))
skill_hints: List[str] = []
for item in (brief.get("must_phrases") or [])[:4]:
t = _trim_str(item, limit=120)
if t:
skill_hints.append(t)
arc = brief.get("development_arc")
if isinstance(arc, list) and arc:
skill_hints.append(f"Entwicklungsbogen: {''.join(str(x) for x in arc[:5])}")
success_path = [
_trim_str(x, limit=200)
for x in (ga.get("success_criteria") or [])
if _trim_str(x, limit=200)
][:4]
stage_success = [
_trim_str(x, limit=200)
for x in (spec.get("success_criteria") or [])
if _trim_str(x, limit=200)
][:4]
load_profile = [
_trim_str(x, limit=80)
for x in (spec.get("load_profile") or [])
if _trim_str(x, limit=80)
][:6]
anti_patterns = [
_trim_str(x, limit=200)
for x in (spec.get("anti_patterns") or [])
if _trim_str(x, limit=200)
][:3]
snap: Dict[str, Any] = {
"primary_topic": topic,
"start_situation": start,
"target_state": target,
"roadmap_notes": notes,
"stage_learning_goal": _trim_str(
spec.get("learning_goal"), limit=1200
),
"stage_phase": _trim_str(spec.get("phase")),
"stage_exercise_type": _trim_str(spec.get("exercise_type")),
"stage_load_profile": load_profile or None,
"stage_success_criteria": stage_success or None,
"stage_anti_patterns": anti_patterns or None,
"path_success_criteria": success_path or None,
"skill_hints": skill_hints or None,
}
return {k: v for k, v in snap.items() if v is not None and v != "" and v != []}
def build_progression_path_gap_planning_context(
*,
goal_query: str,
@ -97,6 +164,10 @@ def build_progression_path_gap_planning_context(
major_step_count: Optional[int] = None,
roadmap_phase: Optional[str] = None,
roadmap_learning_goal: Optional[str] = None,
goal_analysis: Optional[Mapping[str, Any]] = None,
resolved_structured: Optional[Mapping[str, Any]] = None,
stage_spec: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
offer = offer or {}
@ -127,10 +198,18 @@ def build_progression_path_gap_planning_context(
"path_step_count": path_step_count,
"major_step_count": major_step_count,
}
snap = build_progression_gap_snapshot(
goal_analysis=goal_analysis,
resolved_structured=resolved_structured,
stage_spec=stage_spec,
semantic_brief=semantic_brief,
)
ctx.update(snap)
return sanitize_planning_context_for_ai(ctx)
__all__ = [
"build_progression_gap_snapshot",
"build_progression_path_gap_planning_context",
"compact_planning_context_json",
"planning_context_prompt_variables",

View File

@ -12,7 +12,8 @@ from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_path_qa import find_step_pair_index
from planning_exercise_semantics import PlanningSemanticBrief
from planning_exercise_form_context import build_progression_gap_snapshot
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
@ -265,27 +266,62 @@ def build_gap_fill_goal_text(
spec: Mapping[str, Any],
step_a: Optional[Mapping[str, Any]] = None,
step_b: Optional[Mapping[str, Any]] = None,
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
) -> str:
"""Ausführlicher Zieltext für KI-Neuanlage aus dem Pfad-Kontext."""
"""Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext."""
topic = (brief.primary_topic or "Technik").strip()
phase = spec.get("phase") or "vertiefung"
from_title = (step_a or {}).get("title") or spec.get("from_title") or "vorherigem Schritt"
to_title = (step_b or {}).get("title") or spec.get("to_title") or "nächstem Schritt"
arc = ", ".join(brief.development_arc or []) or "einstieg → grundlage → vertiefung → anwendung → perfektion"
snap = dict(roadmap_snapshot or {})
if not snap:
snap = build_progression_gap_snapshot(semantic_brief=brief_to_summary_dict(brief))
parts = [
f"Planungsziel (gesamter Pfad): {goal_query}",
f"Hauptthema: {topic}",
f"Entwicklungsphase dieser Übung: {phase}",
f"Erwarteter Entwicklungsbogen: {arc}",
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
f"Hauptthema: {snap.get('primary_topic') or topic}",
]
if snap.get("start_situation"):
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
if snap.get("target_state"):
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
if snap.get("roadmap_notes"):
parts.append(f"Ergänzender Kontext: {snap['roadmap_notes']}")
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
if stage_goal:
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
parts.extend(
[
f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}",
f"Erwarteter Entwicklungsbogen: {arc}",
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
]
)
if snap.get("stage_load_profile"):
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
if snap.get("stage_success_criteria"):
parts.append(
"Erfolgskriterien dieser Stufe: "
+ "; ".join(str(x) for x in snap["stage_success_criteria"][:4])
)
if snap.get("stage_anti_patterns"):
parts.append(
"Vermeiden: " + "; ".join(str(x) for x in snap["stage_anti_patterns"][:3])
)
if snap.get("skill_hints"):
parts.append(
"Fähigkeiten-/Fokus-Hinweise: "
+ "; ".join(str(x) for x in snap["skill_hints"][:4])
)
if spec.get("rationale"):
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
if spec.get("sketch"):
parts.append(f"Skizze: {spec['sketch']}")
parts.append(
"Die Übung muss einen klaren, trainierbaren Bezug zum Hauptthema haben — "
"keine generische Kraftübung ohne Technikbezug. Konkrete Durchführung, Ziel und Trainerhinweise ausformulieren."
"Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, "
"Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. "
"Konkrete Durchführung, Ziel und Trainerhinweise ausformulieren."
)
return "\n\n".join(parts)[:8000]
@ -297,6 +333,7 @@ def build_gap_fill_offer(
goal_query: str = "",
brief: Optional[PlanningSemanticBrief] = None,
proposal: Optional[Mapping[str, Any]] = None,
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
idx = int(spec.get("insert_after_index") or 0)
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
@ -310,6 +347,7 @@ def build_gap_fill_offer(
spec=spec,
step_a=step_a,
step_b=step_b,
roadmap_snapshot=roadmap_snapshot,
)
offer: Dict[str, Any] = {
"offer_id": offer_id,
@ -345,6 +383,7 @@ def apply_gap_fill_after_qa(
include_ai_calls: bool = True,
max_ai_proposals: int = 3,
auto_insert_proposals: bool = False,
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
@ -370,6 +409,7 @@ def apply_gap_fill_after_qa(
goal_query=goal_query,
brief=brief,
proposal=None,
roadmap_snapshot=roadmap_snapshot,
)
offers.append(offer)
continue
@ -397,6 +437,7 @@ def apply_gap_fill_after_qa(
goal_query=goal_query,
brief=brief,
proposal=proposal,
roadmap_snapshot=roadmap_snapshot,
)
offers.append(offer)

View File

@ -6,7 +6,7 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple
from fastapi import HTTPException
from pydantic import BaseModel, Field
@ -50,6 +50,7 @@ from planning_exercise_suggest import (
_normalize_query,
resolve_planning_exercise_intent,
)
from planning_exercise_form_context import build_progression_gap_snapshot
from planning_progression_roadmap import (
MajorStep,
ProgressionRoadmapContext,
@ -61,6 +62,7 @@ from planning_progression_roadmap import (
resolve_step_exercise_kind_filter,
roadmap_context_from_override,
run_progression_roadmap_pipeline,
run_start_target_resolve_only,
stage_spec_retrieval_query,
)
from routers.training_planning import _has_planning_role
@ -79,6 +81,7 @@ class ProgressionPathSuggestRequest(BaseModel):
include_llm_start_target: bool = True
roadmap_first: bool = False
roadmap_only: bool = False
start_target_only: bool = False
roadmap_override: Optional[RoadmapOverridePayload] = None
start_situation: Optional[str] = Field(default=None, max_length=2000)
target_state: Optional[str] = Field(default=None, max_length=2000)
@ -87,6 +90,44 @@ class ProgressionPathSuggestRequest(BaseModel):
exercise_kind_any: Optional[List[str]] = None
def _roadmap_gap_snapshot_for_spec(
roadmap_ctx: Optional[ProgressionRoadmapContext],
spec: Mapping[str, Any],
*,
semantic_brief: PlanningSemanticBrief,
) -> Dict[str, Any]:
"""Roadmap-Kontext für KI-Lücken-Übung (Start, Ziel, Stufenspec)."""
major_idx = spec.get("roadmap_major_step_index")
stage_spec_dict: Optional[Dict[str, Any]] = None
if roadmap_ctx and major_idx is not None:
for s in roadmap_ctx.stage_specs or []:
if int(s.major_step_index) == int(major_idx):
stage_spec_dict = s.model_dump()
if roadmap_ctx.roadmap:
for m in roadmap_ctx.roadmap.major_steps:
if m.index == int(major_idx):
stage_spec_dict["phase"] = m.phase
break
break
ga = roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx and roadmap_ctx.goal_analysis else None
rs = (
roadmap_ctx.resolved_structured.model_dump()
if roadmap_ctx and roadmap_ctx.resolved_structured
else None
)
brief_summary = (
roadmap_ctx.semantic_brief
if roadmap_ctx and roadmap_ctx.semantic_brief
else brief_to_summary_dict(semantic_brief)
)
return build_progression_gap_snapshot(
goal_analysis=ga,
resolved_structured=rs,
stage_spec=stage_spec_dict,
semantic_brief=brief_summary,
)
def _roadmap_structured_from_body(body: ProgressionPathSuggestRequest) -> Optional[RoadmapStructuredInput]:
start = (body.start_situation or "").strip() or None
target = (body.target_state or "").strip() or None
@ -516,7 +557,10 @@ def suggest_progression_path(
roadmap_first = bool(body.roadmap_first)
roadmap_only = bool(body.roadmap_only)
include_roadmap = roadmap_first or body.include_roadmap_preview or roadmap_only
start_target_only = bool(body.start_target_only)
include_roadmap = (
roadmap_first or body.include_roadmap_preview or roadmap_only or start_target_only
)
progression_roadmap: Optional[Dict[str, Any]] = None
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
roadmap_edited = False
@ -538,6 +582,15 @@ def suggest_progression_path(
roadmap_edited = True
max_steps = int(roadmap_ctx.max_steps)
roadmap_first = True
elif start_target_only:
roadmap_ctx = run_start_target_resolve_only(
goal_query,
semantic_brief=semantic_brief,
cur=cur,
include_llm_start_target=body.include_llm_start_target,
structured=roadmap_structured,
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
elif include_roadmap:
roadmap_ctx = run_progression_roadmap_pipeline(
goal_query,
@ -550,6 +603,28 @@ def suggest_progression_path(
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
if start_target_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": False,
"start_target_only": True,
"roadmap_edited": False,
"roadmap_unfilled_count": 0,
"retrieval_phase": "start_target_only",
}
if roadmap_only:
return {
"goal_query": goal_query,
@ -615,6 +690,8 @@ def suggest_progression_path(
steps=steps,
brief=semantic_brief,
goal_query=goal_query,
goal_analysis=roadmap_ctx.goal_analysis if roadmap_ctx else None,
resolved_structured=roadmap_ctx.resolved_structured if roadmap_ctx else None,
)
for spec in roadmap_gap_specs:
roadmap_gap_offers.append(
@ -624,6 +701,9 @@ def suggest_progression_path(
goal_query=goal_query,
brief=semantic_brief,
proposal=None,
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
roadmap_ctx, spec, semantic_brief=semantic_brief
),
)
)
else:
@ -760,6 +840,21 @@ def suggest_progression_path(
brief=semantic_brief,
goal_query=goal_query,
)
path_roadmap_snapshot = None
if roadmap_ctx:
path_roadmap_snapshot = build_progression_gap_snapshot(
goal_analysis=(
roadmap_ctx.goal_analysis.model_dump()
if roadmap_ctx.goal_analysis
else None
),
resolved_structured=(
roadmap_ctx.resolved_structured.model_dump()
if roadmap_ctx.resolved_structured
else None
),
semantic_brief=roadmap_ctx.semantic_brief or brief_to_summary_dict(semantic_brief),
)
steps, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa(
cur,
steps,
@ -769,6 +864,7 @@ def suggest_progression_path(
include_ai_calls=False,
max_ai_proposals=0,
auto_insert_proposals=False,
roadmap_snapshot=path_roadmap_snapshot,
)
if roadmap_gap_offers:

View File

@ -794,6 +794,8 @@ def build_roadmap_unfilled_gap_specs(
steps: Sequence[Mapping[str, Any]],
brief: PlanningSemanticBrief,
goal_query: str,
goal_analysis: Optional[GoalAnalysisArtifact] = None,
resolved_structured: Optional[RoadmapStructuredInput] = None,
) -> List[Dict[str, Any]]:
"""Gap-Fill-Angebote für Roadmap-Stufen ohne Bibliothekstreffer."""
topic = (brief.primary_topic or "Technik").strip()
@ -807,8 +809,18 @@ def build_roadmap_unfilled_gap_specs(
f"Planungsziel: {goal_query}",
f"Roadmap-Stufe {stage_spec.major_step_index + 1} ({phase}): {stage_spec.learning_goal}",
]
if resolved_structured and (resolved_structured.start_situation or "").strip():
sketch_parts.append(f"Ausgangslage (Pfad): {resolved_structured.start_situation.strip()}")
elif goal_analysis and (goal_analysis.start_assumption or "").strip():
sketch_parts.append(f"Ausgangslage (Pfad): {goal_analysis.start_assumption.strip()}")
if resolved_structured and (resolved_structured.target_state or "").strip():
sketch_parts.append(f"Gesamtziel (Pfad): {resolved_structured.target_state.strip()}")
elif goal_analysis and (goal_analysis.target_state or "").strip():
sketch_parts.append(f"Gesamtziel (Pfad): {goal_analysis.target_state.strip()}")
if stage_spec.success_criteria:
sketch_parts.append(f"Erfolgskriterien: {', '.join(stage_spec.success_criteria[:3])}")
if stage_spec.load_profile:
sketch_parts.append(f"Belastung: {', '.join(stage_spec.load_profile[:4])}")
specs.append(
{
"source": "roadmap_unfilled",
@ -960,6 +972,48 @@ def _merge_structured_into_goal_analysis(
)
def run_start_target_resolve_only(
goal_query: str,
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
cur=None,
include_llm_start_target: bool = True,
structured: Optional[RoadmapStructuredInput] = None,
) -> ProgressionRoadmapContext:
"""Nur Start/Ziel/Ergänzungen auflösen — ohne Roadmap-Stufen (Review vor Major Steps)."""
brief = semantic_brief or build_semantic_brief(goal_query)
resolved, resolve_meta, llm_extract = resolve_roadmap_structured_input(
goal_query,
structured,
brief=brief,
cur=cur,
include_llm=include_llm_start_target,
)
topic_override = None
if llm_extract and (llm_extract.primary_topic or "").strip():
topic_override = llm_extract.primary_topic.strip()
goal_analysis = build_goal_analysis(
goal_query,
brief,
structured=resolved,
topic_override=topic_override,
)
ctx = ProgressionRoadmapContext(
goal_query=goal_query.strip(),
max_steps=2,
semantic_brief=brief_to_summary_dict(brief),
resolved_structured=resolved,
start_target_extract=llm_extract,
start_target_resolve=resolve_meta,
goal_analysis=goal_analysis,
llm_start_target_applied=resolve_meta.llm_start_target_applied,
pipeline_phase="start_target_only",
)
if resolve_meta.llm_start_target_applied:
ctx.prompt_slugs.append(PROMPT_SLUG_START_TARGET)
return ctx
def run_progression_roadmap_pipeline(
goal_query: str,
*,
@ -1135,6 +1189,7 @@ __all__ = [
"consolidate_micro_to_major",
"develop_micro_objectives",
"progression_roadmap_to_api_dict",
"run_start_target_resolve_only",
"run_progression_roadmap_pipeline",
"try_llm_start_target_extract",
"try_llm_goal_analysis",

View File

@ -73,6 +73,7 @@ def post_progression_path_suggest(
or body.include_ai_gap_fill
or body.include_llm_roadmap
or body.include_llm_start_target
or (body.start_target_only and body.include_llm_start_target)
)
club_id = resolve_club_id_for_probe(tenant) if uses_ai else None
if uses_ai:

View File

@ -1,5 +1,6 @@
"""Tests Planungs-KI Phase D — planning_context für suggestExerciseAi."""
from planning_exercise_form_context import (
build_progression_gap_snapshot,
build_progression_path_gap_planning_context,
planning_context_prompt_variables,
sanitize_planning_context_for_ai,
@ -44,3 +45,36 @@ def test_build_progression_path_gap_context():
def test_sanitize_truncates_long_strings():
ctx = sanitize_planning_context_for_ai({"goal_query": "x" * 900})
assert len(ctx["goal_query"]) <= 800
def test_build_progression_gap_snapshot_includes_start_target_and_stage():
snap = build_progression_gap_snapshot(
goal_analysis={
"primary_topic": "Kumite Beinarbeit",
"start_assumption": "gleichförmige Steppbewegung",
"target_state": "explosiver Angriff mit Ausweichen",
"success_criteria": ["nachvollziehbarer Übergang"],
},
resolved_structured={"roadmap_notes": "Kindergruppe"},
stage_spec={
"learning_goal": "variable Rhythmen",
"load_profile": ["timing", "distanz"],
"success_criteria": ["Reaktion unter Druck"],
"anti_patterns": ["statisches Stehen"],
},
semantic_brief={"must_phrases": ["Beinarbeit"], "development_arc": ["grundlage", "anwendung"]},
)
assert snap["start_situation"] == "gleichförmige Steppbewegung"
assert snap["stage_learning_goal"] == "variable Rhythmen"
assert "timing" in snap["stage_load_profile"]
assert snap["roadmap_notes"] == "Kindergruppe"
def test_gap_planning_context_carries_snapshot_fields():
ctx = build_progression_path_gap_planning_context(
goal_query="Kumite Beinarbeit",
goal_analysis={"start_assumption": "Start", "target_state": "Ziel"},
stage_spec={"learning_goal": "Stufenziel", "load_profile": ["koordination"]},
)
assert ctx["start_situation"] == "Start"
assert ctx["stage_learning_goal"] == "Stufenziel"

View File

@ -91,3 +91,25 @@ def test_build_gap_fill_goal_text_includes_topic():
assert "Mae Geri" in text or "mae geri" in text.lower()
assert "anwendung" in text
assert "Kihon" in text
def test_build_gap_fill_goal_text_includes_roadmap_snapshot():
brief = build_semantic_brief("Kumite Beinarbeit")
text = build_gap_fill_goal_text(
goal_query="Kumite Beinarbeit",
brief=brief,
spec={"phase": "vertiefung", "title_hint": "variable Rhythmen"},
step_a={"title": "Schritt A"},
step_b={"title": "Schritt B"},
roadmap_snapshot={
"start_situation": "gleichförmige Steppbewegung",
"target_state": "explosiver Angriff",
"stage_learning_goal": "variable Rhythmen und multidirektionale Kontrolle",
"stage_load_profile": ["timing", "distanz"],
"skill_hints": ["Beinarbeit"],
},
)
assert "gleichförmige Steppbewegung" in text
assert "explosiver Angriff" in text
assert "variable Rhythmen" in text
assert "timing" in text

View File

@ -16,6 +16,7 @@ from planning_progression_roadmap import (
resolve_roadmap_structured_input,
resolve_step_exercise_kind_filter,
run_progression_roadmap_pipeline,
run_start_target_resolve_only,
stage_spec_exercise_kind_filter,
stage_spec_retrieval_query,
normalize_major_steps_for_override,
@ -173,6 +174,15 @@ def test_resolve_structured_regex_fallback_without_llm():
assert "dynamischen" in (resolved.target_state or "")
def test_run_start_target_resolve_only_no_major_steps():
ctx = run_start_target_resolve_only(KUMITE_GOAL, include_llm_start_target=False)
assert ctx.pipeline_phase == "start_target_only"
assert ctx.roadmap is None
assert ctx.goal_analysis is not None
assert "Steppbewegung" in ctx.goal_analysis.start_assumption
assert ctx.resolved_structured is not None
def test_resolve_structured_merges_user_and_llm_notes():
brief = build_semantic_brief("Kumite Beinarbeit")
structured = RoadmapStructuredInput(roadmap_notes="Kindergruppe 1012")

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.211"
APP_VERSION = "0.8.212"
BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260607087"
@ -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.21.0", # LLM Start/Ziel-Extraktion (planning_progression_start_target)
"planning_exercise_suggest": "0.21.1", # start_target_only + reicher gap-fill planning_context
"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

View File

@ -201,8 +201,10 @@ export default function ExerciseProgressionPathBuilder({
const [editableMajorSteps, setEditableMajorSteps] = useState([])
const [roadmapDirty, setRoadmapDirty] = useState(false)
const [loadingRoadmap, setLoadingRoadmap] = useState(false)
const [loadingStartTarget, setLoadingStartTarget] = useState(false)
const [loadingMatch, setLoadingMatch] = useState(false)
const loading = loadingRoadmap || loadingMatch
const [startTargetAnalyzed, setStartTargetAnalyzed] = useState(false)
const loading = loadingRoadmap || loadingStartTarget || loadingMatch
const [focusAreas, setFocusAreas] = useState([])
const [skillsCatalog, setSkillsCatalog] = useState([])
const [generatingOfferId, setGeneratingOfferId] = useState(null)
@ -397,6 +399,9 @@ export default function ExerciseProgressionPathBuilder({
pathSteps,
editableMajorSteps,
progressionRoadmap,
startSituation,
targetState,
roadmapNotes,
})
try {
const aiRes = await api.suggestExerciseAi({
@ -531,6 +536,60 @@ export default function ExerciseProgressionPathBuilder({
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
}
const applyStartTargetResponse = (res) => {
const roadmap = res?.progression_roadmap || null
setProgressionRoadmap((prev) => ({
...(prev || {}),
...roadmap,
roadmap: prev?.roadmap || roadmap?.roadmap || null,
stage_specs: prev?.stage_specs || roadmap?.stage_specs || [],
}))
applyResolvedStructuredFromRoadmap(roadmap, {
setStartSituation,
setTargetState,
setRoadmapNotes,
})
setSemanticBrief(res?.semantic_brief_summary || null)
setStartTargetAnalyzed(true)
}
const analyzeStartTarget = 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
}
setLoadingStartTarget(true)
setError('')
try {
const res = await api.suggestProgressionPath({
query: q,
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: false,
include_llm_roadmap: false,
include_llm_start_target: true,
start_target_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
})
applyStartTargetResponse(res)
} catch (e) {
console.error(e)
setError(e.message || 'Start/Ziel-Analyse fehlgeschlagen')
} finally {
setLoadingStartTarget(false)
}
}
const suggestRoadmap = async () => {
const q = (goalQuery || '').trim()
if (q.length < 3) {
@ -541,6 +600,7 @@ export default function ExerciseProgressionPathBuilder({
alert('Zuerst einen Graphen wählen.')
return
}
const fieldsEmpty = !startSituation.trim() && !targetState.trim()
setLoadingRoadmap(true)
setError('')
try {
@ -554,7 +614,7 @@ export default function ExerciseProgressionPathBuilder({
include_ai_gap_fill: false,
include_roadmap_preview: true,
include_llm_roadmap: true,
include_llm_start_target: true,
include_llm_start_target: fieldsEmpty,
roadmap_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
@ -567,11 +627,14 @@ export default function ExerciseProgressionPathBuilder({
setMaxSteps(majors.length)
const roadmap = res?.progression_roadmap || null
setProgressionRoadmap(roadmap)
applyResolvedStructuredFromRoadmap(roadmap, {
setStartSituation,
setTargetState,
setRoadmapNotes,
})
if (fieldsEmpty) {
applyResolvedStructuredFromRoadmap(roadmap, {
setStartSituation,
setTargetState,
setRoadmapNotes,
})
setStartTargetAnalyzed(true)
}
setSemanticBrief(res?.semantic_brief_summary || null)
setPathSteps([])
setTargetSummary(null)
@ -767,18 +830,37 @@ export default function ExerciseProgressionPathBuilder({
</div>
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.4 }}>
Leer gelassen: Start/Ziel werden per KI aus dem Zieltext verstanden und formuliert (Fallback: Muster
von bis ). Manuelle Eingaben haben Vorrang.
Optional zuerst Start/Ziel analysieren, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end', marginTop: '10px' }}>
<button
type="button"
className="btn btn-secondary"
disabled={disabled || loading || saving || !graphId}
onClick={analyzeStartTarget}
title="Nur Ausgangslage, Zielzustand und Ergänzungen per KI — ohne Roadmap-Stufen"
>
{loadingStartTarget ? 'Analyse …' : 'Start/Ziel analysieren'}
</button>
<button
type="button"
className="btn btn-primary"
disabled={disabled || loading || saving || !graphId}
onClick={suggestRoadmap}
title={
startSituation.trim() && targetState.trim()
? 'Roadmap-Stufen aus den gesetzten Start/Ziel-Feldern'
: 'Start/Ziel-Analyse und Roadmap-Stufen in einem Schritt'
}
>
{loadingRoadmap ? 'Roadmap …' : 'Roadmap vorschlagen'}
</button>
{startTargetAnalyzed && !editableMajorSteps.length ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Start/Ziel bereit Roadmap als Nächstes
</span>
) : null}
<button
type="button"
className="btn btn-secondary"
@ -808,7 +890,8 @@ export default function ExerciseProgressionPathBuilder({
</p>
) : null}
{progressionRoadmap?.goal_analysis ? (
{(progressionRoadmap?.goal_analysis ||
progressionRoadmap?.pipeline_phase === 'start_target_only') ? (
<div
style={{
marginTop: '12px',

View File

@ -30,6 +30,18 @@ export function buildPickerPlanningContextForAi({
return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null && v !== ''))
}
function stageSpecForMajorIndex(progressionRoadmap, majorIdx) {
if (majorIdx == null || !progressionRoadmap) return null
const specs = progressionRoadmap?.stage_specs
if (!Array.isArray(specs)) return null
const hit = specs.find((s) => Number(s.major_step_index) === Number(majorIdx))
if (!hit) return null
const majors = progressionRoadmap?.roadmap?.major_steps
const major =
Array.isArray(majors) && majors.find((m) => Number(m.index) === Number(majorIdx))
return major ? { ...hit, phase: hit.phase || major.phase } : hit
}
export function buildPathGapPlanningContextForAi({
goalQuery = '',
semanticBrief = null,
@ -38,6 +50,9 @@ export function buildPathGapPlanningContextForAi({
pathSteps = [],
editableMajorSteps = [],
progressionRoadmap = null,
startSituation = '',
targetState = '',
roadmapNotes = '',
} = {}) {
const afterIdx = Number(offer?.insert_after_index)
const stepA = Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx] : null
@ -49,19 +64,63 @@ export function buildPathGapPlanningContextForAi({
majorIdxRaw != null && Number.isFinite(Number(majorIdxRaw)) ? Number(majorIdxRaw) : null
const majorStep =
majorIdx != null && editableMajorSteps[majorIdx] ? editableMajorSteps[majorIdx] : null
const stageSpec = stageSpecForMajorIndex(progressionRoadmap, majorIdx)
const ga = progressionRoadmap?.goal_analysis || null
const rs = progressionRoadmap?.resolved_structured || null
const start =
(startSituation || '').trim() ||
rs?.start_situation ||
ga?.start_assumption ||
null
const target =
(targetState || '').trim() || rs?.target_state || ga?.target_state || null
const notes = (roadmapNotes || '').trim() || rs?.roadmap_notes || null
const skillHints = []
if (Array.isArray(semanticBrief?.must_phrases)) {
semanticBrief.must_phrases.slice(0, 4).forEach((p) => {
const s = String(p || '').trim()
if (s) skillHints.push(s)
})
}
if (Array.isArray(semanticBrief?.development_arc) && semanticBrief.development_arc.length) {
skillHints.push(
`Entwicklungsbogen: ${semanticBrief.development_arc.slice(0, 5).join(' → ')}`,
)
}
const ctx = {
source: 'progression_path_gap_fill',
goal_query: (goalQuery || '').trim() || null,
primary_topic: semanticBrief?.primary_topic || null,
primary_topic: ga?.primary_topic || semanticBrief?.primary_topic || null,
progression_graph_id: graphId != null ? Number(graphId) : null,
gap_source: offer?.source || null,
gap_phase: offer?.phase || offer?.gap?.expected_phase || null,
roadmap_major_step_index: majorIdx,
roadmap_phase: majorStep?.phase || offer?.phase || null,
roadmap_phase: majorStep?.phase || stageSpec?.phase || offer?.phase || null,
roadmap_learning_goal:
(majorStep?.learning_goal || offer?.title_hint || offer?.gap?.learning_goal || '').trim() ||
null,
start_situation: start,
target_state: target,
roadmap_notes: notes,
stage_learning_goal: stageSpec?.learning_goal || null,
stage_phase: stageSpec?.phase || majorStep?.phase || null,
stage_exercise_type: stageSpec?.exercise_type || null,
stage_load_profile: Array.isArray(stageSpec?.load_profile)
? stageSpec.load_profile.slice(0, 6)
: null,
stage_success_criteria: Array.isArray(stageSpec?.success_criteria)
? stageSpec.success_criteria.slice(0, 4)
: null,
stage_anti_patterns: Array.isArray(stageSpec?.anti_patterns)
? stageSpec.anti_patterns.slice(0, 3)
: null,
path_success_criteria: Array.isArray(ga?.success_criteria)
? ga.success_criteria.slice(0, 4)
: null,
skill_hints: skillHints.length ? skillHints : null,
neighbor_before_title: stepA?.exerciseTitle || offer?.from_title || null,
neighbor_after_title: stepB?.exerciseTitle || offer?.to_title || null,
path_step_count: Array.isArray(pathSteps) ? pathSteps.length : 0,