diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 47aef4a..8f5617b 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -48,7 +48,8 @@ _LLM_RERANK_PRE_LIMIT = 32 class PlanningExerciseSuggestRequest(BaseModel): - unit_id: int = Field(..., ge=1) + unit_id: Optional[int] = Field(default=None, ge=1) + group_id: Optional[int] = Field(default=None, ge=1) section_order_index: Optional[int] = Field(default=None, ge=0) phase_order_index: Optional[int] = Field(default=None, ge=0) parallel_stream_order_index: Optional[int] = Field(default=None, ge=0) @@ -196,26 +197,42 @@ def _load_progression_successors( def _load_group_recent_exercise_ids( cur, group_id: Optional[int], - exclude_unit_id: int, + exclude_unit_id: Optional[int] = None, limit: int = 40, ) -> Set[int]: if not group_id: return set() - cur.execute( - """ - SELECT tusi.exercise_id AS eid - FROM training_units tu - INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id - INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id - WHERE tu.group_id = %s - AND tu.id <> %s - AND tusi.exercise_id IS NOT NULL - AND COALESCE(tu.status, '') <> 'cancelled' - ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC - LIMIT 200 - """, - (int(group_id), int(exclude_unit_id)), - ) + if exclude_unit_id is not None: + cur.execute( + """ + SELECT tusi.exercise_id AS eid + FROM training_units tu + INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id + INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id + WHERE tu.group_id = %s + AND tu.id <> %s + AND tusi.exercise_id IS NOT NULL + AND COALESCE(tu.status, '') <> 'cancelled' + ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC + LIMIT 200 + """, + (int(group_id), int(exclude_unit_id)), + ) + else: + cur.execute( + """ + SELECT tusi.exercise_id AS eid + FROM training_units tu + INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id + INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id + WHERE tu.group_id = %s + AND tusi.exercise_id IS NOT NULL + AND COALESCE(tu.status, '') <> 'cancelled' + ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC + LIMIT 200 + """, + (int(group_id),), + ) out: Set[int] = set() for r in cur.fetchall(): if r.get("eid") is None: @@ -364,13 +381,82 @@ def build_planning_exercise_context_pack( } +def build_client_planning_context_pack( + cur, + *, + tenant: TenantContext, + body: PlanningExerciseSuggestRequest, +) -> Dict[str, Any]: + """Freie / Client-Kontext-Suche ohne persistierte training_units.id (Formular, Rahmen-Slot).""" + role = tenant.global_role + if not _has_planning_role(role): + raise HTTPException(status_code=403, detail="Nur Trainer dürfen Planungs-Vorschläge abrufen") + + planned_ids: List[int] = [] + if body.planned_exercise_ids: + seen: Set[int] = set() + for raw in body.planned_exercise_ids: + try: + eid = int(raw) + except (TypeError, ValueError): + continue + if eid < 1 or eid in seen: + continue + seen.add(eid) + planned_ids.append(eid) + + anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id) + anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id) + progression_ids, progression_notes = _load_progression_successors( + cur, body.progression_graph_id, anchor_id + ) + + group_id = body.group_id + group_name = None + if group_id: + cur.execute("SELECT name FROM training_groups WHERE id = %s", (int(group_id),)) + gr = cur.fetchone() + if gr: + group_name = (gr.get("name") or "").strip() or None + + group_recent = _load_group_recent_exercise_ids(cur, group_id, exclude_unit_id=None) + 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 { + "unit_id": None, + "unit": { + "id": None, + "framework_slot_id": None, + "origin_framework_slot_id": None, + }, + "unit_title": None, + "group_id": group_id, + "group_name": group_name, + "section_order_index": body.section_order_index, + "section_title": None, + "planned_exercise_ids": planned_ids, + "anchor_exercise_id": anchor_id, + "anchor_title": anchor_title, + "anchor_skill_ids": sorted(anchor_skills), + "progression_graph_id": body.progression_graph_id, + "progression_successor_ids": sorted(progression_ids), + "progression_edge_notes": progression_notes, + "group_recent_exercise_ids": sorted(group_recent), + "context_mode": "client_free", + } + + def suggest_planning_exercises( cur, *, tenant: TenantContext, body: PlanningExerciseSuggestRequest, ) -> Dict[str, Any]: - pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body) + if body.unit_id: + pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body) + else: + pack = build_client_planning_context_pack(cur, tenant=tenant, body=body) pack = _apply_client_planned_override(cur, pack, body) query = _normalize_query(body.query) heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint) @@ -601,6 +687,7 @@ def suggest_planning_exercises( "anchor_title": pack.get("anchor_title"), "anchor_exercise_id": pack.get("anchor_exercise_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"), } return { diff --git a/backend/version.py b/backend/version.py index c0cb927..4808aa8 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.171" +APP_VERSION = "0.8.172" 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.4.0", # include_llm_intent, scenario_kind, query_intent_summary + "planning_exercise_suggest": "0.4.1", # unit_id optional; client_free Kontext; group_id "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.172", + "date": "2026-05-22", + "changes": [ + "Planungs-KI UI: Menüpunkt „Planungs-KI: Übung vorschlagen“ im +-Dialog; Freitext-Suche ohne gespeicherte unit_id (client_free).", + "API exercise-suggest: unit_id optional, group_id für Client-Kontext.", + ], + }, { "version": "0.8.171", "date": "2026-05-22", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 7a28d26..e5a7518 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -40,7 +40,11 @@ export default function ExercisePickerModal({ enableQuickCreateDraft = false, /** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */ planningUnitId = null, - /** true auf TrainingUnitEditPage: Hinweis wenn Einheit noch keine ID hat. */ + /** 'planning' = Planungs-KI-API; 'library' = Volltext-Bibliothek */ + pickerMode = 'library', + /** Planungs-KI auch ohne gespeicherte unit_id (Client-Kontext / Freitext). */ + enableFreePlanningSearch = false, + /** true auf TrainingUnitEditPage: Hinweis wenn Planungs-KI ohne Einheit und ohne Freitext. */ expectPlanningSearch = false, /** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */ planningContext = null, @@ -84,9 +88,11 @@ export default function ExercisePickerModal({ }, [planningUnitId, planningContext?.unitId]) const activePlanningContext = useMemo(() => { - if (!resolvedPlanningUnitId) return null - return { - unitId: resolvedPlanningUnitId, + if (pickerMode !== 'planning') return null + const groupIdRaw = planningContext?.groupId + const groupId = Number(groupIdRaw) + const base = { + groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null, sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0, phaseOrderIndex: planningContext?.phaseOrderIndex ?? null, parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null, @@ -97,10 +103,24 @@ export default function ExercisePickerModal({ : [], intentHint: planningContext?.intentHint ?? null, } - }, [resolvedPlanningUnitId, planningContext]) + if (!resolvedPlanningUnitId) { + if (!enableFreePlanningSearch && !planningContext) return null + return { unitId: null, ...base } + } + return { + unitId: resolvedPlanningUnitId, + ...base, + } + }, [pickerMode, resolvedPlanningUnitId, enableFreePlanningSearch, planningContext]) - const usePlanningSearch = resolvedPlanningUnitId != null - const planningSearchBlocked = Boolean(expectPlanningSearch && !usePlanningSearch) + const usePlanningSearch = pickerMode === 'planning' && activePlanningContext != null + const useFreePlanningSearch = usePlanningSearch && !resolvedPlanningUnitId + const planningSearchBlocked = Boolean( + pickerMode === 'planning' && + expectPlanningSearch && + !resolvedPlanningUnitId && + !enableFreePlanningSearch + ) /** Gemeinsamer Suchtext — in Planung nur ein Feld; in Bibliothek beide Felder kombiniert. */ const effectivePickerQuery = useMemo(() => { @@ -306,8 +326,7 @@ export default function ExercisePickerModal({ try { if (usePlanningSearch && activePlanningContext) { const query = effectivePickerQuery - const res = await api.suggestPlanningExercises({ - unit_id: Number(activePlanningContext.unitId), + const requestBody = { section_order_index: activePlanningContext.sectionOrderIndex != null ? Number(activePlanningContext.sectionOrderIndex) @@ -336,13 +355,20 @@ export default function ExercisePickerModal({ .filter((x) => Number.isFinite(x) && x > 0) : undefined, include_llm_intent: Boolean(query), - include_llm_rank: true, + include_llm_rank: Boolean(query), query, - intent_hint: activePlanningContext.intentHint || null, + intent_hint: activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null), limit: PAGE_SIZE, exercise_kind_any: Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined, - }) + } + if (resolvedPlanningUnitId) { + requestBody.unit_id = Number(resolvedPlanningUnitId) + } + if (activePlanningContext.groupId) { + requestBody.group_id = Number(activePlanningContext.groupId) + } + const res = await api.suggestPlanningExercises(requestBody) setPlanningContextSummary(res?.context_summary || null) setPlanningTargetProfileSummary(res?.target_profile_summary || null) setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied)) @@ -397,6 +423,8 @@ export default function ExercisePickerModal({ activePlanningContext, effectivePickerQuery, exerciseKindAny, + resolvedPlanningUnitId, + useFreePlanningSearch, ]) useEffect(() => { @@ -643,12 +671,11 @@ export default function ExercisePickerModal({ lineHeight: 1.45, }} > - Planungs-KI noch nicht verfügbar. Die Einheit hat noch keine gespeicherte ID — - bitte zuerst Speichern, dann den Übungspicker erneut öffnen. Bis dahin gilt nur die - Bibliothekssuche (Volltext). + Planungs-KI noch nicht verfügbar. Bitte zuerst Speichern oder den + Menüpunkt Planungs-KI: Übung vorschlagen nutzen (Freitext ohne gespeicherte Einheit).
) : null} - {!usePlanningSearch && !planningSearchBlocked ? ( + {pickerMode === 'library' && expectPlanningSearch ? (- Bibliothekssuche (Volltext) — Planungs-KI mit - Kontext (Einheit, Plan, Anker) gibt es in der{' '} - Trainingseinheit bearbeiten, nach dem Speichern der Einheit. + Bibliothekssuche (Volltext) — für Planungs-KI mit + Kontext oder Freitext-Anfrage den Menüpunkt{' '} + Planungs-KI: Übung vorschlagen … unter dem + wählen. +
+ ) : null} + {useFreePlanningSearch ? ( ++ Freie Planungs-KI — Anker und bisherige Übungen aus + dem Formular; nach Speichern kommen Gruppe, Historie und Rahmen dazu. +
+ ) : null} + {!usePlanningSearch && !planningSearchBlocked && !expectPlanningSearch ? ( ++ Bibliothekssuche (Volltext)
) : null}