Add Structured Roadmap Inputs and Enhance Goal Analysis Features
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s

- Introduced `RoadmapStructuredInput` to encapsulate structured inputs for start situation, target state, and roadmap notes.
- Updated `ProgressionPathSuggestRequest` to include new fields for structured roadmap inputs.
- Implemented parsing logic for goal queries to extract start and target states, enhancing the goal analysis process.
- Enhanced `build_goal_analysis` to utilize structured inputs, improving the clarity and relevance of generated goals.
- Updated the `ExerciseProgressionPathBuilder` component to support new structured input fields, enhancing user experience.
- Incremented application version to 0.8.210 to reflect these changes.
This commit is contained in:
Lars 2026-06-09 11:10:46 +02:00
parent 87f258be38
commit 9dd44ce3ca
5 changed files with 428 additions and 18 deletions

View File

@ -54,6 +54,7 @@ from planning_progression_roadmap import (
MajorStep,
ProgressionRoadmapContext,
RoadmapOverridePayload,
RoadmapStructuredInput,
StageSpecArtifact,
build_roadmap_unfilled_gap_specs,
progression_roadmap_to_api_dict,
@ -78,10 +79,26 @@ class ProgressionPathSuggestRequest(BaseModel):
roadmap_first: bool = False
roadmap_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)
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None
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
notes = (body.roadmap_notes or "").strip() or None
if not any([start, target, notes]):
return None
return RoadmapStructuredInput(
start_situation=start,
target_state=target,
roadmap_notes=notes,
)
def _pick_best_path_hit(
hits: List[Dict[str, Any]],
used_exercise_ids: Set[int],
@ -502,6 +519,7 @@ def suggest_progression_path(
progression_roadmap: Optional[Dict[str, Any]] = None
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
roadmap_edited = False
roadmap_structured = _roadmap_structured_from_body(body)
if body.roadmap_override is not None:
try:
@ -510,6 +528,7 @@ def suggest_progression_path(
max_steps=max_steps,
semantic_brief=semantic_brief,
override=body.roadmap_override,
structured=roadmap_structured,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@ -525,6 +544,7 @@ def suggest_progression_path(
semantic_brief=semantic_brief,
cur=cur,
include_llm_roadmap=body.include_llm_roadmap,
structured=roadmap_structured,
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)

View File

@ -109,6 +109,14 @@ class StageSpecArtifact(BaseModel):
anti_patterns: List[str] = Field(default_factory=list)
class RoadmapStructuredInput(BaseModel):
"""Optionale Strukturierung: Start, Ziel, Ergänzungen (Progressionsgraph, kein Gruppen-Tracking)."""
start_situation: Optional[str] = Field(default=None, max_length=2000)
target_state: Optional[str] = Field(default=None, max_length=2000)
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
class RoadmapOverridePayload(BaseModel):
"""Vom Trainer bearbeitete Major Steps — steuert Retrieval ohne erneute Roadmap-KI."""
@ -116,6 +124,9 @@ class RoadmapOverridePayload(BaseModel):
stage_specs: Optional[List[StageSpecArtifact]] = None
_GENERIC_START_MARKER = "Voraussetzungen der Zielgruppe werden im Progressionsgraphen nicht analysiert"
class ProgressionRoadmapContext(BaseModel):
goal_query: str
max_steps: int = Field(ge=2, le=10, default=5)
@ -268,34 +279,177 @@ def _topic_label(brief: PlanningSemanticBrief) -> str:
return (brief.primary_topic or brief.retrieval_query or "Technik").strip()
_PHASE_TOPIC_WORDS = frozenset(
{"einstieg", "grundlage", "vertiefung", "anwendung", "perfektion", "technik"}
)
def _extract_topic_from_goal_query(goal_query: str, brief: PlanningSemanticBrief) -> str:
q = (goal_query or "").strip()
m = re.match(r"^(.+?)\s+von\s+(?:der|die|dem|das|einer?|einem)\s+", q, flags=re.IGNORECASE)
if m:
cand = m.group(1).strip().rstrip(".,;")
if len(cand) >= 3:
return cand
topic = _topic_label(brief)
if topic and topic.lower() not in _PHASE_TOPIC_WORDS and len(topic) >= 4:
return topic
return topic or "Technik"
def parse_start_target_from_goal_query(goal_query: str) -> Tuple[Optional[str], Optional[str]]:
"""„von … bis …“ aus Freitext (z. B. Kumite Beinarbeit von X bis Y)."""
q = (goal_query or "").strip()
if not q:
return None, None
m = re.search(
r"\bvon\s+((?:(?:der|die|dem|das|einer?|einem)\s+)?.+?)\s+bis\s+"
r"(?:zur?|zum|zu der|zu einem)?\s*(.+?)\s*$",
q,
flags=re.IGNORECASE | re.DOTALL,
)
if not m:
return None, None
start = m.group(1).strip().rstrip(".,;")
target = m.group(2).strip().rstrip(".,;")
if len(start) < 4 or len(target) < 4:
return None, None
return start[:800], target[:800]
def _roadmap_llm_goal_block(
goal_query: str,
*,
structured: Optional[RoadmapStructuredInput] = None,
parsed_start: Optional[str] = None,
parsed_target: Optional[str] = None,
) -> str:
"""Reicher Kontext für Roadmap-LLM ohne zwingend neue Prompt-Migration."""
lines = [f"Gesamtanfrage: {(goal_query or '').strip()}"]
start = (structured.start_situation if structured else None) or parsed_start
target = (structured.target_state if structured else None) or parsed_target
notes = structured.roadmap_notes if structured else None
if start:
lines.append(f"Ausgangslage/Startpunkt: {start.strip()}")
if target:
lines.append(f"Zielzustand: {target.strip()}")
if notes and notes.strip():
lines.append(f"Ergänzungen (Fokus, Gruppe, Besonderheiten): {notes.strip()}")
return "\n".join(lines)
def build_goal_analysis(
goal_query: str,
brief: PlanningSemanticBrief,
*,
structured: Optional[RoadmapStructuredInput] = None,
) -> GoalAnalysisArtifact:
"""Phase A — deterministisch aus Anfrage + Semantic Brief."""
topic = _topic_label(brief)
target = goal_query.strip() or f"Entwicklung {topic}"
"""Phase A — aus Anfrage, optionalen Feldern und Semantic Brief."""
topic = _extract_topic_from_goal_query(goal_query, brief)
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
start = (structured.start_situation if structured else None) or parsed_start
target = (structured.target_state if structured else None) or parsed_target
notes = (structured.roadmap_notes if structured else None) or ""
if not target:
target = goal_query.strip() or f"Entwicklung {topic}"
arc = list(brief.development_arc or [])
start_phase = arc[0] if arc else "grundlage"
target_phase = arc[-1] if arc else "perfektion"
if start:
start_assumption = start.strip()
else:
start_assumption = (
f"Einstieg auf Niveau „{start_phase}“ — {_GENERIC_START_MARKER} "
"(erst Trainingsplanung)."
)
criteria: List[str] = []
if brief.must_phrases:
criteria.extend(brief.must_phrases[:3])
if topic:
criteria.append(f"klarer Bezug zu {topic}")
if start and target:
criteria.append(f"nachvollziehbarer Übergang von „{start[:80]}“ zu „{target[:80]}")
if notes.strip():
criteria.append(f"Berücksichtigung: {notes.strip()[:200]}")
constraints: Dict[str, Any] = {"partner_required": False, "group_analysis": False}
if notes.strip():
constraints["trainer_notes"] = notes.strip()[:500]
return GoalAnalysisArtifact(
primary_topic=topic,
start_assumption=(
f"Einstieg auf Niveau „{start_phase}“ — Voraussetzungen der Zielgruppe werden im "
"Progressionsgraphen nicht analysiert (erst Trainingsplanung)."
),
target_state=target,
start_assumption=start_assumption,
target_state=target.strip(),
success_criteria=criteria or [f"sichere Entwicklung Richtung {target_phase}"],
constraints={"partner_required": False, "group_analysis": False},
constraints=constraints,
)
def _has_specific_start_target(goal_analysis: GoalAnalysisArtifact) -> bool:
start = (goal_analysis.start_assumption or "").strip()
target = (goal_analysis.target_state or "").strip()
if _GENERIC_START_MARKER in start:
return False
return len(start) >= 6 and len(target) >= 6 and start != target
def _target_facets(target: str) -> List[str]:
parts = re.split(r"\s+und\s+|\s+mit\s+|,\s*", target, flags=re.IGNORECASE)
out: List[str] = []
for p in parts:
s = p.strip().rstrip(".,;")
if len(s) >= 5 and s.lower() not in {x.lower() for x in out}:
out.append(s[:200])
return out[:6]
def develop_micro_objectives_from_start_target(
goal_analysis: GoalAnalysisArtifact,
*,
min_count: int,
) -> List[MicroObjective]:
"""Zwischenziele entlang Start → Ziel (heuristisch, themenspezifisch)."""
topic = goal_analysis.primary_topic or "Technik"
start = goal_analysis.start_assumption.strip()
target = goal_analysis.target_state.strip()
facets = _target_facets(target)
phases = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"]
titles: List[str] = [f"{topic}: Ausgang — {start}"]
n_middle = max(0, min_count - 2)
for i in range(n_middle):
if facets and i < len(facets):
titles.append(f"{topic}: {facets[i]} — schrittweise einführen")
else:
titles.append(
f"{topic}: Übergangsschritt {i + 1} — Annäherung vom Ausgang zum Ziel"
)
titles.append(f"{topic}: Ziel — {target}")
while len(titles) < min_count:
titles.insert(max(1, len(titles) - 1), f"{topic}: Vertiefung vor Zielerreichung")
titles = titles[:max(min_count, 2)]
micro: List[MicroObjective] = []
for i, title in enumerate(titles):
phase = phases[min(i, len(phases) - 1)]
micro.append(
MicroObjective(
id=f"m{i + 1}",
phase=phase,
title=title,
weight=0.9 if i in (0, len(titles) - 1) else 0.85,
depends_on=[f"m{i}"] if i > 0 else [],
)
)
return micro
def _micro_title_for_phase(phase: str, topic: str) -> str:
p = (phase or "vertiefung").lower()
labels = {
@ -314,7 +468,10 @@ def develop_micro_objectives(
goal_analysis: GoalAnalysisArtifact,
min_count: int = 6,
) -> List[MicroObjective]:
"""Phase B1 — Zwischenziele (heuristisch aus development_arc)."""
"""Phase B1 — Zwischenziele (Start→Ziel oder development_arc-Fallback)."""
if _has_specific_start_target(goal_analysis):
return develop_micro_objectives_from_start_target(goal_analysis, min_count=min_count)
topic = goal_analysis.primary_topic or _topic_label(brief)
arc = [str(p).lower() for p in (brief.development_arc or []) if str(p).strip()]
seen_phases: set = set()
@ -592,11 +749,12 @@ def roadmap_context_from_override(
max_steps: int,
semantic_brief: PlanningSemanticBrief,
override: RoadmapOverridePayload,
structured: Optional[RoadmapStructuredInput] = None,
) -> 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)
goal_analysis = build_goal_analysis(goal_query, semantic_brief, structured=structured)
stage_specs: List[StageSpecArtifact]
if override.stage_specs and len(override.stage_specs) >= effective_max:
stage_specs = []
@ -632,6 +790,31 @@ def roadmap_context_from_override(
)
def _merge_structured_into_goal_analysis(
llm_ga: GoalAnalysisArtifact,
*,
goal_query: str,
brief: PlanningSemanticBrief,
structured: Optional[RoadmapStructuredInput],
) -> GoalAnalysisArtifact:
ga_struct = build_goal_analysis(goal_query, brief, structured=structured)
if not _has_specific_start_target(ga_struct):
return llm_ga
merged_criteria = list(
dict.fromkeys((llm_ga.success_criteria or []) + (ga_struct.success_criteria or []))
)[:6]
merged_constraints = {**(llm_ga.constraints or {}), **(ga_struct.constraints or {})}
return llm_ga.model_copy(
update={
"primary_topic": ga_struct.primary_topic or llm_ga.primary_topic,
"start_assumption": ga_struct.start_assumption,
"target_state": ga_struct.target_state,
"success_criteria": merged_criteria,
"constraints": merged_constraints,
}
)
def run_progression_roadmap_pipeline(
goal_query: str,
*,
@ -639,20 +822,33 @@ def run_progression_roadmap_pipeline(
semantic_brief: Optional[PlanningSemanticBrief] = None,
cur=None,
include_llm_roadmap: bool = False,
structured: Optional[RoadmapStructuredInput] = None,
) -> ProgressionRoadmapContext:
"""Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback."""
brief = semantic_brief or build_semantic_brief(goal_query)
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
llm_goal_query = _roadmap_llm_goal_block(
goal_query,
structured=structured,
parsed_start=parsed_start,
parsed_target=parsed_target,
)
ctx = ProgressionRoadmapContext(
goal_query=goal_query.strip(),
max_steps=max_steps,
semantic_brief=brief_to_summary_dict(brief),
)
goal_analysis = build_goal_analysis(goal_query, brief)
goal_analysis = build_goal_analysis(goal_query, brief, structured=structured)
if include_llm_roadmap and cur is not None:
llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=goal_query, brief=brief)
llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=llm_goal_query, brief=brief)
if ga_ok and llm_ga:
goal_analysis = llm_ga
goal_analysis = _merge_structured_into_goal_analysis(
llm_ga,
goal_query=goal_query,
brief=brief,
structured=structured,
)
ctx.llm_goal_analysis_applied = True
ctx.prompt_slugs.append(PROMPT_SLUG_GOAL_ANALYSIS)
ctx.goal_analysis = goal_analysis
@ -661,7 +857,7 @@ def run_progression_roadmap_pipeline(
if include_llm_roadmap and cur is not None:
llm_rm, rm_ok = try_llm_roadmap(
cur,
goal_query=goal_query,
goal_query=llm_goal_query,
brief=brief,
goal_analysis=goal_analysis,
max_steps=max_steps,
@ -689,7 +885,7 @@ def run_progression_roadmap_pipeline(
if include_llm_roadmap and cur is not None:
llm_specs, spec_ok = try_llm_stage_specs(
cur,
goal_query=goal_query,
goal_query=llm_goal_query,
goal_analysis=goal_analysis,
major_steps=roadmap.major_steps,
)
@ -734,7 +930,9 @@ __all__ = [
"ProgressionRoadmapContext",
"RoadmapArtifact",
"RoadmapOverridePayload",
"RoadmapStructuredInput",
"normalize_major_steps_for_override",
"parse_start_target_from_goal_query",
"roadmap_context_from_override",
"StageSpecArtifact",
"build_goal_analysis",

View File

@ -4,11 +4,13 @@ from planning_progression_roadmap import (
PROMPT_SLUG_ROADMAP,
PROMPT_SLUG_STAGE_SPEC,
MajorStep,
RoadmapStructuredInput,
StageSpecArtifact,
build_goal_analysis,
build_roadmap_unfilled_gap_specs,
consolidate_micro_to_major,
develop_micro_objectives,
parse_start_target_from_goal_query,
progression_roadmap_to_api_dict,
resolve_step_exercise_kind_filter,
run_progression_roadmap_pipeline,
@ -20,6 +22,11 @@ from planning_progression_roadmap import (
)
from planning_exercise_semantics import build_semantic_brief
KUMITE_GOAL = (
"Kumite Beinarbeit von einer gleichartigen Steppbewegung bis zur dynamischen "
"unvorhersehbaren Bewegung mit explosivartigem Angriff und ausweichen"
)
def test_run_progression_roadmap_pipeline_major_step_count():
ctx = run_progression_roadmap_pipeline(
@ -134,3 +141,54 @@ def test_api_dict_exposes_prompt_slug_catalog():
assert api["prompt_slug_catalog"]["roadmap"] == PROMPT_SLUG_ROADMAP
assert api["prompt_slug_catalog"]["stage_spec"] == PROMPT_SLUG_STAGE_SPEC
assert api["prompt_slugs"] == []
def test_parse_start_target_kumite_beinarbeit():
start, target = parse_start_target_from_goal_query(KUMITE_GOAL)
assert start is not None
assert "Steppbewegung" in start
assert target is not None
assert "dynamischen" in target
assert "Angriff" in target
def test_build_goal_analysis_uses_parsed_start_target():
brief = build_semantic_brief(KUMITE_GOAL)
ga = build_goal_analysis(KUMITE_GOAL, brief)
assert "Kumite Beinarbeit" in ga.primary_topic
assert "Steppbewegung" in ga.start_assumption
assert "dynamischen" in ga.target_state
assert "Voraussetzungen der Zielgruppe werden im Progressionsgraphen nicht analysiert" not in ga.start_assumption
def test_build_goal_analysis_structured_fields_override():
brief = build_semantic_brief("Kumite Beinarbeit")
structured = RoadmapStructuredInput(
start_situation="statische Vorwärtsbewegung im Partnerdrill",
target_state="explosiver Gegenangriff nach unvorhersehbarer Beinarbeit",
roadmap_notes="Kindergruppe 1012 Jahre",
)
ga = build_goal_analysis("Kumite Beinarbeit", brief, structured=structured)
assert ga.start_assumption == structured.start_situation
assert ga.target_state == structured.target_state
assert any("Kindergruppe" in c for c in ga.success_criteria)
def test_develop_micro_objectives_start_target_kumite():
brief = build_semantic_brief(KUMITE_GOAL)
ga = build_goal_analysis(KUMITE_GOAL, brief)
micro = develop_micro_objectives(brief, goal_analysis=ga, min_count=6)
titles = [m.title for m in micro]
assert any("Ausgang" in t for t in titles)
assert any("Ziel" in t for t in titles)
assert not any("Einstieg und Orientierung zum Thema" in t for t in titles)
def test_pipeline_kumite_major_steps_not_generic_templates():
ctx = run_progression_roadmap_pipeline(KUMITE_GOAL, max_steps=5, include_llm_roadmap=False)
goals = [s.learning_goal for s in ctx.roadmap.major_steps]
joined = " ".join(goals).lower()
assert "kumite beinarbeit" in joined
assert "steppbewegung" in joined or "ausgang" in joined
assert "dynamisch" in joined or "ziel" in joined
assert not any(g == "Grundstellung und Basisbewegung" for g in goals)

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.209"
APP_VERSION = "0.8.210"
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.20.1", # F3-Polish: roadmap_first QA lite (keine Brücken/Reorder)
"planning_exercise_suggest": "0.20.2", # Strukturierte Roadmap-Eingaben Start/Ziel + von-bis-Parsing
"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

@ -12,6 +12,31 @@ import {
} from '../utils/exerciseAiQuickCreate'
import { buildPathGapPlanningContextForAi } from '../utils/planningContextForExerciseAi'
/** „von … bis …“ aus Freitext (z. B. Kumite Beinarbeit von X bis Y). */
function parseStartTargetFromGoalQuery(q) {
const text = String(q || '').trim()
if (!text) return { start: '', target: '' }
const m = text.match(
/\bvon\s+((?:(?:der|die|dem|das|einer?|einem)\s+)?.+?)\s+bis\s+(?:zur?|zum|zu der|zu einem)?\s*(.+)$/is,
)
if (!m) return { start: '', target: '' }
const start = m[1].trim().replace(/[.,;]+$/, '')
const target = m[2].trim().replace(/[.,;]+$/, '')
if (start.length < 4 || target.length < 4) return { start: '', target: '' }
return { start, target }
}
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
const start = (startSituation || '').trim()
const target = (targetState || '').trim()
const notes = (roadmapNotes || '').trim()
const body = {}
if (start) body.start_situation = start
if (target) body.target_state = target
if (notes) body.roadmap_notes = notes
return body
}
function emptyPathStep() {
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] }
}
@ -154,6 +179,9 @@ export default function ExerciseProgressionPathBuilder({
onSaved,
}) {
const [goalQuery, setGoalQuery] = useState('')
const [startSituation, setStartSituation] = useState('')
const [targetState, setTargetState] = useState('')
const [roadmapNotes, setRoadmapNotes] = useState('')
const [maxSteps, setMaxSteps] = useState(5)
const [segmentNotes, setSegmentNotes] = useState('')
const [saving, setSaving] = useState(false)
@ -507,6 +535,19 @@ export default function ExerciseProgressionPathBuilder({
alert('Zuerst einen Graphen wählen.')
return
}
let start = startSituation.trim()
let target = targetState.trim()
if (!start || !target) {
const parsed = parseStartTargetFromGoalQuery(q)
if (parsed.start && !start) {
start = parsed.start
setStartSituation(parsed.start)
}
if (parsed.target && !target) {
target = parsed.target
setTargetState(parsed.target)
}
}
setLoadingRoadmap(true)
setError('')
try {
@ -522,6 +563,7 @@ export default function ExerciseProgressionPathBuilder({
include_llm_roadmap: true,
roadmap_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(start, target, roadmapNotes),
})
const majors = mapMajorStepsFromApi(res?.progression_roadmap)
if (majors.length < 2) {
@ -578,6 +620,7 @@ export default function ExerciseProgressionPathBuilder({
roadmap_first: true,
roadmap_override: override,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
})
applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length)
@ -680,6 +723,53 @@ export default function ExerciseProgressionPathBuilder({
disabled={disabled || loading || saving}
/>
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: '10px',
marginTop: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Startpunkt / Ausgangslage</label>
<textarea
className="form-input"
rows={2}
value={startSituation}
onChange={(e) => setStartSituation(e.target.value)}
placeholder="z. B. gleichartige Steppbewegung, vorhersehbar"
disabled={disabled || loading || saving}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Zielzustand</label>
<textarea
className="form-input"
rows={2}
value={targetState}
onChange={(e) => setTargetState(e.target.value)}
placeholder="z. B. dynamische, unvorhersehbare Bewegung mit explosivem Angriff und Ausweichen"
disabled={disabled || loading || saving}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Ergänzungen (Fokus, Gruppe, Besonderheiten)</label>
<textarea
className="form-input"
rows={2}
value={roadmapNotes}
onChange={(e) => setRoadmapNotes(e.target.value)}
placeholder="optional: Altersgruppe, Kumite-Kontext, Trainingsfokus …"
disabled={disabled || loading || saving}
/>
</div>
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.4 }}>
Bei von bis im Ziel werden Start und Ziel automatisch vorausgefüllt, wenn die Felder leer sind.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end', marginTop: '10px' }}>
<button
type="button"
className="btn btn-primary"
@ -717,6 +807,50 @@ export default function ExerciseProgressionPathBuilder({
</p>
) : null}
{progressionRoadmap?.goal_analysis ? (
<div
style={{
marginTop: '12px',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
<strong style={{ fontSize: '13px' }}>Zielanalyse</strong>
{progressionRoadmap.llm_goal_analysis_applied ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
KI-Zielanalyse
</span>
) : (
<span className="exercise-tag">heuristisch</span>
)}
{progressionRoadmap.goal_analysis.primary_topic ? (
<span className="exercise-tag">Thema: {progressionRoadmap.goal_analysis.primary_topic}</span>
) : null}
</div>
<div style={{ fontSize: '12px', color: 'var(--text2)', lineHeight: 1.5 }}>
<div>
<span style={{ color: 'var(--text3)' }}>Ausgang: </span>
{progressionRoadmap.goal_analysis.start_assumption}
</div>
<div style={{ marginTop: '6px' }}>
<span style={{ color: 'var(--text3)' }}>Ziel: </span>
{progressionRoadmap.goal_analysis.target_state}
</div>
{Array.isArray(progressionRoadmap.goal_analysis.success_criteria) &&
progressionRoadmap.goal_analysis.success_criteria.length > 0 ? (
<ul style={{ margin: '8px 0 0', paddingLeft: '18px' }}>
{progressionRoadmap.goal_analysis.success_criteria.slice(0, 4).map((c) => (
<li key={c}>{c}</li>
))}
</ul>
) : null}
</div>
</div>
) : null}
{(semanticBrief || targetSummary) && pathSteps.length > 0 ? (
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{semanticBrief?.primary_topic ? (