From 207817376d41aa3e51d9986916a1ee7bf8193b76 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 22:09:28 +0200 Subject: [PATCH] Enhance Planning Exercise Suggestion with LLM-Rerank and Client Overrides - Implemented optional LLM-Rerank functionality in the planning exercise suggestion process, allowing for improved exercise ranking based on user-defined criteria. - Updated the `suggestPlanningExercises` API to accept `planned_exercise_ids` for client-side overrides, enhancing flexibility in exercise selection. - Enhanced the `ExercisePickerModal` to reflect LLM ranking status and support new planning context features. - Incremented application version to 0.8.170 and updated changelog to document the new features and improvements in the planning AI capabilities. --- .../PLANNING_EXERCISE_SUGGEST_CONTEXT.md | 32 ++- backend/ai_prompt_runtime.py | 9 + ...i_prompt_planning_exercise_search_rank.sql | 54 +++++ backend/planning_exercise_llm_rank.py | 223 ++++++++++++++++++ backend/planning_exercise_suggest.py | 76 +++++- backend/routers/planning_exercise_suggest.py | 2 +- .../tests/test_planning_exercise_suggest.py | 34 +++ backend/version.py | 18 +- docs/HANDOVER.md | 4 +- frontend/src/api/planning.js | 2 +- .../src/components/ExercisePickerModal.jsx | 11 + frontend/src/pages/TrainingUnitEditPage.jsx | 12 + 12 files changed, 463 insertions(+), 14 deletions(-) create mode 100644 backend/migrations/072_ai_prompt_planning_exercise_search_rank.sql create mode 100644 backend/planning_exercise_llm_rank.py create mode 100644 backend/tests/test_planning_exercise_suggest.py diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index 5d337d1..bdfd06e 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -178,8 +178,8 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: |-------|--------| | **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung | | **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` | +| **P2** ✅ (optional) | LLM-Rerank `planning_exercise_search_rank`, `include_llm_rank`, `llm_rank_applied` | | **P1** | LLM Intent-JSON; Neu-Anlage mit Pack | -| **P2** | LLM-Rerank + Kurzbegründung | | **P3** | Skill-Discovery / Framework-Ziele im Pack | --- @@ -189,14 +189,38 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: - **2026-05-22:** Erstfassung; P0 API + Planungs-Picker. - **2026-05-22:** P0 implementiert (`planning_exercise_suggest.py`, Router, Picker); unsaved Formular-Plan noch nicht an API (nur persistierte Einheit). - **2026-05-22:** P0.1 — `planning_exercise_profiles.py`, Profil-Score in Hybrid-Retrieval, `retrieval_phase: profile_v1`, `target_profile_summary`. +- **2026-05-22:** P2 — LLM-Rerank optional (`include_llm_rank`); Client `planned_exercise_ids[]`; Prompt Migration 072. --- ## 11. Bekannte P0-Lücken -- **Ungespeicherte Plan-Änderungen:** API liest DB-Stand der Einheit — offene Formular-Items folgen in P0.1 (Client übergibt `planned_exercise_ids[]`). +- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage). - **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API). -- **LLM-Intent / Rerank:** P1/P2 laut Roadmap §9. +- **LLM-Intent:** P1 laut Roadmap §9. + +--- + +## 15. LLM-Rerank (P2) + +**Request:** + +| Feld | Typ | Default | Bedeutung | +|------|-----|---------|-----------| +| `planned_exercise_ids` | `int[]` | — | Optional: Reihenfolge aus Formular (überschreibt DB-Plan) | +| `include_llm_rank` | `bool` | `false` | Top-32 Hybrid-Kandidaten → OpenRouter Prompt `planning_exercise_search_rank` | + +**Response:** + +| Feld | Wert | +|------|------| +| `retrieval_phase` | `profile_v1` oder `profile_v1+llm_rank` | +| `llm_rank_applied` | `true` wenn LLM erfolgreich sortiert hat | +| `hits[].llm_rank` | optional: Position nach LLM (1…n) | + +**Fallback:** Kein API-Key, inaktiver Prompt oder Parse-Fehler → Hybrid-Reihenfolge unverändert, `llm_rank_applied: false`. + +**Prompt:** Migration **072**, Slug `planning_exercise_search_rank` — Kandidaten als JSON mit Titel, summary, goal (Plaintext), skills; Ausgabe `{ ranked_ids, reasons }`. --- @@ -277,4 +301,4 @@ Im Hybrid-Score kommt **`w_profile * profile_score`** hinzu (Intent-abhängig ~0 | `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank | | `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) | -**Phase 2 (P2):** Top 20–40 Kandidaten nach Hybrid+Profil → LLM `planning_exercise_search_rank` mit **Titel + summary + goal**; nur IDs aus Kandidatenliste. +**Phase 2 (P2):** siehe §15 — optional per `include_llm_rank`. diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py index 315c1d2..a7c4bdb 100644 --- a/backend/ai_prompt_runtime.py +++ b/backend/ai_prompt_runtime.py @@ -11,6 +11,12 @@ from typing import Any, Dict, Mapping, Optional, Tuple from prompt_resolver import MustacheRenderResult, render_mustache_template +_PLANNING_AI_SLUGS = frozenset( + { + "planning_exercise_search_rank", + } +) + _EXERCISE_AI_SLUGS = frozenset( { "exercise_summary", @@ -26,12 +32,15 @@ class AiPromptContextKind(str, Enum): ohne bestehende Slugs zu invalidieren. """ + PLANNING_EXERCISE_SEARCH = "planning_exercise_search" EXERCISE_FORM_AI = "exercise_form_ai" def context_kind_for_slug(slug: str) -> Optional[AiPromptContextKind]: """Ordnet einen DB-Slug einer Kontext-Art zu, sofern registriert.""" s = (slug or "").strip().lower() + if s in _PLANNING_AI_SLUGS: + return AiPromptContextKind.PLANNING_EXERCISE_SEARCH if s in _EXERCISE_AI_SLUGS: return AiPromptContextKind.EXERCISE_FORM_AI return None diff --git a/backend/migrations/072_ai_prompt_planning_exercise_search_rank.sql b/backend/migrations/072_ai_prompt_planning_exercise_search_rank.sql new file mode 100644 index 0000000..68c1eb3 --- /dev/null +++ b/backend/migrations/072_ai_prompt_planning_exercise_search_rank.sql @@ -0,0 +1,54 @@ +-- Migration 072: KI-Prompt Planungs-Übungssuche — LLM-Rerank (Phase 2) +-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §14 + +INSERT INTO ai_prompts ( + slug, display_name, description, template, + category, output_format, output_schema, is_system_default, default_template, active, sort_order +) +SELECT + 'planning_exercise_search_rank', + 'Planungs-Übungssuche Rerank', + 'Ordnet Kandidaten für die Trainingsplanung nach Intent und Kontext; nur IDs aus candidates_json.', + $t$Du bist Assistent für Kampfsport-Trainer bei der Trainingsplanung. +Ordne die vorgegebenen Übungs-Kandidaten nach Eignung für die aktuelle Planungssituation. + +Regeln: +- Verwende NUR exercise_id-Werte aus candidates_json (keine erfundenen IDs). +- Berücksichtige search_query, intent, planning_context_json und target_profile_json. +- Bewerte anhand von Titel, summary, goal und skills jedes Kandidaten. +- Gib maximal {{result_limit}} IDs in sinnvoller Reihenfolge zurück (beste zuerst). +- Kurze Begründung pro Top-Treffer auf Deutsch (1 Satz, sachlich). + +Intent-Hinweise: +- suggest_next / progression_next: logische Fortsetzung, Progression, passende Skills +- deepen_exercise: Vertiefung zum Anker, ähnlicher Fokus +- continue_plan_goal: schließt an bisherigen Plan und Skill-Lücken an +- free_search: Freitext-Relevanz + +Kontext: +Intent: {{intent}} +Suchanfrage: {{search_query}} +Planung: {{planning_context_json}} +Zielprofil: {{target_profile_json}} + +Kandidaten (JSON): +{{candidates_json}} + +Antworte NUR mit JSON (kein Text davor/danach): +{ + "ranked_ids": [123, 456], + "reasons": { "123": "…", "456": "…" } +}$t$, + 'training', + 'json', + '{"type":"object","required":["ranked_ids"],"properties":{"ranked_ids":{"type":"array","items":{"type":"integer"}},"reasons":{"type":"object"}}}'::jsonb, + true, + NULL, + true, + 10 +WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_rank'); + +UPDATE ai_prompts +SET default_template = template +WHERE slug = 'planning_exercise_search_rank' + AND (default_template IS NULL OR TRIM(default_template) = ''); diff --git a/backend/planning_exercise_llm_rank.py b/backend/planning_exercise_llm_rank.py new file mode 100644 index 0000000..b3a2549 --- /dev/null +++ b/backend/planning_exercise_llm_rank.py @@ -0,0 +1,223 @@ +""" +Phase 2 Planungs-Übungssuche: LLM-Rerank über Hybrid-Kandidaten. + +Prompt-Slug: planning_exercise_search_rank (Migration 072) +""" +from __future__ import annotations + +import json +import logging +import re +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple + +from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt +from exercise_ai import strip_html_to_plain +from openrouter_chat import ( + effective_openrouter_model_for_prompt_row, + normalize_openrouter_env, + openrouter_chat_completion, +) + +_logger = logging.getLogger("shinkan.planning_exercise_llm_rank") + +_LLM_RERANK_POOL = 32 +_MAX_GOAL_PLAIN = 480 +_MAX_SUMMARY_PLAIN = 320 +_MAX_REASON_LEN = 160 + + +def _compact_json(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +def _extract_json_object(text: str) -> Dict[str, Any]: + s = (text or "").strip() + if s.startswith("```"): + s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) + if s.endswith("```"): + s = s[:-3].strip() + start = s.find("{") + end = s.rfind("}") + if start < 0 or end <= start: + raise ValueError("Kein JSON-Objekt in LLM-Antwort") + obj = json.loads(s[start : end + 1]) + if not isinstance(obj, dict): + raise ValueError("LLM-Antwort ist kein JSON-Objekt") + return obj + + +def parse_planning_exercise_rank_response( + text: str, + allowed_ids: Set[int], +) -> Tuple[List[int], Dict[int, str]]: + """ + Validiert LLM-Ranking: nur erlaubte exercise_id, dedupliziert, Reihenfolge beibehalten. + """ + obj = _extract_json_object(text) + ranked_raw = obj.get("ranked_ids") or obj.get("ranked") or obj.get("ids") + if not isinstance(ranked_raw, list): + raise ValueError("ranked_ids fehlt oder ist keine Liste") + + ranked: List[int] = [] + seen: Set[int] = set() + for raw in ranked_raw: + try: + eid = int(raw) + except (TypeError, ValueError): + continue + if eid < 1 or eid not in allowed_ids or eid in seen: + continue + seen.add(eid) + ranked.append(eid) + + reasons_out: Dict[int, str] = {} + reasons_raw = obj.get("reasons") or obj.get("reasons_by_id") or {} + if isinstance(reasons_raw, dict): + for k, v in reasons_raw.items(): + try: + eid = int(k) + except (TypeError, ValueError): + continue + if eid not in allowed_ids: + continue + txt = str(v or "").strip() + if txt: + reasons_out[eid] = txt[:_MAX_REASON_LEN] + + return ranked, reasons_out + + +def _build_candidate_payload( + hit: Mapping[str, Any], + *, + goal_plain: str, + skill_names: Sequence[str], +) -> Dict[str, Any]: + return { + "id": int(hit["id"]), + "title": str(hit.get("title") or "").strip()[:200], + "summary": strip_html_to_plain(hit.get("summary"), max_len=_MAX_SUMMARY_PLAIN), + "goal": goal_plain, + "skills": list(skill_names)[:8], + "retrieval_score": float(hit.get("score") or 0.0), + } + + +def _load_exercise_goals(cur, exercise_ids: Sequence[int]) -> Dict[int, str]: + ids = [int(x) for x in exercise_ids if int(x) > 0] + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f"SELECT id, goal FROM exercises WHERE id IN ({ph})", + ids, + ) + return {int(r["id"]): str(r.get("goal") or "") for r in cur.fetchall()} + + +def _load_skill_names(cur, skill_ids: Sequence[int]) -> Dict[int, str]: + ids = sorted({int(x) for x in skill_ids if int(x) > 0}) + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", ids) + return {int(r["id"]): str(r.get("name") or "") for r in cur.fetchall()} + + +def try_llm_rerank_planning_hits( + cur, + *, + hits: List[Dict[str, Any]], + skills_by_ex: Mapping[int, Set[int]], + query: str, + intent: str, + context_summary: Mapping[str, Any], + target_profile_summary: Mapping[str, Any], + limit: int, +) -> Tuple[List[Dict[str, Any]], bool]: + """ + Optionaler LLM-Rerank der Top-Kandidaten. Bei Fehler: Original-Reihenfolge, llm_applied=False. + """ + if not hits: + return hits, False + + api_key, _ = normalize_openrouter_env() + if not api_key: + return hits, False + + pool = hits[:_LLM_RERANK_POOL] + allowed_ids = {int(h["id"]) for h in pool} + goals = _load_exercise_goals(cur, list(allowed_ids)) + + all_skill_ids: Set[int] = set() + for eid in allowed_ids: + all_skill_ids.update(skills_by_ex.get(eid) or set()) + skill_name_map = _load_skill_names(cur, list(all_skill_ids)) + + candidates: List[Dict[str, Any]] = [] + for hit in pool: + eid = int(hit["id"]) + sk_ids = sorted(skills_by_ex.get(eid) or set()) + sk_names = [skill_name_map.get(sid, f"#{sid}") for sid in sk_ids[:8]] + goal_plain = strip_html_to_plain(goals.get(eid), max_len=_MAX_GOAL_PLAIN) + candidates.append( + _build_candidate_payload(hit, goal_plain=goal_plain, skill_names=sk_names) + ) + + variables = { + "search_query": query or "", + "intent": intent or "", + "planning_context_json": _compact_json(dict(context_summary or {})), + "target_profile_json": _compact_json(dict(target_profile_summary or {})), + "candidates_json": _compact_json(candidates), + "result_limit": str(max(1, min(int(limit), 50))), + } + + try: + prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_rank", variables) + model = effective_openrouter_model_for_prompt_row(prow) + raw = openrouter_chat_completion( + api_key=api_key, + model=model, + user_content=rendered.text, + ) + ranked_ids, llm_reasons = parse_planning_exercise_rank_response(raw, allowed_ids) + except AiPromptUnavailableError: + return hits, False + except Exception as exc: + _logger.warning("Planungs-LLM-Rerank fehlgeschlagen: %s", exc) + return hits, False + + if not ranked_ids: + return hits, False + + hit_by_id = {int(h["id"]): h for h in hits} + reranked: List[Dict[str, Any]] = [] + used: Set[int] = set() + for eid in ranked_ids: + hit = hit_by_id.get(eid) + if not hit: + continue + used.add(eid) + new_hit = dict(hit) + reasons = list(hit.get("reasons") or []) + llm_reason = llm_reasons.get(eid) + if llm_reason and llm_reason not in reasons: + reasons.insert(0, llm_reason) + new_hit["reasons"] = reasons + new_hit["llm_rank"] = len(reranked) + 1 + reranked.append(new_hit) + + for hit in hits: + eid = int(hit["id"]) + if eid in used: + continue + reranked.append(dict(hit)) + + return reranked[: max(int(limit), len(reranked))], True + + +__all__ = [ + "parse_planning_exercise_rank_response", + "try_llm_rerank_planning_hits", +] diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index f0538a3..83f31e4 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -17,6 +17,7 @@ from planning_exercise_profiles import ( load_exercise_match_profiles_bulk, score_exercise_against_target, ) +from planning_exercise_llm_rank import try_llm_rerank_planning_hits # Planungs-Berechtigung + Sektionen (bestehende Implementierung) from routers.training_planning import ( @@ -40,6 +41,7 @@ VALID_INTENTS = { } _CANDIDATE_POOL_LIMIT = 400 +_LLM_RERANK_PRE_LIMIT = 32 class PlanningExerciseSuggestRequest(BaseModel): @@ -51,6 +53,8 @@ class PlanningExerciseSuggestRequest(BaseModel): progression_graph_id: Optional[int] = Field(default=None, ge=1) query: Optional[str] = "" intent_hint: Optional[str] = None + planned_exercise_ids: Optional[List[int]] = None + include_llm_rank: bool = False limit: int = Field(default=20, ge=1, le=50) exercise_kind_any: Optional[List[str]] = None @@ -240,6 +244,42 @@ def _skill_jaccard(a: Set[int], b: Set[int]) -> float: return inter / union if union else 0.0 +def _apply_client_planned_override( + cur, + pack: Dict[str, Any], + body: PlanningExerciseSuggestRequest, +) -> Dict[str, Any]: + """Client-Plan (ungespeichertes Formular) überschreibt DB-Stand.""" + if not body.planned_exercise_ids: + return pack + planned_ids: List[int] = [] + 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) + if not planned_ids: + return pack + + pack["planned_exercise_ids"] = planned_ids + if not body.anchor_exercise_id: + anchor_id = _resolve_anchor_from_plan(planned_ids, None) + pack["anchor_exercise_id"] = anchor_id + if anchor_id: + titles = _load_exercise_titles(cur, [anchor_id]) + pack["anchor_title"] = titles.get(anchor_id) + pack["anchor_skill_ids"] = sorted(_load_skill_ids_for_exercise(cur, anchor_id)) + else: + pack["anchor_title"] = None + pack["anchor_skill_ids"] = [] + return pack + + def build_planning_exercise_context_pack( cur, *, @@ -327,6 +367,7 @@ def suggest_planning_exercises( body: PlanningExerciseSuggestRequest, ) -> Dict[str, Any]: pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body) + pack = _apply_client_planned_override(cur, pack, body) query = _normalize_query(body.query) intent = resolve_planning_exercise_intent(query, body.intent_hint) weights = _intent_weights(intent) @@ -497,6 +538,38 @@ def suggest_planning_exercises( ) hits.sort(key=lambda h: (-h["score"], h.get("title") or "")) + + llm_applied = False + retrieval_phase = "profile_v1" + if body.include_llm_rank: + pre_limit = max(int(body.limit), _LLM_RERANK_PRE_LIMIT) + pool_hits = hits[:pre_limit] + pool_hits, llm_applied = try_llm_rerank_planning_hits( + cur, + hits=pool_hits, + skills_by_ex=skills_by_ex, + query=query, + intent=intent, + context_summary={ + "unit_title": pack.get("unit_title"), + "group_name": pack.get("group_name"), + "section_title": pack.get("section_title"), + "planned_count": len(planned_set), + "anchor_title": pack.get("anchor_title"), + "intent": intent, + }, + target_profile_summary=target_profile_summary, + limit=int(body.limit), + ) + if llm_applied: + retrieval_phase = "profile_v1+llm_rank" + tail = hits[pre_limit:] + hits = pool_hits + tail + else: + hits = pool_hits[: int(body.limit)] + else: + hits = hits[: int(body.limit)] + hits = hits[: int(body.limit)] context_summary = { @@ -512,7 +585,8 @@ def suggest_planning_exercises( return { "context_summary": context_summary, "target_profile_summary": target_profile_summary, - "retrieval_phase": "profile_v1", + "retrieval_phase": retrieval_phase, + "llm_rank_applied": llm_applied, "intent_resolved": intent, "query_normalized": query or None, "hits": hits, diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index 9a341d6..310fd5f 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -1,5 +1,5 @@ """ -POST /api/planning/exercise-suggest — planungsgebundene Übungssuche (P0 Hybrid-Retrieval). +POST /api/planning/exercise-suggest — planungsgebundene Übungssuche (Hybrid + Profil + optional LLM-Rerank). """ from fastapi import APIRouter, Depends diff --git a/backend/tests/test_planning_exercise_suggest.py b/backend/tests/test_planning_exercise_suggest.py new file mode 100644 index 0000000..293f47c --- /dev/null +++ b/backend/tests/test_planning_exercise_suggest.py @@ -0,0 +1,34 @@ +"""Tests für Planungs-Übungssuche (Intent, LLM-Rerank-Parser).""" +from planning_exercise_suggest import resolve_planning_exercise_intent +from planning_exercise_llm_rank import parse_planning_exercise_rank_response + + +def test_resolve_planning_exercise_intent_defaults(): + assert resolve_planning_exercise_intent("", None) == "suggest_next" + assert resolve_planning_exercise_intent(" ", "suggest_next") == "suggest_next" + + +def test_resolve_planning_exercise_intent_keywords(): + assert resolve_planning_exercise_intent("Vertiefung Partner", None) == "deepen_exercise" + assert resolve_planning_exercise_intent("nächste übung", None) == "suggest_next" + assert resolve_planning_exercise_intent("progression graph", None) == "progression_next" + + +def test_parse_planning_exercise_rank_response_filters_ids(): + allowed = {10, 20, 30} + ranked, reasons = parse_planning_exercise_rank_response( + '{"ranked_ids":[20,999,20,10],"reasons":{"20":"Passt gut","999":"ignore"}}', + allowed, + ) + assert ranked == [20, 10] + assert reasons[20] == "Passt gut" + assert 999 not in reasons + + +def test_parse_planning_exercise_rank_response_reasons_by_id_alias(): + ranked, reasons = parse_planning_exercise_rank_response( + '{"ranked_ids":[5],"reasons_by_id":{"5":"Skill-Lücke"}}', + {5}, + ) + assert ranked == [5] + assert reasons[5] == "Skill-Lücke" diff --git a/backend/version.py b/backend/version.py index 233cd86..c5ced1c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.169" +APP_VERSION = "0.8.170" BUILD_DATE = "2026-05-22" -DB_SCHEMA_VERSION = "20260531071" +DB_SCHEMA_VERSION = "20260531072" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -22,13 +22,13 @@ MODULE_VERSIONS = { "admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail "ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion "ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text - "ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row + "ai_prompt_runtime": "0.2.1", # Kontext-Art planning_exercise_search; load_and_render_ai_prompt "groups": "0.1.0", "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", - "exercises": "2.35.0", # Planungs-KI P0.1: Profil-Score profile_v1 + target_profile_summary - "planning_exercise_suggest": "0.2.1", # Fix Import library_content_visibility_sql aus tenant_context + "exercises": "2.36.0", # Planungs-KI P2: LLM-Rerank + Client planned_exercise_ids + "planning_exercise_suggest": "0.3.0", # include_llm_rank, planned_exercise_ids Override "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.170", + "date": "2026-05-22", + "changes": [ + "Planungs-KI P2: optionaler LLM-Rerank (planning_exercise_search_rank) mit Titel/summary/goal; include_llm_rank.", + "Client planned_exercise_ids für ungespeicherten Plan; Migration 072 Prompt.", + ], + }, { "version": "0.8.169", "date": "2026-05-22", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index a965601..bc852cf 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -89,10 +89,10 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions -### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.168**) +### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.170**) - **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4. -- **Planungs-Übungssuche (P0.1):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Context-Pack, Hybrid-Retrieval + **Profil-Score** (`profile_v1`, `ExerciseMatchProfile` / `PlanningTargetProfile`); **`POST /api/planning/exercise-suggest`**; Frontend **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**. +- **Planungs-Übungssuche (P2):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Hybrid + Profil-Score + optional **LLM-Rerank** (`include_llm_rank`, Prompt `planning_exercise_search_rank`); Client **`planned_exercise_ids`**; **`POST /api/planning/exercise-suggest`**; **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**. - **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2 - **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter - **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`** diff --git a/frontend/src/api/planning.js b/frontend/src/api/planning.js index 750a571..f67e49b 100644 --- a/frontend/src/api/planning.js +++ b/frontend/src/api/planning.js @@ -76,7 +76,7 @@ export async function quickCreateTrainingUnit(data) { }) } -/** Planungs-KI P0: kontextgebundene Übungssuche (Hybrid-Retrieval). */ +/** Planungs-KI: kontextgebundene Übungssuche (Hybrid + Profil + optional LLM-Rerank). */ export async function suggestPlanningExercises(body = {}) { return request('/api/planning/exercise-suggest', { method: 'POST', diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index e8b0cd4..133d802 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -68,6 +68,7 @@ export default function ExercisePickerModal({ const [quickCreateDraft, setQuickCreateDraft] = useState(null) const [planningContextSummary, setPlanningContextSummary] = useState(null) const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null) + const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false) const [planningIntentResolved, setPlanningIntentResolved] = useState(null) const pickerScrollRef = useRef(null) @@ -155,6 +156,7 @@ export default function ExercisePickerModal({ setQuickCreateDraft(null) setPlanningContextSummary(null) setPlanningTargetProfileSummary(null) + setPlanningLlmRankApplied(false) setPlanningIntentResolved(null) return } @@ -271,6 +273,11 @@ export default function ExercisePickerModal({ planningContext.anchorExerciseId != null ? Number(planningContext.anchorExerciseId) : null, progression_graph_id: planningContext.progressionGraphId != null ? Number(planningContext.progressionGraphId) : null, + planned_exercise_ids: + Array.isArray(planningContext.plannedExerciseIds) && planningContext.plannedExerciseIds.length > 0 + ? planningContext.plannedExerciseIds.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0) + : undefined, + include_llm_rank: true, query, intent_hint: planningContext.intentHint || null, limit: PAGE_SIZE, @@ -279,6 +286,7 @@ export default function ExercisePickerModal({ }) setPlanningContextSummary(res?.context_summary || null) setPlanningTargetProfileSummary(res?.target_profile_summary || null) + setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied)) setPlanningIntentResolved(res?.intent_resolved || null) const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({ id: h.id, @@ -294,6 +302,7 @@ export default function ExercisePickerModal({ } else { setPlanningContextSummary(null) setPlanningTargetProfileSummary(null) + setPlanningLlmRankApplied(false) setPlanningIntentResolved(null) const batch = await api.listExercises({ ...queryBase, @@ -312,6 +321,7 @@ export default function ExercisePickerModal({ setHasMore(false) setPlanningContextSummary(null) setPlanningTargetProfileSummary(null) + setPlanningLlmRankApplied(false) setPlanningIntentResolved(null) } finally { setLoading(false) @@ -538,6 +548,7 @@ export default function ExercisePickerModal({ {planningIntentResolved ? (

Modus: {planningIntentResolved.replace(/_/g, ' ')} + {planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}

) : null} diff --git a/frontend/src/pages/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx index 974376e..dc62558 100644 --- a/frontend/src/pages/TrainingUnitEditPage.jsx +++ b/frontend/src/pages/TrainingUnitEditPage.jsx @@ -152,11 +152,23 @@ export default function TrainingUnitEditPage() { } } } + const plannedExerciseIds = [] + const seenPlan = new Set() + for (const sec of secs) { + 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 || seenPlan.has(eid)) continue + seenPlan.add(eid) + plannedExerciseIds.push(eid) + } + } return { unitId: Number(editingUnit.id), sectionOrderIndex: sIdx, anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null, progressionGraphId: null, + plannedExerciseIds, } }, [editingUnit?.id, exercisePickerTarget, formData.sections])