shinkan-jinkendo/backend/tests/test_planning_exercise_path_ai_fill.py
Lars df93da9a03
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 39s
Test Suite / playwright-tests (push) Successful in 1m22s
Enhance Gap Fill and Rematch Logic in Progression Path
- Introduced `_step_neighbors_at_index` to safely retrieve neighboring steps without causing IndexErrors, improving robustness in gap fill specifications.
- Updated `collect_gap_fill_specs` to utilize the new neighbor retrieval function, ensuring safe access to adjacent steps during gap fill processing.
- Enhanced rematch logic in `_run_roadmap_rematch_loop` to incorporate `max_rematch_rounds`, allowing for controlled iterations during roadmap rematching.
- Improved handling of unfilled roadmap slots in `collect_rematch_slot_indices`, ensuring accurate identification of gaps in the progression path.
- Added tests to validate the new gap fill handling and rematch logic, ensuring reliability in path suggestion features.
2026-06-11 21:20:47 +02:00

269 lines
9.1 KiB
Python

"""Tests Planungs-KI Phase E3 — Lücken-Angebote und Off-Topic."""
from planning_exercise_path_ai_fill import (
build_gap_fill_goal_text,
build_gap_fill_offer,
collect_gap_fill_specs,
)
from planning_exercise_path_qa import parse_llm_suggested_new_exercises, strip_off_topic_steps_from_path
from planning_exercise_semantics import build_semantic_brief
def test_parse_llm_suggested_new_exercises():
brief = build_semantic_brief("Mae Geri Perfektion")
llm_qa = {
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraft am Sandsack",
"sketch": "Kraft und Schnelligkeit",
"phase": "vertiefung",
"insert_after_step_index": 1,
"rationale": "Zwischenschritt",
}
]
}
specs = parse_llm_suggested_new_exercises(llm_qa, brief=brief, step_count=5)
assert len(specs) == 1
assert specs[0]["insert_after_index"] == 1
assert "Mae Geri" in specs[0]["title_hint"]
def test_collect_gap_fill_specs_off_topic_and_unfilled():
brief = build_semantic_brief("Mae Geri Perfektion")
steps = [
{"exercise_id": 1, "title": "Mae Geri Kihon"},
{"exercise_id": 2, "title": "Präzision"},
{"exercise_id": 3, "title": "One Leg Squat"},
{"exercise_id": 4, "title": "Gleichgewichtstritt"},
]
unfilled = [
{
"from_exercise_id": 2,
"to_exercise_id": 3,
"expected_phase": "vertiefung",
"from_title": "Präzision",
"to_title": "One Leg Squat",
}
]
off_topic = [
{
"step_index": 2,
"exercise_id": 3,
"title": "One Leg Squat",
"expected_phase": "vertiefung",
}
]
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=unfilled,
off_topic_steps=off_topic,
llm_specs=[],
brief=brief,
goal_query="Mae Geri Perfektion",
)
sources = {s["source"] for s in specs}
assert "unfilled_gap" in sources
assert "off_topic" in sources
off = next(s for s in specs if s["source"] == "off_topic")
assert off["replace_step_index"] == 2
assert off["insert_after_index"] == 1
def test_strip_off_topic_steps_from_path():
steps = [
{"exercise_id": 1, "title": "A"},
{"exercise_id": 2, "title": "B"},
{"exercise_id": 3, "title": "One Leg Squat"},
{"exercise_id": 4, "title": "D"},
]
off_topic = [{"step_index": 2, "title": "One Leg Squat", "exercise_id": 3}]
out, removed = strip_off_topic_steps_from_path(steps, off_topic)
assert len(out) == 3
assert len(removed) == 1
assert removed[0]["removed_title"] == "One Leg Squat"
assert [s["exercise_id"] for s in out] == [1, 2, 4]
def test_build_gap_fill_goal_text_includes_topic():
brief = build_semantic_brief("Mae Geri Perfektion")
text = build_gap_fill_goal_text(
goal_query="Mae Geri Perfektion",
brief=brief,
spec={"phase": "anwendung", "rationale": "Fehlt Kombinationstraining"},
step_a={"title": "Kihon"},
step_b={"title": "Kumite"},
)
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
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_roadmap_unfilled_uses_major_step_neighbors():
"""Leere Stufe 2 zwischen Stufe 1 und 3 — Nachbarn per roadmap_major_step_index."""
brief = build_semantic_brief("Kumite Beinarbeit")
steps = [
{
"title": "Explosive Angriffe",
"exercise_id": 10,
"roadmap_major_step_index": 0,
},
{
"title": "Kumite-Anwendung",
"exercise_id": 30,
"roadmap_major_step_index": 2,
},
]
offer = build_gap_fill_offer(
spec={
"source": "roadmap_unfilled",
"roadmap_major_step_index": 1,
"phase": "grundlage",
"title_hint": "Grundlegende Kumite-Steppbewegungen",
"gap": {"learning_goal": "Grundlegende Kumite-Steppbewegungen", "expected_phase": "grundlage"},
},
steps=steps,
goal_query="Kumite Beinarbeit",
brief=brief,
)
assert offer["roadmap_major_step_index"] == 1
assert "Explosive Angriffe" in offer["from_title"]
assert "Kumite-Anwendung" in offer["to_title"]
assert "Stufen-Lernziel" in offer["goal_for_ai"] or "Roadmap-Stufe" in offer["goal_for_ai"]
def test_build_gap_fill_offer_includes_entry_state_from_prior_steps():
brief = build_semantic_brief("Kumite Beinarbeit")
steps = [
{
"roadmap_major_step_index": 0,
"title": "Schritt A",
"roadmap_target_state": "gleichmäßige Distanz",
"success_criteria": ["Partnerabstand stabil"],
},
{"roadmap_major_step_index": 2, "title": "Schritt C"},
]
offer = build_gap_fill_offer(
spec={
"source": "roadmap_unfilled",
"phase": "vertiefung",
"title_hint": "Rhythmen",
"roadmap_major_step_index": 1,
},
steps=steps,
goal_query="Kumite Beinarbeit",
brief=brief,
roadmap_snapshot={
"start_situation": "Steppbewegung",
"stage_learning_goal": "variable Rhythmen",
},
)
assert offer["context_preview"]["entry_state"] == "gleichmäßige Distanz"
assert "Eingangszustand" in offer["goal_for_ai"]
def test_build_gap_fill_offer_exposes_context_preview():
brief = build_semantic_brief("Kumite Beinarbeit")
offer = build_gap_fill_offer(
spec={"source": "roadmap_unfilled", "phase": "vertiefung", "title_hint": "Rhythmen"},
steps=[{"title": "A"}, {"title": "B"}],
goal_query="Kumite Beinarbeit",
brief=brief,
roadmap_snapshot={
"start_situation": "Steppbewegung",
"target_state": "explosiver Angriff",
"stage_learning_goal": "variable Rhythmen",
},
)
assert offer["context_preview"]["start_situation"] == "Steppbewegung"
assert "variable Rhythmen" in offer["goal_for_ai"]
def test_collect_gap_fill_specs_off_topic_last_step_no_crash():
"""Rand-Slot: off_topic am letzten Schritt darf keinen IndexError auslösen (500)."""
brief = build_semantic_brief("Mawashi Geri Kumite")
steps = [
{"exercise_id": 1, "title": "Stand", "roadmap_major_step_index": 0},
{"exercise_id": 2, "title": "Yoko Geri", "roadmap_major_step_index": 1},
]
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=[],
off_topic_steps=[
{
"step_index": 1,
"roadmap_major_step_index": 1,
"title": "Yoko Geri",
"expected_phase": "anwendung",
}
],
llm_specs=[],
brief=brief,
goal_query="Mawashi Geri Kumite",
)
assert len(specs) == 1
assert specs[0]["source"] == "off_topic"
assert "Stand" in specs[0]["sketch"]
def test_collect_gap_fill_specs_off_topic_first_step_uses_safe_neighbors():
brief = build_semantic_brief("Mawashi Geri")
steps = [
{"exercise_id": 1, "title": "Yoko Geri", "roadmap_major_step_index": 0},
{"exercise_id": 2, "title": "Mawashi", "roadmap_major_step_index": 1},
]
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=[],
off_topic_steps=[
{
"step_index": 0,
"roadmap_major_step_index": 0,
"title": "Yoko Geri",
}
],
llm_specs=[],
brief=brief,
goal_query="Mawashi Geri",
)
assert len(specs) == 1
assert "Mawashi" in specs[0]["sketch"]
assert "vorherigem Schritt" in specs[0]["sketch"]