diff --git a/backend/migrations/087_ai_prompt_planning_progression_start_target.sql b/backend/migrations/087_ai_prompt_planning_progression_start_target.sql new file mode 100644 index 0000000..ead93b3 --- /dev/null +++ b/backend/migrations/087_ai_prompt_planning_progression_start_target.sql @@ -0,0 +1,52 @@ +-- Migration 087: Planungs-KI — LLM Start/Ziel-Extraktion aus Trainer-Anfrage (Alternative zu Regex) + +INSERT INTO ai_prompts ( + slug, display_name, description, template, + category, output_format, output_schema, is_system_default, default_template, active, sort_order +) +SELECT + 'planning_progression_start_target', + 'Progressions-Roadmap Start/Ziel-Extraktion', + 'Versteht die Trainer-Anfrage und formuliert dedizierte Ausgangslage, Zielzustand und Ergänzungen (ohne Gruppen-Tracking).', + $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen didaktischen Progressionsgraphen. + +Trainer-Anfrage (Ursprungstext): +{{goal_query}} + +Semantic Brief (heuristisch): {{semantic_brief_json}} + +Bereits vom Trainer eingegebene Ergänzungen (falls vorhanden): {{user_notes}} + +Aufgabe: +1. **primary_topic** — Kern-Thema/Technik in kurzer, präziser Bezeichnung (z. B. „Kumite Beinarbeit“, „Mae Geri“). +2. **start_situation** — Ausgangslage in eigenen Worten: Was kann der Athlet/die Gruppe *jetzt* (laut Anfrage oder sinnvoll ableitbar)? Konkret, beobachtbar, ohne Gruppenanalyse aus der Datenbank. +3. **target_state** — Zielzustand in eigenen Worten: Was soll am Ende der Progression erreicht sein? Konkret, didaktisch nutzbar. +4. **roadmap_notes** — Ergänzungen aus dem Ursprungstext: Fokus, Kontext (z. B. Kumite), besondere Anforderungen, Einschränkungen, die der Trainer erwähnt hat oder die für die Roadmap relevant sind. Nicht wiederholen, was bereits in start_situation/target_state steht. +5. **extraction_notes** — Kurz (1–2 Sätze): Was war explizit vs. abgeleitet? Wo war die Anfrage unklar? + +Regeln: +- Keine Gruppenanalyse — nur das, was aus dem Text hervorgeht oder didaktisch naheliegend formuliert ist. +- Formuliere start_situation und target_state **eigenständig und verständlich**, nicht nur Textfragmente kopieren. +- Bei „von … bis …“: Start und Ziel aus diesem Bogen schärfen und präzise beschreiben. +- Bei nur einem Thema ohne Bogen: start_situation und target_state didaktisch sinnvoll formulieren oder leer lassen, wenn nicht ableitbar — dann in extraction_notes erklären. +- Antworte NUR mit JSON. + +{ + "primary_topic": "…", + "start_situation": "…", + "target_state": "…", + "roadmap_notes": "…", + "extraction_notes": "…" +}$t$, + 'training', + 'json', + '{"type":"object","properties":{"primary_topic":{"type":"string"},"start_situation":{"type":"string"},"target_state":{"type":"string"},"roadmap_notes":{"type":"string"},"extraction_notes":{"type":"string"}}}'::jsonb, + true, + NULL, + true, + 13 +WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_start_target'); + +UPDATE ai_prompts SET default_template = template +WHERE slug = 'planning_progression_start_target' + AND (default_template IS NULL OR TRIM(default_template) = ''); diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index eb76e66..b77b0a7 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -76,6 +76,7 @@ class ProgressionPathSuggestRequest(BaseModel): include_ai_gap_fill: bool = True include_roadmap_preview: bool = False include_llm_roadmap: bool = True + include_llm_start_target: bool = True roadmap_first: bool = False roadmap_only: bool = False roadmap_override: Optional[RoadmapOverridePayload] = None @@ -544,6 +545,7 @@ def suggest_progression_path( semantic_brief=semantic_brief, cur=cur, include_llm_roadmap=body.include_llm_roadmap, + include_llm_start_target=body.include_llm_start_target, structured=roadmap_structured, ) progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx) diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py index a4463d6..75fe32c 100644 --- a/backend/planning_progression_roadmap.py +++ b/backend/planning_progression_roadmap.py @@ -32,6 +32,7 @@ from planning_exercise_semantics import ( _logger = logging.getLogger("shinkan.planning_progression_roadmap") # Nur Slugs — Templates in DB (ai_prompts), bearbeitbar im Admin. +PROMPT_SLUG_START_TARGET = "planning_progression_start_target" PROMPT_SLUG_GOAL_ANALYSIS = "planning_progression_goal_analysis" PROMPT_SLUG_ROADMAP = "planning_progression_roadmap" PROMPT_SLUG_STAGE_SPEC = "planning_progression_stage_spec" @@ -117,6 +118,26 @@ class RoadmapStructuredInput(BaseModel): roadmap_notes: Optional[str] = Field(default=None, max_length=2000) +class StartTargetExtractArtifact(BaseModel): + """LLM-Ergebnis: dedizierte Beschreibung von Ausgang, Ziel und Ergänzungen.""" + + primary_topic: str = "" + start_situation: str = "" + target_state: str = "" + roadmap_notes: str = "" + extraction_notes: str = "" + + +class StartTargetResolveMeta(BaseModel): + """Herkunft der aufgelösten Felder (user > llm > regex).""" + + start_source: str = "none" + target_source: str = "none" + notes_source: str = "none" + topic_source: str = "none" + llm_start_target_applied: bool = False + + class RoadmapOverridePayload(BaseModel): """Vom Trainer bearbeitete Major Steps — steuert Retrieval ohne erneute Roadmap-KI.""" @@ -131,10 +152,14 @@ class ProgressionRoadmapContext(BaseModel): goal_query: str max_steps: int = Field(ge=2, le=10, default=5) semantic_brief: Optional[Dict[str, Any]] = None + resolved_structured: Optional[RoadmapStructuredInput] = None + start_target_extract: Optional[StartTargetExtractArtifact] = None + start_target_resolve: Optional[StartTargetResolveMeta] = None goal_analysis: Optional[GoalAnalysisArtifact] = None roadmap: Optional[RoadmapArtifact] = None stage_specs: List[StageSpecArtifact] = Field(default_factory=list) pipeline_phase: str = "roadmap_v1" + llm_start_target_applied: bool = False llm_goal_analysis_applied: bool = False llm_roadmap_applied: bool = False llm_stage_spec_applied: bool = False @@ -177,6 +202,31 @@ def _run_prompt_json( return None +def try_llm_start_target_extract( + cur, + *, + goal_query: str, + brief: PlanningSemanticBrief, + user_notes: str = "", +) -> Tuple[Optional[StartTargetExtractArtifact], bool]: + obj = _run_prompt_json( + cur, + PROMPT_SLUG_START_TARGET, + { + "goal_query": goal_query or "", + "semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False), + "user_notes": (user_notes or "").strip(), + }, + ) + if not obj: + return None, False + try: + return StartTargetExtractArtifact.model_validate(obj), True + except ValidationError as exc: + _logger.warning("Start/Ziel-Extraktion JSON ungültig: %s", exc) + return None, False + + def try_llm_goal_analysis( cur, *, @@ -297,6 +347,100 @@ def _extract_topic_from_goal_query(goal_query: str, brief: PlanningSemanticBrief return topic or "Technik" +def _merge_roadmap_notes(*parts: Optional[str]) -> Optional[str]: + seen: set[str] = set() + lines: List[str] = [] + for raw in parts: + s = (raw or "").strip() + if not s: + continue + key = s.lower() + if key in seen: + continue + seen.add(key) + lines.append(s) + return "\n".join(lines) if lines else None + + +def resolve_roadmap_structured_input( + goal_query: str, + structured: Optional[RoadmapStructuredInput], + *, + brief: PlanningSemanticBrief, + cur=None, + include_llm: bool = False, +) -> Tuple[RoadmapStructuredInput, StartTargetResolveMeta, Optional[StartTargetExtractArtifact]]: + """Priorität je Feld: Trainer-Eingabe > LLM-Extraktion > Regex (von … bis …).""" + user = structured or RoadmapStructuredInput() + user_start = (user.start_situation or "").strip() + user_target = (user.target_state or "").strip() + user_notes = (user.roadmap_notes or "").strip() + + llm_extract: Optional[StartTargetExtractArtifact] = None + llm_ok = False + if include_llm and cur is not None: + llm_extract, llm_ok = try_llm_start_target_extract( + cur, + goal_query=goal_query, + brief=brief, + user_notes=user_notes, + ) + + parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query) + + meta = StartTargetResolveMeta(llm_start_target_applied=llm_ok) + + if user_start: + start = user_start + meta.start_source = "user" + elif llm_ok and (llm_extract.start_situation or "").strip(): + start = llm_extract.start_situation.strip() + meta.start_source = "llm" + elif parsed_start: + start = parsed_start + meta.start_source = "regex" + else: + start = "" + + if user_target: + target = user_target + meta.target_source = "user" + elif llm_ok and (llm_extract.target_state or "").strip(): + target = llm_extract.target_state.strip() + meta.target_source = "llm" + elif parsed_target: + target = parsed_target + meta.target_source = "regex" + else: + target = "" + + llm_notes = (llm_extract.roadmap_notes or "").strip() if llm_ok and llm_extract else "" + if user_notes and llm_notes: + notes = _merge_roadmap_notes(user_notes, llm_notes) or "" + meta.notes_source = "merged" + elif user_notes: + notes = user_notes + meta.notes_source = "user" + elif llm_notes: + notes = llm_notes + meta.notes_source = "llm" + else: + notes = "" + meta.notes_source = "none" + + if llm_ok and (llm_extract.primary_topic or "").strip(): + meta.topic_source = "llm" + else: + meta.topic_source = "heuristic" + + resolved = RoadmapStructuredInput( + start_situation=start or None, + target_state=target or None, + roadmap_notes=notes or None, + ) + return resolved, meta, llm_extract if llm_ok else None + + 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() @@ -343,9 +487,10 @@ def build_goal_analysis( brief: PlanningSemanticBrief, *, structured: Optional[RoadmapStructuredInput] = None, + topic_override: Optional[str] = None, ) -> GoalAnalysisArtifact: """Phase A — aus Anfrage, optionalen Feldern und Semantic Brief.""" - topic = _extract_topic_from_goal_query(goal_query, brief) + topic = (topic_override or "").strip() or _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 @@ -797,7 +942,7 @@ def _merge_structured_into_goal_analysis( brief: PlanningSemanticBrief, structured: Optional[RoadmapStructuredInput], ) -> GoalAnalysisArtifact: - ga_struct = build_goal_analysis(goal_query, brief, structured=structured) + ga_struct = build_goal_analysis(goal_query, brief, structured=structured, topic_override=None) if not _has_specific_start_target(ga_struct): return llm_ga merged_criteria = list( @@ -822,14 +967,22 @@ def run_progression_roadmap_pipeline( semantic_brief: Optional[PlanningSemanticBrief] = None, cur=None, include_llm_roadmap: bool = False, + include_llm_start_target: 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) + resolved, resolve_meta, llm_extract = resolve_roadmap_structured_input( + goal_query, + structured, + brief=brief, + cur=cur, + include_llm=include_llm_start_target, + ) parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query) llm_goal_query = _roadmap_llm_goal_block( goal_query, - structured=structured, + structured=resolved, parsed_start=parsed_start, parsed_target=parsed_target, ) @@ -837,9 +990,24 @@ def run_progression_roadmap_pipeline( goal_query=goal_query.strip(), max_steps=max_steps, semantic_brief=brief_to_summary_dict(brief), + resolved_structured=resolved, + start_target_extract=llm_extract, + start_target_resolve=resolve_meta, + llm_start_target_applied=resolve_meta.llm_start_target_applied, ) + if resolve_meta.llm_start_target_applied: + ctx.prompt_slugs.append(PROMPT_SLUG_START_TARGET) - goal_analysis = build_goal_analysis(goal_query, brief, structured=structured) + 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, + ) if include_llm_roadmap and cur is not None: llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=llm_goal_query, brief=brief) if ga_ok and llm_ga: @@ -847,7 +1015,7 @@ def run_progression_roadmap_pipeline( llm_ga, goal_query=goal_query, brief=brief, - structured=structured, + structured=resolved, ) ctx.llm_goal_analysis_applied = True ctx.prompt_slugs.append(PROMPT_SLUG_GOAL_ANALYSIS) @@ -901,18 +1069,37 @@ def run_progression_roadmap_pipeline( def progression_roadmap_to_api_dict(ctx: ProgressionRoadmapContext) -> Dict[str, Any]: + resolve = ctx.start_target_resolve return { "goal_analysis": ctx.goal_analysis.model_dump() if ctx.goal_analysis else None, + "resolved_structured": ( + ctx.resolved_structured.model_dump() if ctx.resolved_structured else None + ), + "start_target_extract": ( + ctx.start_target_extract.model_dump() if ctx.start_target_extract else None + ), + "start_target_sources": ( + { + "start": resolve.start_source, + "target": resolve.target_source, + "notes": resolve.notes_source, + "topic": resolve.topic_source, + } + if resolve + else None + ), "roadmap": ctx.roadmap.model_dump() if ctx.roadmap else None, "stage_specs": [s.model_dump() for s in ctx.stage_specs], "pipeline_phase": ctx.pipeline_phase, "major_step_count": len(ctx.roadmap.major_steps) if ctx.roadmap else 0, "micro_objective_count": len(ctx.roadmap.micro_objectives) if ctx.roadmap else 0, + "llm_start_target_applied": ctx.llm_start_target_applied, "llm_goal_analysis_applied": ctx.llm_goal_analysis_applied, "llm_roadmap_applied": ctx.llm_roadmap_applied, "llm_stage_spec_applied": ctx.llm_stage_spec_applied, "prompt_slugs": list(ctx.prompt_slugs), "prompt_slug_catalog": { + "start_target": PROMPT_SLUG_START_TARGET, "goal_analysis": PROMPT_SLUG_GOAL_ANALYSIS, "roadmap": PROMPT_SLUG_ROADMAP, "stage_spec": PROMPT_SLUG_STAGE_SPEC, @@ -921,6 +1108,7 @@ def progression_roadmap_to_api_dict(ctx: ProgressionRoadmapContext) -> Dict[str, __all__ = [ + "PROMPT_SLUG_START_TARGET", "PROMPT_SLUG_GOAL_ANALYSIS", "PROMPT_SLUG_ROADMAP", "PROMPT_SLUG_STAGE_SPEC", @@ -931,8 +1119,11 @@ __all__ = [ "RoadmapArtifact", "RoadmapOverridePayload", "RoadmapStructuredInput", + "StartTargetExtractArtifact", + "StartTargetResolveMeta", "normalize_major_steps_for_override", "parse_start_target_from_goal_query", + "resolve_roadmap_structured_input", "roadmap_context_from_override", "StageSpecArtifact", "build_goal_analysis", @@ -945,6 +1136,7 @@ __all__ = [ "develop_micro_objectives", "progression_roadmap_to_api_dict", "run_progression_roadmap_pipeline", + "try_llm_start_target_extract", "try_llm_goal_analysis", "try_llm_roadmap", "try_llm_stage_specs", diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index cd0e6a2..e4debbb 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -72,6 +72,7 @@ def post_progression_path_suggest( or body.include_llm_path_qa or body.include_ai_gap_fill or body.include_llm_roadmap + or body.include_llm_start_target ) club_id = resolve_club_id_for_probe(tenant) if uses_ai else None if uses_ai: diff --git a/backend/tests/test_planning_progression_roadmap.py b/backend/tests/test_planning_progression_roadmap.py index a6cb3de..4941ab7 100644 --- a/backend/tests/test_planning_progression_roadmap.py +++ b/backend/tests/test_planning_progression_roadmap.py @@ -3,6 +3,7 @@ from planning_progression_roadmap import ( PROMPT_SLUG_GOAL_ANALYSIS, PROMPT_SLUG_ROADMAP, PROMPT_SLUG_STAGE_SPEC, + PROMPT_SLUG_START_TARGET, MajorStep, RoadmapStructuredInput, StageSpecArtifact, @@ -12,6 +13,7 @@ from planning_progression_roadmap import ( develop_micro_objectives, parse_start_target_from_goal_query, progression_roadmap_to_api_dict, + resolve_roadmap_structured_input, resolve_step_exercise_kind_filter, run_progression_roadmap_pipeline, stage_spec_exercise_kind_filter, @@ -137,12 +139,53 @@ def test_roadmap_context_from_override(): 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) + assert api["prompt_slug_catalog"]["start_target"] == PROMPT_SLUG_START_TARGET assert api["prompt_slug_catalog"]["goal_analysis"] == PROMPT_SLUG_GOAL_ANALYSIS 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_resolve_structured_user_overrides_regex(): + brief = build_semantic_brief(KUMITE_GOAL) + structured = RoadmapStructuredInput( + start_situation="Trainer-Start explizit", + target_state="Trainer-Ziel explizit", + ) + resolved, meta, llm_raw = resolve_roadmap_structured_input( + KUMITE_GOAL, structured, brief=brief, include_llm=False + ) + assert llm_raw is None + assert resolved.start_situation == "Trainer-Start explizit" + assert resolved.target_state == "Trainer-Ziel explizit" + assert meta.start_source == "user" + assert meta.target_source == "user" + + +def test_resolve_structured_regex_fallback_without_llm(): + brief = build_semantic_brief(KUMITE_GOAL) + resolved, meta, _ = resolve_roadmap_structured_input( + KUMITE_GOAL, None, brief=brief, include_llm=False + ) + assert meta.start_source == "regex" + assert meta.target_source == "regex" + assert "Steppbewegung" in (resolved.start_situation or "") + assert "dynamischen" in (resolved.target_state or "") + + +def test_resolve_structured_merges_user_and_llm_notes(): + brief = build_semantic_brief("Kumite Beinarbeit") + structured = RoadmapStructuredInput(roadmap_notes="Kindergruppe 10–12") + resolved, meta, _ = resolve_roadmap_structured_input( + "Kumite Beinarbeit", + structured, + brief=brief, + include_llm=False, + ) + assert resolved.roadmap_notes == "Kindergruppe 10–12" + assert meta.notes_source == "user" + + def test_parse_start_target_kumite_beinarbeit(): start, target = parse_start_target_from_goal_query(KUMITE_GOAL) assert start is not None diff --git a/backend/version.py b/backend/version.py index 1b65436..928a5de 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.210" +APP_VERSION = "0.8.211" BUILD_DATE = "2026-06-07" -DB_SCHEMA_VERSION = "20260606086" +DB_SCHEMA_VERSION = "20260607087" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -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.2", # Strukturierte Roadmap-Eingaben Start/Ziel + von-bis-Parsing + "planning_exercise_suggest": "0.21.0", # LLM Start/Ziel-Extraktion (planning_progression_start_target) "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 diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index 38d4103..b24c67d 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -12,18 +12,24 @@ 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 applyResolvedStructuredFromRoadmap(progressionRoadmap, setters) { + const rs = progressionRoadmap?.resolved_structured + if (!rs) return + if (rs.start_situation) setters.setStartSituation(String(rs.start_situation)) + if (rs.target_state) setters.setTargetState(String(rs.target_state)) + if (rs.roadmap_notes) setters.setRoadmapNotes(String(rs.roadmap_notes)) +} + +function sourceLabel(source) { + const map = { + user: 'manuell', + llm: 'KI-Extraktion', + regex: 'Muster (von … bis …)', + merged: 'manuell + KI', + heuristic: 'heuristisch', + none: '—', + } + return map[source] || source || '—' } function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { @@ -535,19 +541,6 @@ 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 { @@ -561,9 +554,10 @@ export default function ExerciseProgressionPathBuilder({ include_ai_gap_fill: false, include_roadmap_preview: true, include_llm_roadmap: true, + include_llm_start_target: true, roadmap_only: true, progression_graph_id: Number(graphId), - ...roadmapStructuredPayload(start, target, roadmapNotes), + ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), }) const majors = mapMajorStepsFromApi(res?.progression_roadmap) if (majors.length < 2) { @@ -571,7 +565,13 @@ export default function ExerciseProgressionPathBuilder({ } setEditableMajorSteps(majors) setMaxSteps(majors.length) - setProgressionRoadmap(res?.progression_roadmap || null) + const roadmap = res?.progression_roadmap || null + setProgressionRoadmap(roadmap) + applyResolvedStructuredFromRoadmap(roadmap, { + setStartSituation, + setTargetState, + setRoadmapNotes, + }) setSemanticBrief(res?.semantic_brief_summary || null) setPathSteps([]) setTargetSummary(null) @@ -767,7 +767,8 @@ export default function ExerciseProgressionPathBuilder({

- Bei „von … bis …“ im Ziel werden Start und Ziel automatisch vorausgefüllt, wenn die Felder leer sind. + Leer gelassen: Start/Ziel werden per KI aus dem Zieltext verstanden und formuliert (Fallback: Muster + „von … bis …“). Manuelle Eingaben haben Vorrang.

) : null}