From 1e7941f57baabcf398f50b372a2b69ff2a8c221d Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Jun 2026 07:09:46 +0200 Subject: [PATCH] Enhance Gap Fill Goal Text and Skill Expectations Integration - Updated `build_gap_fill_goal_text` to include expected skills in the generated text, improving clarity for users. - Enhanced `_roadmap_gap_snapshot_for_spec` to incorporate skill expectations from the progression stage, enriching the roadmap context. - Modified `_annotate_roadmap_step` to append skill expectations to the step reasons, providing additional insights. - Updated tests to verify the inclusion of expected skills in the gap fill goal text. - Incremented application version to 0.8.215 to reflect these changes. --- backend/planning_exercise_path_ai_fill.py | 11 + backend/planning_exercise_path_builder.py | 106 +++++- backend/planning_skill_expectations.py | 334 ++++++++++++++++++ .../test_planning_exercise_path_ai_fill.py | 17 + .../tests/test_planning_skill_expectations.py | 121 +++++++ backend/version.py | 2 +- .../src/utils/planningContextForExerciseAi.js | 7 + 7 files changed, 593 insertions(+), 5 deletions(-) create mode 100644 backend/planning_skill_expectations.py create mode 100644 backend/tests/test_planning_skill_expectations.py diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py index 25e28da..1018b59 100644 --- a/backend/planning_exercise_path_ai_fill.py +++ b/backend/planning_exercise_path_ai_fill.py @@ -314,6 +314,17 @@ def build_gap_fill_goal_text( "Fähigkeiten-/Fokus-Hinweise: " + "; ".join(str(x) for x in snap["skill_hints"][:4]) ) + expected = snap.get("expected_skills") or [] + if expected: + names = [ + str(s.get("skill_name") or "").strip() + for s in expected[:5] + if str(s.get("skill_name") or "").strip() + ] + if names: + parts.append( + "Erwartete Fähigkeiten (Scoring): " + ", ".join(names) + ) if spec.get("rationale"): parts.append(f"Qualitätsprüfung: {spec['rationale']}") if spec.get("sketch"): diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index efef5cf..2184805 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -51,6 +51,12 @@ from planning_exercise_suggest import ( resolve_planning_exercise_intent, ) from planning_exercise_form_context import build_progression_gap_snapshot +from planning_skill_expectations import ( + apply_expectations_to_target, + build_planning_skill_expectations, + expectation_input_from_progression_path, + expectation_input_from_progression_stage, +) from planning_progression_roadmap import ( MajorStep, ProgressionRoadmapContext, @@ -91,14 +97,17 @@ class ProgressionPathSuggestRequest(BaseModel): def _roadmap_gap_snapshot_for_spec( + cur, roadmap_ctx: Optional[ProgressionRoadmapContext], spec: Mapping[str, Any], *, + goal_query: str, semantic_brief: PlanningSemanticBrief, ) -> Dict[str, Any]: - """Roadmap-Kontext für KI-Lücken-Übung (Start, Ziel, Stufenspec).""" + """Roadmap-Kontext für KI-Lücken-Übung (Start, Ziel, Stufenspec, Fähigkeiten).""" major_idx = spec.get("roadmap_major_step_index") stage_spec_dict: Optional[Dict[str, Any]] = None + major_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): @@ -107,6 +116,7 @@ def _roadmap_gap_snapshot_for_spec( for m in roadmap_ctx.roadmap.major_steps: if m.index == int(major_idx): stage_spec_dict["phase"] = m.phase + major_dict = m.model_dump() break break ga = roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx and roadmap_ctx.goal_analysis else None @@ -120,12 +130,25 @@ def _roadmap_gap_snapshot_for_spec( if roadmap_ctx and roadmap_ctx.semantic_brief else brief_to_summary_dict(semantic_brief) ) - return build_progression_gap_snapshot( + snap = build_progression_gap_snapshot( goal_analysis=ga, resolved_structured=rs, stage_spec=stage_spec_dict, semantic_brief=brief_summary, ) + inp = expectation_input_from_progression_stage( + goal_query=goal_query, + goal_analysis=ga, + resolved_structured=rs, + stage_spec=stage_spec_dict, + semantic_brief_summary=brief_summary, + major_step=major_dict, + ) + exp = build_planning_skill_expectations(cur, inp, semantic_brief=semantic_brief) + if exp.items: + snap["expected_skills"] = exp.to_api_dict()["expected_skills"] + snap["skill_expectation_sources"] = exp.sources + return snap def _roadmap_structured_from_body(body: ProgressionPathSuggestRequest) -> Optional[RoadmapStructuredInput]: @@ -244,6 +267,7 @@ def _run_path_step_retrieval( path_intent: Optional[str] = None, step_query_override: Optional[str] = None, step_phase_override: Optional[str] = None, + step_target_profile_override: Optional[PlanningTargetProfile] = None, ) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]: step_query = step_query_override or step_retrieval_query( semantic_brief, goal_query, step_index, max_steps @@ -311,7 +335,11 @@ def _run_path_step_retrieval( "expectation_mode": "query_only" if step_index == 0 and not planned_ids else "planning_hybrid", } - if path_target_profile is not None: + if step_target_profile_override is not None: + target_profile = step_target_profile_override + intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search") + query_intent_summary = {} + elif path_target_profile is not None: target_profile = path_target_profile intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search") query_intent_summary = {} @@ -414,6 +442,7 @@ def _annotate_roadmap_step( *, stage_spec: StageSpecArtifact, major_step: Optional[MajorStep], + skill_expectations: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: reasons = list(step.get("reasons") or []) learning_goal = (stage_spec.learning_goal or "").strip() @@ -421,11 +450,23 @@ def _annotate_roadmap_step( roadmap_reason = f"Roadmap: {learning_goal[:120]}" if roadmap_reason not in reasons: reasons.insert(0, roadmap_reason) + if skill_expectations and skill_expectations.get("expected_skills"): + names = [ + str(s.get("skill_name") or "").strip() + for s in skill_expectations["expected_skills"][:3] + if str(s.get("skill_name") or "").strip() + ] + if names: + skill_reason = f"Fähigkeiten: {', '.join(names)}" + if skill_reason not in reasons: + reasons.append(skill_reason) step["reasons"] = reasons[:4] step["roadmap_major_step_index"] = stage_spec.major_step_index step["roadmap_phase"] = major_step.phase if major_step else None step["roadmap_learning_goal"] = learning_goal or None step["roadmap_match_source"] = "stage_spec" + if skill_expectations: + step["skill_expectations"] = skill_expectations return step @@ -463,8 +504,37 @@ def _build_steps_roadmap_first( anchor_variant_id: Optional[int] = None unfilled: List[Tuple[int, StageSpecArtifact]] = [] + ga_dump = ( + roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx.goal_analysis else None + ) + rs_dump = ( + roadmap_ctx.resolved_structured.model_dump() + if roadmap_ctx.resolved_structured + else None + ) + brief_summary = ( + roadmap_ctx.semantic_brief + if roadmap_ctx.semantic_brief + else brief_to_summary_dict(semantic_brief) + ) + for step_index, stage_spec in enumerate(stage_specs): major = major_by_index.get(stage_spec.major_step_index) + stage_spec_dict = stage_spec.model_dump() + if major: + stage_spec_dict["phase"] = major.phase + stage_inp = expectation_input_from_progression_stage( + goal_query=goal_query, + goal_analysis=ga_dump, + resolved_structured=rs_dump, + stage_spec=stage_spec_dict, + semantic_brief_summary=brief_summary, + major_step=major.model_dump() if major else None, + ) + stage_exp = build_planning_skill_expectations(cur, stage_inp, semantic_brief=semantic_brief) + step_target = apply_expectations_to_target(path_target_profile, stage_exp) + skill_exp_api = stage_exp.to_api_dict() if stage_exp.items else None + step_query = stage_spec_retrieval_query( semantic_brief=semantic_brief, goal_query=goal_query, @@ -490,6 +560,7 @@ def _build_steps_roadmap_first( path_intent=path_intent, step_query_override=step_query, step_phase_override=major.phase if major else None, + step_target_profile_override=step_target, ) hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief) @@ -511,6 +582,7 @@ def _build_steps_roadmap_first( path_intent=path_intent, step_query_override=goal_query, step_phase_override=major.phase if major else None, + step_target_profile_override=step_target, ) hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief) @@ -522,6 +594,7 @@ def _build_steps_roadmap_first( _hit_to_path_step(hit), stage_spec=stage_spec, major_step=major, + skill_expectations=skill_exp_api, ) steps.append(step) eid = int(step["exercise_id"]) @@ -652,6 +725,26 @@ def suggest_progression_path( semantic_brief=semantic_brief, include_llm_intent=body.include_llm_intent, ) + path_skill_expectations: Optional[Dict[str, Any]] = None + if roadmap_ctx and roadmap_ctx.goal_analysis: + path_inp = expectation_input_from_progression_path( + goal_query=goal_query, + goal_analysis=roadmap_ctx.goal_analysis.model_dump(), + resolved_structured=( + roadmap_ctx.resolved_structured.model_dump() + if roadmap_ctx.resolved_structured + else None + ), + semantic_brief_summary=( + roadmap_ctx.semantic_brief + if roadmap_ctx.semantic_brief + else brief_to_summary_dict(semantic_brief) + ), + ) + path_exp = build_planning_skill_expectations(cur, path_inp, semantic_brief=semantic_brief) + if path_exp.items: + path_target_profile = apply_expectations_to_target(path_target_profile, path_exp) + path_skill_expectations = path_exp.to_api_dict() roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = [] roadmap_gap_offers: List[Dict[str, Any]] = [] @@ -702,7 +795,11 @@ def suggest_progression_path( brief=semantic_brief, proposal=None, roadmap_snapshot=_roadmap_gap_snapshot_for_spec( - roadmap_ctx, spec, semantic_brief=semantic_brief + cur, + roadmap_ctx, + spec, + goal_query=goal_query, + semantic_brief=semantic_brief, ), ) ) @@ -927,6 +1024,7 @@ def suggest_progression_path( "roadmap_only": False, "roadmap_edited": roadmap_edited, "roadmap_unfilled_count": len(roadmap_unfilled), + "path_skill_expectations": path_skill_expectations, "retrieval_phase": "+".join(retrieval_parts), } diff --git a/backend/planning_skill_expectations.py b/backend/planning_skill_expectations.py new file mode 100644 index 0000000..e996349 --- /dev/null +++ b/backend/planning_skill_expectations.py @@ -0,0 +1,334 @@ +""" +Wiederverwendbare Fähigkeiten-Erwartungen für Planungs-KI. + +Domänen-Scopes (gleiches Input-/Output-Modell): +- ``progression_stage`` — ein Major Step / stage_spec im Progressionsgraphen +- ``progression_path`` — gesamter Pfad (Ziel + Start/Ziel) +- ``training_section`` — Abschnitt einer Trainingseinheit (Phase G, später) +- ``framework_slot`` — Rahmen-Session-Slot (Phase G, später) + +Konsumenten mergen ``skill_weights`` in ``PlanningTargetProfile`` (Retrieval) +oder liefern ``expected_skills`` an Übungs-KI (planning_context). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from planning_exercise_profiles import _merge_weight_maps, _normalize_weight_map +from planning_exercise_semantics import PlanningSemanticBrief, resolve_semantic_skill_weights +from planning_exercise_text_signals import _load_skills_for_text_match, _match_skills_in_text + +# Scope-Strings — stabil für API/UI und spätere Trainingsplanung +SCOPE_PROGRESSION_STAGE = "progression_stage" +SCOPE_PROGRESSION_PATH = "progression_path" +SCOPE_TRAINING_SECTION = "training_section" +SCOPE_FRAMEWORK_SLOT = "framework_slot" + +_LOAD_PROFILE_SKILL_TERMS: Dict[str, Tuple[str, ...]] = { + "koordination": ("Koordination",), + "präzision": ("Präzision",), + "praezision": ("Präzision",), + "kraft": ("Kraft", "Kime"), + "geschwindigkeit": ("Geschwindigkeit", "Schnelligkeit"), + "schnelligkeit": ("Schnelligkeit", "Geschwindigkeit"), + "timing": ("Timing", "Reaktion"), + "reaktion": ("Reaktion", "Timing"), + "distanz": ("Distanz",), + "raum": ("Raum", "Distanz"), + "gleichgewicht": ("Gleichgewicht",), + "kime": ("Kime",), + "ausdauer": ("Ausdauer",), + "beweglichkeit": ("Beweglichkeit",), +} + + +@dataclass +class PlanningSkillExpectationInput: + scope: str = SCOPE_PROGRESSION_STAGE + primary_topic: str = "" + goal_query: str = "" + start_situation: str = "" + target_state: str = "" + stage_learning_goal: str = "" + roadmap_notes: str = "" + load_profile: List[str] = field(default_factory=list) + phase: str = "" + skill_hints: List[str] = field(default_factory=list) + + +@dataclass +class PlanningSkillExpectationItem: + skill_id: int + skill_name: str + weight: float + source: str + + +@dataclass +class PlanningSkillExpectations: + scope: str + skill_weights: Dict[int, float] + items: List[PlanningSkillExpectationItem] + sources: List[str] + + def to_api_dict(self) -> Dict[str, Any]: + return { + "scope": self.scope, + "sources": list(self.sources), + "expected_skills": [ + { + "skill_id": it.skill_id, + "skill_name": it.skill_name, + "weight": round(it.weight, 4), + "source": it.source, + } + for it in self.items + ], + } + + +def _norm_load_key(s: str) -> str: + return (s or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue") + + +def _text_blob(inp: PlanningSkillExpectationInput) -> str: + parts = [ + inp.primary_topic, + inp.goal_query, + inp.start_situation, + inp.target_state, + inp.stage_learning_goal, + inp.roadmap_notes, + inp.phase, + " ".join(inp.load_profile or []), + " ".join(inp.skill_hints or []), + ] + return "\n".join(p for p in parts if (p or "").strip()).lower() + + +def _resolve_skills_by_name_terms( + cur, + terms: Sequence[str], + *, + weight: float = 0.9, + source: str, + weights: Dict[int, float], + items: Dict[int, PlanningSkillExpectationItem], +) -> bool: + found = False + for name in terms: + if not name: + continue + cur.execute( + """ + SELECT id, name FROM skills + WHERE (status IS NULL OR status = 'active') + AND LOWER(name) LIKE %s + ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END, + LENGTH(name) ASC + LIMIT 1 + """, + (f"%{name.lower()}%", name.lower(), f"{name.lower()}%"), + ) + row = cur.fetchone() + if not row: + continue + sid = int(row["id"]) + w = max(weights.get(sid, 0.0), weight) + weights[sid] = w + items[sid] = PlanningSkillExpectationItem( + skill_id=sid, + skill_name=str(row.get("name") or "").strip(), + weight=w, + source=source, + ) + found = True + return found + + +def _merge_weights_into( + weights: Dict[int, float], + items: Dict[int, PlanningSkillExpectationItem], + incoming: Dict[int, float], + *, + source: str, + skill_rows: Sequence[Tuple[int, str, int]], +) -> None: + name_by_id = {sid: name for sid, name, _ in skill_rows} + for sid, w in incoming.items(): + if w <= 0: + continue + merged = max(weights.get(sid, 0.0), float(w)) + weights[sid] = merged + items[sid] = PlanningSkillExpectationItem( + skill_id=sid, + skill_name=name_by_id.get(sid, f"Skill #{sid}"), + weight=merged, + source=source, + ) + + +def build_planning_skill_expectations( + cur, + inp: PlanningSkillExpectationInput, + *, + semantic_brief: Optional[PlanningSemanticBrief] = None, +) -> PlanningSkillExpectations: + """Deterministisch: Thema + Text + load_profile → skill_weights.""" + weights: Dict[int, float] = {} + items: Dict[int, PlanningSkillExpectationItem] = {} + sources: List[str] = [] + + skill_rows = _load_skills_for_text_match(cur) + + if semantic_brief is not None: + topic_weights = resolve_semantic_skill_weights(cur, semantic_brief) + if topic_weights: + sources.append("semantic_topic") + _merge_weights_into( + weights, items, topic_weights, source="semantic_topic", skill_rows=skill_rows + ) + + blob = _text_blob(inp) + if blob: + text_weights = _match_skills_in_text(blob, skill_rows) + if text_weights: + sources.append("text_match") + _merge_weights_into( + weights, items, text_weights, source="text_match", skill_rows=skill_rows + ) + + load_found = False + for raw in inp.load_profile or []: + key = _norm_load_key(str(raw)) + terms = _LOAD_PROFILE_SKILL_TERMS.get(key, (str(raw).strip(),) if key else ()) + if _resolve_skills_by_name_terms( + cur, terms, weight=0.88, source="load_profile", weights=weights, items=items + ): + load_found = True + if load_found and "load_profile" not in sources: + sources.append("load_profile") + + normalized = _normalize_weight_map(weights) + out_items = sorted( + [ + PlanningSkillExpectationItem( + skill_id=sid, + skill_name=items[sid].skill_name, + weight=normalized[sid], + source=items[sid].source, + ) + for sid in normalized + ], + key=lambda x: (-x.weight, x.skill_name.lower()), + )[:8] + + return PlanningSkillExpectations( + scope=inp.scope or SCOPE_PROGRESSION_STAGE, + skill_weights=normalized, + items=out_items, + sources=sources, + ) + + +def expectation_input_from_progression_stage( + *, + goal_query: str, + goal_analysis: Optional[Mapping[str, Any]] = None, + resolved_structured: Optional[Mapping[str, Any]] = None, + stage_spec: Optional[Mapping[str, Any]] = None, + semantic_brief_summary: Optional[Mapping[str, Any]] = None, + major_step: Optional[Mapping[str, Any]] = None, +) -> PlanningSkillExpectationInput: + """Roadmap-Stufe → PlanningSkillExpectationInput (Progressionsgraph).""" + ga = dict(goal_analysis or {}) + rs = dict(resolved_structured or {}) + spec = dict(stage_spec or {}) + brief = dict(semantic_brief_summary or {}) + major = dict(major_step or {}) + + skill_hints: List[str] = [] + for item in (brief.get("must_phrases") or [])[:4]: + s = str(item or "").strip() + if s: + skill_hints.append(s) + + return PlanningSkillExpectationInput( + scope=SCOPE_PROGRESSION_STAGE, + primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(), + goal_query=(goal_query or "").strip(), + start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(), + target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(), + stage_learning_goal=str( + spec.get("learning_goal") or major.get("learning_goal") or "" + ).strip(), + roadmap_notes=str(rs.get("roadmap_notes") or "").strip(), + load_profile=[str(x).strip() for x in (spec.get("load_profile") or []) if str(x).strip()], + phase=str(spec.get("phase") or major.get("phase") or "").strip(), + skill_hints=skill_hints, + ) + + +def expectation_input_from_progression_path( + *, + goal_query: str, + goal_analysis: Optional[Mapping[str, Any]] = None, + resolved_structured: Optional[Mapping[str, Any]] = None, + semantic_brief_summary: Optional[Mapping[str, Any]] = None, +) -> PlanningSkillExpectationInput: + """Gesamtpfad-Kontext (z. B. einmaliges Pfad-Profil).""" + ga = dict(goal_analysis or {}) + rs = dict(resolved_structured or {}) + brief = dict(semantic_brief_summary or {}) + skill_hints: List[str] = [] + for item in (brief.get("must_phrases") or [])[:4]: + s = str(item or "").strip() + if s: + skill_hints.append(s) + return PlanningSkillExpectationInput( + scope=SCOPE_PROGRESSION_PATH, + primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(), + goal_query=(goal_query or "").strip(), + start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(), + target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(), + roadmap_notes=str(rs.get("roadmap_notes") or "").strip(), + skill_hints=skill_hints, + ) + + +def apply_expectations_to_target(target, expectations: PlanningSkillExpectations): + """Merge Erwartungs-Skills in PlanningTargetProfile (Retrieval).""" + from planning_exercise_semantics import enrich_target_with_semantic_expectations + + if not expectations.skill_weights: + return target + return enrich_target_with_semantic_expectations( + target, skill_weights=dict(expectations.skill_weights) + ) + + +def merge_expectation_skill_weights( + base: Dict[int, float], + extra: Dict[int, float], + *, + extra_scale: float = 1.0, +) -> Dict[int, float]: + merged = _merge_weight_maps(base, extra, scale=extra_scale) + return _normalize_weight_map(merged) + + +__all__ = [ + "SCOPE_FRAMEWORK_SLOT", + "SCOPE_PROGRESSION_PATH", + "SCOPE_PROGRESSION_STAGE", + "SCOPE_TRAINING_SECTION", + "PlanningSkillExpectationInput", + "PlanningSkillExpectationItem", + "PlanningSkillExpectations", + "apply_expectations_to_target", + "build_planning_skill_expectations", + "expectation_input_from_progression_path", + "expectation_input_from_progression_stage", + "merge_expectation_skill_weights", +] diff --git a/backend/tests/test_planning_exercise_path_ai_fill.py b/backend/tests/test_planning_exercise_path_ai_fill.py index 54b317a..98cb393 100644 --- a/backend/tests/test_planning_exercise_path_ai_fill.py +++ b/backend/tests/test_planning_exercise_path_ai_fill.py @@ -119,6 +119,23 @@ def test_build_gap_fill_goal_text_includes_roadmap_snapshot(): assert "timing" in text +def test_build_gap_fill_goal_text_includes_expected_skills(): + brief = build_semantic_brief("Kumite Beinarbeit") + text = build_gap_fill_goal_text( + goal_query="Kumite Beinarbeit", + brief=brief, + spec={"phase": "vertiefung", "title_hint": "Rhythmen"}, + roadmap_snapshot={ + "expected_skills": [ + {"skill_name": "Timing", "weight": 0.9}, + {"skill_name": "Distanz", "weight": 0.8}, + ], + }, + ) + assert "Erwartete Fähigkeiten" in text + assert "Timing" in text + + def test_build_gap_fill_offer_exposes_context_preview(): brief = build_semantic_brief("Kumite Beinarbeit") offer = build_gap_fill_offer( diff --git a/backend/tests/test_planning_skill_expectations.py b/backend/tests/test_planning_skill_expectations.py new file mode 100644 index 0000000..f35be9a --- /dev/null +++ b/backend/tests/test_planning_skill_expectations.py @@ -0,0 +1,121 @@ +"""Tests wiederverwendbare Fähigkeiten-Erwartungen (Progressionsgraph + später Planung).""" +from unittest.mock import MagicMock + +from planning_skill_expectations import ( + SCOPE_PROGRESSION_PATH, + SCOPE_PROGRESSION_STAGE, + PlanningSkillExpectationInput, + apply_expectations_to_target, + build_planning_skill_expectations, + expectation_input_from_progression_path, + expectation_input_from_progression_stage, +) + + +class _FakeCursor: + def __init__(self, skills): + self._skills = skills + + def execute(self, query, params=None): + del query + if params and len(params) >= 2: + needle = str(params[0]).strip("%").lower() + exact = str(params[1]).lower() + matches = [ + s + for s in self._skills + if needle in s["name"].lower() or s["name"].lower() == exact + ] + self._row = matches[0] if matches else None + else: + self._row = None + + def fetchone(self): + return self._row + + +def _fake_skill_rows(): + return [ + (1, "Koordination", 0), + (2, "Timing", 0), + (3, "Distanz", 0), + (4, "Kime", 0), + ] + + +def test_expectation_input_from_progression_stage_merges_sources(monkeypatch): + monkeypatch.setattr( + "planning_skill_expectations._load_skills_for_text_match", + lambda cur: _fake_skill_rows(), + ) + inp = expectation_input_from_progression_stage( + goal_query="Kumite Beinarbeit", + goal_analysis={ + "primary_topic": "Kumite", + "start_assumption": "gleichförmige Steppbewegung", + "target_state": "explosiver Angriff", + }, + resolved_structured={"roadmap_notes": "Kindergruppe"}, + stage_spec={ + "learning_goal": "variable Rhythmen", + "load_profile": ["timing", "distanz"], + "phase": "vertiefung", + }, + semantic_brief_summary={"must_phrases": ["Beinarbeit"], "primary_topic": "Kumite"}, + major_step={"phase": "vertiefung", "learning_goal": "Major-Ziel"}, + ) + assert inp.scope == SCOPE_PROGRESSION_STAGE + assert inp.primary_topic == "Kumite" + assert inp.start_situation == "gleichförmige Steppbewegung" + assert inp.load_profile == ["timing", "distanz"] + assert "Beinarbeit" in inp.skill_hints + + +def test_build_planning_skill_expectations_load_profile_and_text(monkeypatch): + monkeypatch.setattr( + "planning_skill_expectations._load_skills_for_text_match", + lambda cur: _fake_skill_rows(), + ) + cur = _FakeCursor( + [ + {"id": 2, "name": "Timing"}, + {"id": 3, "name": "Distanz"}, + ] + ) + inp = PlanningSkillExpectationInput( + scope=SCOPE_PROGRESSION_STAGE, + primary_topic="Kumite", + goal_query="Kumite Beinarbeit mit Timing", + load_profile=["timing", "distanz"], + ) + exp = build_planning_skill_expectations(cur, inp) + assert exp.scope == SCOPE_PROGRESSION_STAGE + assert "load_profile" in exp.sources or "text_match" in exp.sources + names = {it.skill_name for it in exp.items} + assert "Timing" in names or "Distanz" in names + assert exp.skill_weights + + +def test_expectation_input_from_progression_path_scope(): + inp = expectation_input_from_progression_path( + goal_query="Mae Geri Perfektion", + goal_analysis={"primary_topic": "Mae Geri"}, + resolved_structured={"start_situation": "Grundstellung", "target_state": "freier Kick"}, + ) + assert inp.scope == SCOPE_PROGRESSION_PATH + assert inp.start_situation == "Grundstellung" + assert inp.target_state == "freier Kick" + + +def test_apply_expectations_to_target_noop_without_weights(): + class _Target: + skill_weights = {} + + def to_summary_dict(self, cur): + return {} + + target = _Target() + from planning_skill_expectations import PlanningSkillExpectations + + empty = PlanningSkillExpectations(scope=SCOPE_PROGRESSION_STAGE, skill_weights={}, items=[], sources=[]) + assert apply_expectations_to_target(target, empty) is target diff --git a/backend/version.py b/backend/version.py index e55663f..f266fd2 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.214" +APP_VERSION = "0.8.215" BUILD_DATE = "2026-06-07" DB_SCHEMA_VERSION = "20260607087" diff --git a/frontend/src/utils/planningContextForExerciseAi.js b/frontend/src/utils/planningContextForExerciseAi.js index 345859b..bc77f9e 100644 --- a/frontend/src/utils/planningContextForExerciseAi.js +++ b/frontend/src/utils/planningContextForExerciseAi.js @@ -162,6 +162,13 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) { if (Array.isArray(raw.skill_hints) && raw.skill_hints.length) { push('Fähigkeiten-Fokus', raw.skill_hints.slice(0, 3).join(' · ')) } + if (Array.isArray(raw.expected_skills) && raw.expected_skills.length) { + const names = raw.expected_skills + .map((s) => String(s?.skill_name || '').trim()) + .filter(Boolean) + .slice(0, 5) + if (names.length) push('Erwartete Fähigkeiten', names.join(' · ')) + } push('Trainer-Ergänzungen', raw.gap_trainer_supplements) return lines }