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 [])
|
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(
|
def build_planning_target_profile(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
unit: Dict[str, Any],
|
unit: Dict[str, Any],
|
||||||
planned_exercise_ids: Sequence[int],
|
planned_exercise_ids: Sequence[int],
|
||||||
|
section_planned_exercise_ids: Optional[Sequence[int]] = None,
|
||||||
anchor_exercise_id: Optional[int],
|
anchor_exercise_id: Optional[int],
|
||||||
intent: str,
|
intent: str,
|
||||||
) -> PlanningTargetProfile:
|
) -> PlanningTargetProfile:
|
||||||
|
|
@ -356,6 +395,13 @@ def build_planning_target_profile(
|
||||||
if skill_plan:
|
if skill_plan:
|
||||||
sources.append("current_unit_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:
|
if anchor_exercise_id:
|
||||||
anchor_profiles = load_exercise_match_profiles_bulk(cur, [int(anchor_exercise_id)])
|
anchor_profiles = load_exercise_match_profiles_bulk(cur, [int(anchor_exercise_id)])
|
||||||
ap = anchor_profiles.get(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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from tenant_context import TenantContext, library_content_visibility_sql
|
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_retrieval import run_multistage_planning_retrieval
|
||||||
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
|
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
|
||||||
from planning_exercise_target_pipeline import (
|
from planning_exercise_target_pipeline import (
|
||||||
|
|
@ -56,6 +57,9 @@ class PlanningExerciseSuggestRequest(BaseModel):
|
||||||
query: Optional[str] = ""
|
query: Optional[str] = ""
|
||||||
intent_hint: Optional[str] = None
|
intent_hint: Optional[str] = None
|
||||||
planned_exercise_ids: Optional[List[int]] = 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_intent: bool = True
|
||||||
include_llm_rank: bool = False
|
include_llm_rank: bool = False
|
||||||
limit: int = Field(default=20, ge=1, le=50)
|
limit: int = Field(default=20, ge=1, le=50)
|
||||||
|
|
@ -241,6 +245,131 @@ def _load_group_recent_exercise_ids(
|
||||||
return out
|
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]:
|
def _section_title_for_index(sections: Sequence[Dict[str, Any]], section_order_index: Optional[int]) -> Optional[str]:
|
||||||
if section_order_index is None:
|
if section_order_index is None:
|
||||||
return 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])
|
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
||||||
anchor_title = titles.get(anchor_id) if anchor_id else None
|
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),
|
||||||
"unit": {
|
"unit": {
|
||||||
"id": int(body.unit_id),
|
"id": int(body.unit_id),
|
||||||
|
|
@ -369,6 +498,7 @@ def build_planning_exercise_context_pack(
|
||||||
"progression_edge_notes": progression_notes,
|
"progression_edge_notes": progression_notes,
|
||||||
"group_recent_exercise_ids": sorted(group_recent),
|
"group_recent_exercise_ids": sorted(group_recent),
|
||||||
}
|
}
|
||||||
|
return _attach_planning_context_details(cur, pack, sections=sections, body=body)
|
||||||
|
|
||||||
|
|
||||||
def build_client_planning_context_pack(
|
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])
|
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
||||||
anchor_title = titles.get(anchor_id) if anchor_id else None
|
anchor_title = titles.get(anchor_id) if anchor_id else None
|
||||||
|
|
||||||
return {
|
pack = {
|
||||||
"unit_id": None,
|
"unit_id": None,
|
||||||
"unit": {
|
"unit": {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
|
@ -424,7 +554,7 @@ def build_client_planning_context_pack(
|
||||||
"group_id": group_id,
|
"group_id": group_id,
|
||||||
"group_name": group_name,
|
"group_name": group_name,
|
||||||
"section_order_index": body.section_order_index,
|
"section_order_index": body.section_order_index,
|
||||||
"section_title": None,
|
"section_title": (body.section_title or "").strip() or None,
|
||||||
"planned_exercise_ids": planned_ids,
|
"planned_exercise_ids": planned_ids,
|
||||||
"anchor_exercise_id": anchor_id,
|
"anchor_exercise_id": anchor_id,
|
||||||
"anchor_title": anchor_title,
|
"anchor_title": anchor_title,
|
||||||
|
|
@ -435,6 +565,7 @@ def build_client_planning_context_pack(
|
||||||
"group_recent_exercise_ids": sorted(group_recent),
|
"group_recent_exercise_ids": sorted(group_recent),
|
||||||
"context_mode": "client_free",
|
"context_mode": "client_free",
|
||||||
}
|
}
|
||||||
|
return _attach_planning_context_details(cur, pack, sections=None, body=body)
|
||||||
|
|
||||||
|
|
||||||
def suggest_planning_exercises(
|
def suggest_planning_exercises(
|
||||||
|
|
@ -448,27 +579,40 @@ def suggest_planning_exercises(
|
||||||
else:
|
else:
|
||||||
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
|
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
|
||||||
pack = _apply_client_planned_override(cur, pack, body)
|
pack = _apply_client_planned_override(cur, pack, body)
|
||||||
|
pack = _attach_planning_context_details(cur, pack, body=body)
|
||||||
query = _normalize_query(body.query)
|
query = _normalize_query(body.query)
|
||||||
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
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 = {
|
pipeline_context = {
|
||||||
"unit_title": pack.get("unit_title"),
|
"unit_title": pack.get("unit_title"),
|
||||||
"group_name": pack.get("group_name"),
|
"group_name": pack.get("group_name"),
|
||||||
"section_title": pack.get("section_title"),
|
"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 []),
|
"planned_count": len(pack.get("planned_exercise_ids") or []),
|
||||||
"anchor_title": pack.get("anchor_title"),
|
"anchor_title": pack.get("anchor_title"),
|
||||||
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
"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"),
|
"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(
|
target_profile, intent, scenario_kind, query_intent_summary = build_planning_target_with_query_pipeline(
|
||||||
cur,
|
cur,
|
||||||
unit=pack["unit"],
|
unit=pack["unit"],
|
||||||
planned_exercise_ids=pack["planned_exercise_ids"],
|
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"),
|
anchor_exercise_id=pack.get("anchor_exercise_id"),
|
||||||
query=query,
|
query=query,
|
||||||
heuristic_intent=heuristic_intent,
|
heuristic_intent=heuristic_intent,
|
||||||
include_llm_intent=body.include_llm_intent,
|
include_llm_intent=body.include_llm_intent,
|
||||||
context_summary=pipeline_context,
|
context_summary=pipeline_context,
|
||||||
|
has_planning_reference=has_plan_ref,
|
||||||
)
|
)
|
||||||
weights = _intent_weights(intent)
|
weights = _intent_weights(intent)
|
||||||
target_profile_summary = target_profile.to_summary_dict(cur)
|
target_profile_summary = target_profile.to_summary_dict(cur)
|
||||||
|
|
@ -549,11 +693,18 @@ def suggest_planning_exercises(
|
||||||
"unit_title": pack.get("unit_title"),
|
"unit_title": pack.get("unit_title"),
|
||||||
"group_name": pack.get("group_name"),
|
"group_name": pack.get("group_name"),
|
||||||
"section_title": pack.get("section_title"),
|
"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),
|
"planned_count": len(planned_set),
|
||||||
"anchor_title": pack.get("anchor_title"),
|
"anchor_title": pack.get("anchor_title"),
|
||||||
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
"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"),
|
"progression_graph_id": pack.get("progression_graph_id"),
|
||||||
"context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"),
|
"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 {
|
return {
|
||||||
|
|
@ -568,5 +719,6 @@ def suggest_planning_exercises(
|
||||||
"intent_resolved": intent,
|
"intent_resolved": intent,
|
||||||
"intent_heuristic": heuristic_intent,
|
"intent_heuristic": heuristic_intent,
|
||||||
"query_normalized": query or None,
|
"query_normalized": query or None,
|
||||||
|
"expectation_mode": expectation_mode,
|
||||||
"hits": hits,
|
"hits": hits,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,14 +224,19 @@ def build_planning_target_with_query_pipeline(
|
||||||
*,
|
*,
|
||||||
unit: Dict[str, Any],
|
unit: Dict[str, Any],
|
||||||
planned_exercise_ids: List[int],
|
planned_exercise_ids: List[int],
|
||||||
|
section_planned_exercise_ids: Optional[List[int]] = None,
|
||||||
anchor_exercise_id: Optional[int],
|
anchor_exercise_id: Optional[int],
|
||||||
query: Optional[str],
|
query: Optional[str],
|
||||||
heuristic_intent: str,
|
heuristic_intent: str,
|
||||||
include_llm_intent: bool,
|
include_llm_intent: bool,
|
||||||
context_summary: Mapping[str, Any],
|
context_summary: Mapping[str, Any],
|
||||||
|
has_planning_reference: bool = True,
|
||||||
) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]:
|
) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict
|
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)
|
scenario = classify_planning_scenario(query, heuristic_intent)
|
||||||
resolved_intent = heuristic_intent
|
resolved_intent = heuristic_intent
|
||||||
|
|
@ -239,13 +244,18 @@ def build_planning_target_with_query_pipeline(
|
||||||
parsed: Optional[PlanningQueryIntentParsed] = None
|
parsed: Optional[PlanningQueryIntentParsed] = None
|
||||||
resolved_skills: List[Dict[str, Any]] = []
|
resolved_skills: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
base = build_planning_target_profile(
|
if has_planning_reference:
|
||||||
cur,
|
base = build_planning_target_profile(
|
||||||
unit=unit,
|
cur,
|
||||||
planned_exercise_ids=planned_exercise_ids,
|
unit=unit,
|
||||||
anchor_exercise_id=anchor_exercise_id,
|
planned_exercise_ids=planned_exercise_ids,
|
||||||
intent=heuristic_intent,
|
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)
|
base_summary = base.to_summary_dict(cur)
|
||||||
|
|
||||||
if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent):
|
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)
|
focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
|
||||||
if focus or style or tt or tg or skills:
|
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(
|
target = merge_query_overlay_into_target(
|
||||||
base,
|
base,
|
||||||
focus=focus,
|
focus=focus,
|
||||||
|
|
@ -280,9 +295,12 @@ def build_planning_target_with_query_pipeline(
|
||||||
tt=tt,
|
tt=tt,
|
||||||
tg=tg,
|
tg=tg,
|
||||||
skills=skills,
|
skills=skills,
|
||||||
emphasis=parsed.emphasis,
|
emphasis=overlay_emphasis,
|
||||||
scenario=scenario,
|
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] = {
|
query_intent_summary: Dict[str, Any] = {
|
||||||
"scenario": scenario,
|
"scenario": scenario,
|
||||||
|
|
@ -293,6 +311,7 @@ def build_planning_target_with_query_pipeline(
|
||||||
"rationale": (parsed.rationale if parsed else None),
|
"rationale": (parsed.rationale if parsed else None),
|
||||||
"skill_hints_resolved": resolved_skills,
|
"skill_hints_resolved": resolved_skills,
|
||||||
"requires_partner": parsed.requires_partner if parsed else None,
|
"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
|
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():
|
def test_parse_planning_query_intent_response():
|
||||||
parsed = parse_planning_query_intent_response(
|
parsed = parse_planning_query_intent_response(
|
||||||
'{"intent":"continue_plan_goal","scenario":"additive_constraint",'
|
'{"intent":"continue_plan_goal","scenario":"additive_constraint",'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.173"
|
APP_VERSION = "0.8.174"
|
||||||
BUILD_DATE = "2026-05-22"
|
BUILD_DATE = "2026-05-22"
|
||||||
DB_SCHEMA_VERSION = "20260531073"
|
DB_SCHEMA_VERSION = "20260531073"
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ MODULE_VERSIONS = {
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
"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_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -43,6 +43,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.173",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,13 @@ export default function ExercisePickerModal({
|
||||||
const base = {
|
const base = {
|
||||||
groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null,
|
groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null,
|
||||||
sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0,
|
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,
|
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
||||||
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
||||||
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
|
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
|
||||||
|
|
@ -424,10 +431,24 @@ export default function ExercisePickerModal({
|
||||||
if (resolvedPlanningUnitId) {
|
if (resolvedPlanningUnitId) {
|
||||||
requestBody.unit_id = Number(resolvedPlanningUnitId)
|
requestBody.unit_id = Number(resolvedPlanningUnitId)
|
||||||
}
|
}
|
||||||
if (activePlanningContext.groupId) {
|
if (activePlanningContext.groupId) {
|
||||||
requestBody.group_id = Number(activePlanningContext.groupId)
|
requestBody.group_id = Number(activePlanningContext.groupId)
|
||||||
}
|
}
|
||||||
const res = await api.suggestPlanningExercises(requestBody)
|
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)
|
setPlanningContextSummary(res?.context_summary || null)
|
||||||
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
||||||
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
||||||
|
|
@ -662,6 +683,16 @@ export default function ExercisePickerModal({
|
||||||
{planningContextSummary.section_title ? (
|
{planningContextSummary.section_title ? (
|
||||||
<span className="exercise-tag">{planningContextSummary.section_title}</span>
|
<span className="exercise-tag">{planningContextSummary.section_title}</span>
|
||||||
) : null}
|
) : 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 ? (
|
{planningContextSummary.planned_count != null ? (
|
||||||
<span className="exercise-tag">{planningContextSummary.planned_count} Übungen im Plan</span>
|
<span className="exercise-tag">{planningContextSummary.planned_count} Übungen im Plan</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -687,6 +718,19 @@ export default function ExercisePickerModal({
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</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 ? (
|
{planningTargetProfileSummary?.has_skill_gap ? (
|
||||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||||
Skill-Lücke zum bisherigen Plan berücksichtigt
|
Skill-Lücke zum bisherigen Plan berücksichtigt
|
||||||
|
|
|
||||||
|
|
@ -286,10 +286,35 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
plannedExerciseIds.push(eid)
|
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 {
|
return {
|
||||||
unitId: null,
|
unitId: null,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
sectionOrderIndex: sIdx,
|
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,
|
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||||
progressionGraphId: null,
|
progressionGraphId: null,
|
||||||
plannedExerciseIds,
|
plannedExerciseIds,
|
||||||
|
|
|
||||||
|
|
@ -173,11 +173,36 @@ export default function TrainingUnitEditPage() {
|
||||||
plannedExerciseIds.push(eid)
|
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)
|
const groupIdRaw = Number(formData.group_id)
|
||||||
return {
|
return {
|
||||||
unitId: resolvedUnitId ? Number(resolvedUnitId) : null,
|
unitId: resolvedUnitId ? Number(resolvedUnitId) : null,
|
||||||
groupId: Number.isFinite(groupIdRaw) && groupIdRaw > 0 ? groupIdRaw : null,
|
groupId: Number.isFinite(groupIdRaw) && groupIdRaw > 0 ? groupIdRaw : null,
|
||||||
sectionOrderIndex: sIdx,
|
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,
|
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||||
progressionGraphId: null,
|
progressionGraphId: null,
|
||||||
plannedExerciseIds,
|
plannedExerciseIds,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user