Enhance Gap Fill Goal Text and Skill Expectations Integration
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m22s

- 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.
This commit is contained in:
Lars 2026-06-10 07:09:46 +02:00
parent 0adf20c9e1
commit 1e7941f57b
7 changed files with 593 additions and 5 deletions

View File

@ -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"):

View File

@ -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),
}

View File

@ -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",
]

View File

@ -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(

View File

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

View File

@ -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"

View File

@ -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
}