From f2650dac57d17b9d4ea70c3697674fa0003eb642 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 9 Jun 2026 16:22:16 +0200 Subject: [PATCH] Enhance Planning Context with Progression Gap Snapshot and Start/Target Analysis - 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. --- backend/planning_exercise_form_context.py | 81 +++++++++++++- backend/planning_exercise_path_ai_fill.py | 57 ++++++++-- backend/planning_exercise_path_builder.py | 100 ++++++++++++++++- backend/planning_progression_roadmap.py | 55 ++++++++++ backend/routers/planning_exercise_suggest.py | 1 + .../test_planning_exercise_form_context.py | 34 ++++++ .../test_planning_exercise_path_ai_fill.py | 22 ++++ .../test_planning_progression_roadmap.py | 10 ++ backend/version.py | 4 +- .../ExerciseProgressionPathBuilder.jsx | 103 ++++++++++++++++-- .../src/utils/planningContextForExerciseAi.js | 63 ++++++++++- 11 files changed, 505 insertions(+), 25 deletions(-) diff --git a/backend/planning_exercise_form_context.py b/backend/planning_exercise_form_context.py index 7e6bac0..3394261 100644 --- a/backend/planning_exercise_form_context.py +++ b/backend/planning_exercise_form_context.py @@ -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", diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py index 6034203..f1cec92 100644 --- a/backend/planning_exercise_path_ai_fill.py +++ b/backend/planning_exercise_path_ai_fill.py @@ -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) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index b77b0a7..efef5cf 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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: diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py index 75fe32c..e1e0a6c 100644 --- a/backend/planning_progression_roadmap.py +++ b/backend/planning_progression_roadmap.py @@ -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", diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index e4debbb..beb0934 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -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: diff --git a/backend/tests/test_planning_exercise_form_context.py b/backend/tests/test_planning_exercise_form_context.py index 1cb6ebd..772e2ff 100644 --- a/backend/tests/test_planning_exercise_form_context.py +++ b/backend/tests/test_planning_exercise_form_context.py @@ -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" diff --git a/backend/tests/test_planning_exercise_path_ai_fill.py b/backend/tests/test_planning_exercise_path_ai_fill.py index 8f7d1ca..e6b6bbd 100644 --- a/backend/tests/test_planning_exercise_path_ai_fill.py +++ b/backend/tests/test_planning_exercise_path_ai_fill.py @@ -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 diff --git a/backend/tests/test_planning_progression_roadmap.py b/backend/tests/test_planning_progression_roadmap.py index 4941ab7..d8f2965 100644 --- a/backend/tests/test_planning_progression_roadmap.py +++ b/backend/tests/test_planning_progression_roadmap.py @@ -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 10–12") diff --git a/backend/version.py b/backend/version.py index 928a5de..b378672 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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 diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index b24c67d..d3f63ea 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -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({

- 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.

+ + {startTargetAnalyzed && !editableMajorSteps.length ? ( + + Start/Ziel bereit — Roadmap als Nächstes + + ) : null}