Enhance Planning Exercise Profiles and Context Handling
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
- Introduced new functions to generate skill profiles from exercise IDs, improving the ability to summarize skills for both units and sections. - Updated the planning target profile to incorporate section-specific exercise IDs, allowing for more granular skill tracking and context. - Enhanced the ExercisePickerModal and related pages to support section context, including titles, guidance notes, and exercise counts. - Implemented expectation mode handling in the planning target pipeline to differentiate between planning references and query-only scenarios. - Incremented version to 0.8.174 and updated changelog to reflect these enhancements in planning AI capabilities.
This commit is contained in:
parent
8e68261bc1
commit
04cc77d501
|
|
@ -293,11 +293,50 @@ def _profile_from_unit_occurrences(cur, unit_id: int) -> Dict[int, float]:
|
|||
return _skill_weights_from_profile(prof.get("skills") or [])
|
||||
|
||||
|
||||
def _profile_from_exercise_ids(cur, exercise_ids: Sequence[int]) -> Dict[int, float]:
|
||||
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||
if not ids:
|
||||
return {}
|
||||
occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids]
|
||||
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||
return _skill_weights_from_profile(prof.get("skills") or [])
|
||||
|
||||
|
||||
def skill_profile_summary_from_exercise_ids(
|
||||
cur,
|
||||
exercise_ids: Sequence[int],
|
||||
*,
|
||||
limit_skills: int = 8,
|
||||
) -> Dict[str, Any]:
|
||||
"""Kompaktes Fähigkeitenprofil für LLM-Kontext und UI."""
|
||||
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||
if not ids:
|
||||
return {"exercise_count": 0, "skills": []}
|
||||
occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids]
|
||||
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||
skills_out = prof.get("skills") or []
|
||||
top = sorted(skills_out, key=lambda s: -float(s.get("weight") or s.get("score") or 0))[:limit_skills]
|
||||
names = _load_skill_names(cur, [int(s["skill_id"]) for s in top if s.get("skill_id") is not None])
|
||||
return {
|
||||
"exercise_count": len(ids),
|
||||
"skills": [
|
||||
{
|
||||
"skill_id": int(s["skill_id"]),
|
||||
"name": names.get(int(s["skill_id"]), f"#{s['skill_id']}"),
|
||||
"weight": round(float(s.get("weight") or s.get("score") or 0), 3),
|
||||
}
|
||||
for s in top
|
||||
if s.get("skill_id") is not None
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_planning_target_profile(
|
||||
cur,
|
||||
*,
|
||||
unit: Dict[str, Any],
|
||||
planned_exercise_ids: Sequence[int],
|
||||
section_planned_exercise_ids: Optional[Sequence[int]] = None,
|
||||
anchor_exercise_id: Optional[int],
|
||||
intent: str,
|
||||
) -> PlanningTargetProfile:
|
||||
|
|
@ -356,6 +395,13 @@ def build_planning_target_profile(
|
|||
if skill_plan:
|
||||
sources.append("current_unit_plan")
|
||||
|
||||
section_ids = [int(x) for x in (section_planned_exercise_ids or []) if int(x) > 0]
|
||||
if section_ids:
|
||||
section_skills = _profile_from_exercise_ids(cur, section_ids)
|
||||
if section_skills:
|
||||
skill_target = _merge_weight_maps(skill_target, section_skills, scale=1.0)
|
||||
sources.append("current_section_plan")
|
||||
|
||||
if anchor_exercise_id:
|
||||
anchor_profiles = load_exercise_match_profiles_bulk(cur, [int(anchor_exercise_id)])
|
||||
ap = anchor_profiles.get(int(anchor_exercise_id))
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from fastapi import HTTPException
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
from tenant_context import TenantContext, library_content_visibility_sql
|
||||
from planning_exercise_profiles import skill_profile_summary_from_exercise_ids
|
||||
from planning_exercise_retrieval import run_multistage_planning_retrieval
|
||||
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
|
||||
from planning_exercise_target_pipeline import (
|
||||
|
|
@ -56,6 +57,9 @@ class PlanningExerciseSuggestRequest(BaseModel):
|
|||
query: Optional[str] = ""
|
||||
intent_hint: Optional[str] = None
|
||||
planned_exercise_ids: Optional[List[int]] = None
|
||||
section_title: Optional[str] = None
|
||||
section_guidance_notes: Optional[str] = None
|
||||
section_planned_exercise_ids: Optional[List[int]] = None
|
||||
include_llm_intent: bool = True
|
||||
include_llm_rank: bool = False
|
||||
limit: int = Field(default=20, ge=1, le=50)
|
||||
|
|
@ -241,6 +245,131 @@ def _load_group_recent_exercise_ids(
|
|||
return out
|
||||
|
||||
|
||||
def _section_for_context(
|
||||
sections: Sequence[Dict[str, Any]],
|
||||
section_order_index: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if section_order_index is None:
|
||||
return None
|
||||
target = int(section_order_index)
|
||||
for sec in sections:
|
||||
if int(sec.get("order_index") or -1) == target:
|
||||
return sec
|
||||
if 0 <= target < len(sections):
|
||||
return sections[target]
|
||||
return None
|
||||
|
||||
|
||||
def _collect_exercise_ids_from_section(sec: Optional[Dict[str, Any]]) -> List[int]:
|
||||
if not sec:
|
||||
return []
|
||||
out: List[int] = []
|
||||
seen: Set[int] = set()
|
||||
for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0)):
|
||||
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||
continue
|
||||
raw = it.get("exercise_id")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
out.append(eid)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_last_exercise_in_section(sec: Optional[Dict[str, Any]]) -> Tuple[Optional[int], Optional[str]]:
|
||||
if not sec:
|
||||
return None, None
|
||||
last_id: Optional[int] = None
|
||||
last_title: Optional[str] = None
|
||||
for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0)):
|
||||
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||
continue
|
||||
raw = it.get("exercise_id")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1:
|
||||
continue
|
||||
last_id = eid
|
||||
t = (it.get("exercise_title") or "").strip()
|
||||
last_title = t or None
|
||||
return last_id, last_title
|
||||
|
||||
|
||||
def _attach_planning_context_details(
|
||||
cur,
|
||||
pack: Dict[str, Any],
|
||||
*,
|
||||
sections: Optional[Sequence[Dict[str, Any]]] = None,
|
||||
body: Optional[PlanningExerciseSuggestRequest] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Abschnitt, Fähigkeitenprofile und letzte Übung anreichern."""
|
||||
sec: Optional[Dict[str, Any]] = None
|
||||
section_idx = pack.get("section_order_index")
|
||||
if sections is not None and section_idx is not None:
|
||||
sec = _section_for_context(sections, section_idx)
|
||||
|
||||
section_ids = _collect_exercise_ids_from_section(sec)
|
||||
if body and body.section_planned_exercise_ids:
|
||||
section_ids = []
|
||||
seen: Set[int] = set()
|
||||
for raw in body.section_planned_exercise_ids:
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
section_ids.append(eid)
|
||||
elif pack.get("section_planned_exercise_ids"):
|
||||
section_ids = list(pack.get("section_planned_exercise_ids") or [])
|
||||
|
||||
section_title = pack.get("section_title")
|
||||
if body and (body.section_title or "").strip():
|
||||
section_title = (body.section_title or "").strip()
|
||||
elif sec and (sec.get("title") or "").strip():
|
||||
section_title = (sec.get("title") or "").strip()
|
||||
|
||||
guidance = None
|
||||
if body and (body.section_guidance_notes or "").strip():
|
||||
guidance = (body.section_guidance_notes or "").strip()
|
||||
elif sec and (sec.get("guidance_notes") or "").strip():
|
||||
guidance = (sec.get("guidance_notes") or "").strip()
|
||||
|
||||
last_in_section_id, last_in_section_title = _resolve_last_exercise_in_section(sec)
|
||||
if body and not last_in_section_id and pack.get("anchor_exercise_id"):
|
||||
last_in_section_id = pack.get("anchor_exercise_id")
|
||||
last_in_section_title = pack.get("anchor_title")
|
||||
|
||||
unit_ids = list(pack.get("planned_exercise_ids") or [])
|
||||
pack["section_title"] = section_title
|
||||
pack["section_guidance_notes"] = guidance
|
||||
pack["section_planned_exercise_ids"] = section_ids
|
||||
pack["section_exercise_count"] = len(section_ids)
|
||||
pack["last_section_exercise_id"] = last_in_section_id
|
||||
pack["last_section_exercise_title"] = last_in_section_title
|
||||
pack["unit_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, unit_ids)
|
||||
pack["section_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, section_ids)
|
||||
pack["has_planning_reference"] = bool(
|
||||
unit_ids
|
||||
or section_ids
|
||||
or pack.get("anchor_exercise_id")
|
||||
or (pack.get("unit") or {}).get("framework_slot_id")
|
||||
or (pack.get("unit") or {}).get("origin_framework_slot_id")
|
||||
)
|
||||
return pack
|
||||
|
||||
|
||||
def _section_title_for_index(sections: Sequence[Dict[str, Any]], section_order_index: Optional[int]) -> Optional[str]:
|
||||
if section_order_index is None:
|
||||
return None
|
||||
|
|
@ -348,7 +477,7 @@ def build_planning_exercise_context_pack(
|
|||
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
||||
anchor_title = titles.get(anchor_id) if anchor_id else None
|
||||
|
||||
return {
|
||||
pack = {
|
||||
"unit_id": int(body.unit_id),
|
||||
"unit": {
|
||||
"id": int(body.unit_id),
|
||||
|
|
@ -369,6 +498,7 @@ def build_planning_exercise_context_pack(
|
|||
"progression_edge_notes": progression_notes,
|
||||
"group_recent_exercise_ids": sorted(group_recent),
|
||||
}
|
||||
return _attach_planning_context_details(cur, pack, sections=sections, body=body)
|
||||
|
||||
|
||||
def build_client_planning_context_pack(
|
||||
|
|
@ -413,7 +543,7 @@ def build_client_planning_context_pack(
|
|||
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
||||
anchor_title = titles.get(anchor_id) if anchor_id else None
|
||||
|
||||
return {
|
||||
pack = {
|
||||
"unit_id": None,
|
||||
"unit": {
|
||||
"id": None,
|
||||
|
|
@ -424,7 +554,7 @@ def build_client_planning_context_pack(
|
|||
"group_id": group_id,
|
||||
"group_name": group_name,
|
||||
"section_order_index": body.section_order_index,
|
||||
"section_title": None,
|
||||
"section_title": (body.section_title or "").strip() or None,
|
||||
"planned_exercise_ids": planned_ids,
|
||||
"anchor_exercise_id": anchor_id,
|
||||
"anchor_title": anchor_title,
|
||||
|
|
@ -435,6 +565,7 @@ def build_client_planning_context_pack(
|
|||
"group_recent_exercise_ids": sorted(group_recent),
|
||||
"context_mode": "client_free",
|
||||
}
|
||||
return _attach_planning_context_details(cur, pack, sections=None, body=body)
|
||||
|
||||
|
||||
def suggest_planning_exercises(
|
||||
|
|
@ -448,27 +579,40 @@ def suggest_planning_exercises(
|
|||
else:
|
||||
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
|
||||
pack = _apply_client_planned_override(cur, pack, body)
|
||||
pack = _attach_planning_context_details(cur, pack, body=body)
|
||||
query = _normalize_query(body.query)
|
||||
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
||||
|
||||
has_plan_ref = bool(pack.get("has_planning_reference"))
|
||||
expectation_mode = "planning_hybrid" if has_plan_ref else "query_only"
|
||||
|
||||
pipeline_context = {
|
||||
"unit_title": pack.get("unit_title"),
|
||||
"group_name": pack.get("group_name"),
|
||||
"section_title": pack.get("section_title"),
|
||||
"section_guidance_notes": pack.get("section_guidance_notes"),
|
||||
"section_exercise_count": pack.get("section_exercise_count"),
|
||||
"planned_count": len(pack.get("planned_exercise_ids") or []),
|
||||
"anchor_title": pack.get("anchor_title"),
|
||||
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
||||
"last_section_exercise_title": pack.get("last_section_exercise_title"),
|
||||
"progression_graph_id": pack.get("progression_graph_id"),
|
||||
"unit_skill_profile": pack.get("unit_skill_profile_summary"),
|
||||
"section_skill_profile": pack.get("section_skill_profile_summary"),
|
||||
"has_planning_reference": has_plan_ref,
|
||||
"expectation_mode": expectation_mode,
|
||||
}
|
||||
target_profile, intent, scenario_kind, query_intent_summary = build_planning_target_with_query_pipeline(
|
||||
cur,
|
||||
unit=pack["unit"],
|
||||
planned_exercise_ids=pack["planned_exercise_ids"],
|
||||
section_planned_exercise_ids=pack.get("section_planned_exercise_ids") or [],
|
||||
anchor_exercise_id=pack.get("anchor_exercise_id"),
|
||||
query=query,
|
||||
heuristic_intent=heuristic_intent,
|
||||
include_llm_intent=body.include_llm_intent,
|
||||
context_summary=pipeline_context,
|
||||
has_planning_reference=has_plan_ref,
|
||||
)
|
||||
weights = _intent_weights(intent)
|
||||
target_profile_summary = target_profile.to_summary_dict(cur)
|
||||
|
|
@ -549,11 +693,18 @@ def suggest_planning_exercises(
|
|||
"unit_title": pack.get("unit_title"),
|
||||
"group_name": pack.get("group_name"),
|
||||
"section_title": pack.get("section_title"),
|
||||
"section_guidance_notes": pack.get("section_guidance_notes"),
|
||||
"section_exercise_count": pack.get("section_exercise_count"),
|
||||
"planned_count": len(planned_set),
|
||||
"anchor_title": pack.get("anchor_title"),
|
||||
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
||||
"last_section_exercise_title": pack.get("last_section_exercise_title"),
|
||||
"progression_graph_id": pack.get("progression_graph_id"),
|
||||
"context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"),
|
||||
"unit_skill_profile": pack.get("unit_skill_profile_summary"),
|
||||
"section_skill_profile": pack.get("section_skill_profile_summary"),
|
||||
"has_planning_reference": pack.get("has_planning_reference"),
|
||||
"expectation_mode": expectation_mode,
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -568,5 +719,6 @@ def suggest_planning_exercises(
|
|||
"intent_resolved": intent,
|
||||
"intent_heuristic": heuristic_intent,
|
||||
"query_normalized": query or None,
|
||||
"expectation_mode": expectation_mode,
|
||||
"hits": hits,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,14 +224,19 @@ def build_planning_target_with_query_pipeline(
|
|||
*,
|
||||
unit: Dict[str, Any],
|
||||
planned_exercise_ids: List[int],
|
||||
section_planned_exercise_ids: Optional[List[int]] = None,
|
||||
anchor_exercise_id: Optional[int],
|
||||
query: Optional[str],
|
||||
heuristic_intent: str,
|
||||
include_llm_intent: bool,
|
||||
context_summary: Mapping[str, Any],
|
||||
has_planning_reference: bool = True,
|
||||
) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]:
|
||||
"""
|
||||
Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict
|
||||
|
||||
Ohne Planungsbezug (keine Übungen/Anker/Rahmen): Erwartungsprofil primär aus Suchtext (query_only).
|
||||
Mit Planungsbezug: hybrid aus Plan + optional Query-Overlay.
|
||||
"""
|
||||
scenario = classify_planning_scenario(query, heuristic_intent)
|
||||
resolved_intent = heuristic_intent
|
||||
|
|
@ -239,13 +244,18 @@ def build_planning_target_with_query_pipeline(
|
|||
parsed: Optional[PlanningQueryIntentParsed] = None
|
||||
resolved_skills: List[Dict[str, Any]] = []
|
||||
|
||||
base = build_planning_target_profile(
|
||||
cur,
|
||||
unit=unit,
|
||||
planned_exercise_ids=planned_exercise_ids,
|
||||
anchor_exercise_id=anchor_exercise_id,
|
||||
intent=heuristic_intent,
|
||||
)
|
||||
if has_planning_reference:
|
||||
base = build_planning_target_profile(
|
||||
cur,
|
||||
unit=unit,
|
||||
planned_exercise_ids=planned_exercise_ids,
|
||||
section_planned_exercise_ids=section_planned_exercise_ids or [],
|
||||
anchor_exercise_id=anchor_exercise_id,
|
||||
intent=heuristic_intent,
|
||||
)
|
||||
else:
|
||||
base = PlanningTargetProfile(sources=["query_only"])
|
||||
|
||||
base_summary = base.to_summary_dict(cur)
|
||||
|
||||
if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent):
|
||||
|
|
@ -273,6 +283,11 @@ def build_planning_target_with_query_pipeline(
|
|||
|
||||
focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
|
||||
if focus or style or tt or tg or skills:
|
||||
overlay_scenario = scenario
|
||||
overlay_emphasis = parsed.emphasis
|
||||
if not has_planning_reference:
|
||||
overlay_scenario = SCENARIO_FREE_SEARCH
|
||||
overlay_emphasis = "replace"
|
||||
target = merge_query_overlay_into_target(
|
||||
base,
|
||||
focus=focus,
|
||||
|
|
@ -280,9 +295,12 @@ def build_planning_target_with_query_pipeline(
|
|||
tt=tt,
|
||||
tg=tg,
|
||||
skills=skills,
|
||||
emphasis=parsed.emphasis,
|
||||
scenario=scenario,
|
||||
emphasis=overlay_emphasis,
|
||||
scenario=overlay_scenario,
|
||||
)
|
||||
elif not has_planning_reference and _normalize_query(query):
|
||||
# Kein LLM, aber Freitext: leichtes Profil bleibt leer — Retrieval nutzt Volltext
|
||||
target = PlanningTargetProfile(sources=["query_only"])
|
||||
|
||||
query_intent_summary: Dict[str, Any] = {
|
||||
"scenario": scenario,
|
||||
|
|
@ -293,6 +311,7 @@ def build_planning_target_with_query_pipeline(
|
|||
"rationale": (parsed.rationale if parsed else None),
|
||||
"skill_hints_resolved": resolved_skills,
|
||||
"requires_partner": parsed.requires_partner if parsed else None,
|
||||
"expectation_mode": "planning_hybrid" if has_planning_reference else "query_only",
|
||||
}
|
||||
|
||||
return target, resolved_intent, scenario, query_intent_summary
|
||||
|
|
|
|||
|
|
@ -78,6 +78,30 @@ def test_compose_retrieval_phase():
|
|||
)
|
||||
|
||||
|
||||
def test_query_only_expectation_without_planning_reference():
|
||||
from planning_exercise_profiles import PlanningTargetProfile
|
||||
from planning_exercise_target_pipeline import build_planning_target_with_query_pipeline
|
||||
|
||||
class _Cur:
|
||||
pass
|
||||
|
||||
target, intent, scenario, summary = build_planning_target_with_query_pipeline(
|
||||
_Cur(),
|
||||
unit={"id": None, "framework_slot_id": None, "origin_framework_slot_id": None},
|
||||
planned_exercise_ids=[],
|
||||
section_planned_exercise_ids=[],
|
||||
anchor_exercise_id=None,
|
||||
query="Partnerübung Reaktion",
|
||||
heuristic_intent="free_search",
|
||||
include_llm_intent=False,
|
||||
context_summary={"expectation_mode": "query_only"},
|
||||
has_planning_reference=False,
|
||||
)
|
||||
assert intent == "free_search"
|
||||
assert summary.get("expectation_mode") == "query_only"
|
||||
assert target.sources == ["query_only"] or "query_only" in target.sources
|
||||
|
||||
|
||||
def test_parse_planning_query_intent_response():
|
||||
parsed = parse_planning_query_intent_response(
|
||||
'{"intent":"continue_plan_goal","scenario":"additive_constraint",'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.173"
|
||||
APP_VERSION = "0.8.174"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260531073"
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ MODULE_VERSIONS = {
|
|||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||
"planning_exercise_suggest": "0.5.0", # Mehrstufiges Profil-Retrieval; LLM-Gates (max 1 Call)
|
||||
"planning_exercise_suggest": "0.6.0", # Abschnitts-/Skill-Kontext; expectation_mode hybrid|query_only
|
||||
"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
|
||||
|
|
@ -43,6 +43,14 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.174",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI: Abschnitts-Kontext (guidance_notes, Übungszahl, letzte Übung), Fähigkeitenprofil Einheit/Abschnitt an LLM.",
|
||||
"Erwartungsprofil hybrid (Planungsbezug) vs. query_only (nur Suchtext); current_section_plan im Target-Profil.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.173",
|
||||
"date": "2026-05-22",
|
||||
|
|
|
|||
|
|
@ -104,6 +104,13 @@ export default function ExercisePickerModal({
|
|||
const base = {
|
||||
groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null,
|
||||
sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0,
|
||||
sectionTitle: planningContext?.sectionTitle ?? null,
|
||||
sectionGuidanceNotes: planningContext?.sectionGuidanceNotes ?? null,
|
||||
sectionPlannedExerciseIds: Array.isArray(planningContext?.sectionPlannedExerciseIds)
|
||||
? planningContext.sectionPlannedExerciseIds
|
||||
: [],
|
||||
sectionExerciseCount: planningContext?.sectionExerciseCount ?? null,
|
||||
lastExerciseTitle: planningContext?.lastExerciseTitle ?? null,
|
||||
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
||||
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
||||
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
|
||||
|
|
@ -424,10 +431,24 @@ export default function ExercisePickerModal({
|
|||
if (resolvedPlanningUnitId) {
|
||||
requestBody.unit_id = Number(resolvedPlanningUnitId)
|
||||
}
|
||||
if (activePlanningContext.groupId) {
|
||||
requestBody.group_id = Number(activePlanningContext.groupId)
|
||||
}
|
||||
const res = await api.suggestPlanningExercises(requestBody)
|
||||
if (activePlanningContext.groupId) {
|
||||
requestBody.group_id = Number(activePlanningContext.groupId)
|
||||
}
|
||||
if (activePlanningContext.sectionTitle) {
|
||||
requestBody.section_title = String(activePlanningContext.sectionTitle)
|
||||
}
|
||||
if (activePlanningContext.sectionGuidanceNotes) {
|
||||
requestBody.section_guidance_notes = String(activePlanningContext.sectionGuidanceNotes)
|
||||
}
|
||||
if (
|
||||
Array.isArray(activePlanningContext.sectionPlannedExerciseIds) &&
|
||||
activePlanningContext.sectionPlannedExerciseIds.length > 0
|
||||
) {
|
||||
requestBody.section_planned_exercise_ids = activePlanningContext.sectionPlannedExerciseIds
|
||||
.map((x) => Number(x))
|
||||
.filter((x) => Number.isFinite(x) && x > 0)
|
||||
}
|
||||
const res = await api.suggestPlanningExercises(requestBody)
|
||||
setPlanningContextSummary(res?.context_summary || null)
|
||||
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
||||
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
||||
|
|
@ -662,6 +683,16 @@ export default function ExercisePickerModal({
|
|||
{planningContextSummary.section_title ? (
|
||||
<span className="exercise-tag">{planningContextSummary.section_title}</span>
|
||||
) : null}
|
||||
{planningContextSummary.section_exercise_count != null ? (
|
||||
<span className="exercise-tag">
|
||||
{planningContextSummary.section_exercise_count} Übungen im Abschnitt
|
||||
</span>
|
||||
) : null}
|
||||
{planningContextSummary.last_section_exercise_title ? (
|
||||
<span className="exercise-tag">
|
||||
Letzte: {planningContextSummary.last_section_exercise_title}
|
||||
</span>
|
||||
) : null}
|
||||
{planningContextSummary.planned_count != null ? (
|
||||
<span className="exercise-tag">{planningContextSummary.planned_count} Übungen im Plan</span>
|
||||
) : null}
|
||||
|
|
@ -687,6 +718,19 @@ export default function ExercisePickerModal({
|
|||
))
|
||||
: null}
|
||||
</div>
|
||||
{planningContextSummary.section_guidance_notes ? (
|
||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text2)' }}>
|
||||
Abschnitt: {planningContextSummary.section_guidance_notes}
|
||||
</p>
|
||||
) : null}
|
||||
{planningContextSummary.expectation_mode ? (
|
||||
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Erwartungsprofil:{' '}
|
||||
{planningContextSummary.expectation_mode === 'query_only'
|
||||
? 'nur Suchtext'
|
||||
: 'Planung + optional Suchtext'}
|
||||
</p>
|
||||
) : null}
|
||||
{planningTargetProfileSummary?.has_skill_gap ? (
|
||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Skill-Lücke zum bisherigen Plan berücksichtigt
|
||||
|
|
|
|||
|
|
@ -286,10 +286,35 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
plannedExerciseIds.push(eid)
|
||||
}
|
||||
}
|
||||
const sectionPlannedExerciseIds = []
|
||||
const seenSec = new Set()
|
||||
for (const it of sec?.items || []) {
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
const eid = Number(it?.exercise_id)
|
||||
if (!Number.isFinite(eid) || eid < 1 || seenSec.has(eid)) continue
|
||||
seenSec.add(eid)
|
||||
sectionPlannedExerciseIds.push(eid)
|
||||
}
|
||||
let lastExerciseTitle = null
|
||||
if (sec?.items?.length) {
|
||||
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
||||
const it = sec.items[i]
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
if (it?.exercise_id) {
|
||||
lastExerciseTitle = (it.exercise_title || '').trim() || null
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
unitId: null,
|
||||
groupId: null,
|
||||
sectionOrderIndex: sIdx,
|
||||
sectionTitle: (sec?.title || '').trim() || null,
|
||||
sectionGuidanceNotes: (sec?.guidance_notes || '').trim() || null,
|
||||
sectionPlannedExerciseIds,
|
||||
sectionExerciseCount: sectionPlannedExerciseIds.length,
|
||||
lastExerciseTitle,
|
||||
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||
progressionGraphId: null,
|
||||
plannedExerciseIds,
|
||||
|
|
|
|||
|
|
@ -173,11 +173,36 @@ export default function TrainingUnitEditPage() {
|
|||
plannedExerciseIds.push(eid)
|
||||
}
|
||||
}
|
||||
const sectionPlannedExerciseIds = []
|
||||
const seenSec = new Set()
|
||||
for (const it of sec?.items || []) {
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
const eid = Number(it?.exercise_id)
|
||||
if (!Number.isFinite(eid) || eid < 1 || seenSec.has(eid)) continue
|
||||
seenSec.add(eid)
|
||||
sectionPlannedExerciseIds.push(eid)
|
||||
}
|
||||
let lastExerciseTitle = null
|
||||
if (sec?.items?.length) {
|
||||
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
||||
const it = sec.items[i]
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
if (it?.exercise_id) {
|
||||
lastExerciseTitle = (it.exercise_title || '').trim() || null
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const groupIdRaw = Number(formData.group_id)
|
||||
return {
|
||||
unitId: resolvedUnitId ? Number(resolvedUnitId) : null,
|
||||
groupId: Number.isFinite(groupIdRaw) && groupIdRaw > 0 ? groupIdRaw : null,
|
||||
sectionOrderIndex: sIdx,
|
||||
sectionTitle: (sec?.title || '').trim() || null,
|
||||
sectionGuidanceNotes: (sec?.guidance_notes || '').trim() || null,
|
||||
sectionPlannedExerciseIds,
|
||||
sectionExerciseCount: sectionPlannedExerciseIds.length,
|
||||
lastExerciseTitle,
|
||||
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||
progressionGraphId: null,
|
||||
plannedExerciseIds,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user