Enhance Planning Exercise Suggestion with LLM-Rerank and Client Overrides
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 47s
Test Suite / playwright-tests (push) Successful in 1m14s
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 47s
Test Suite / playwright-tests (push) Successful in 1m14s
- 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.
This commit is contained in:
parent
128a9d752e
commit
207817376d
|
|
@ -178,8 +178,8 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
|
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
|
||||||
| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` |
|
| **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 |
|
| **P1** | LLM Intent-JSON; Neu-Anlage mit Pack |
|
||||||
| **P2** | LLM-Rerank + Kurzbegründung |
|
|
||||||
| **P3** | Skill-Discovery / Framework-Ziele im Pack |
|
| **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:** 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 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:** 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
|
## 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).
|
- **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 |
|
| `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank |
|
||||||
| `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) |
|
| `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`.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ from typing import Any, Dict, Mapping, Optional, Tuple
|
||||||
|
|
||||||
from prompt_resolver import MustacheRenderResult, render_mustache_template
|
from prompt_resolver import MustacheRenderResult, render_mustache_template
|
||||||
|
|
||||||
|
_PLANNING_AI_SLUGS = frozenset(
|
||||||
|
{
|
||||||
|
"planning_exercise_search_rank",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
_EXERCISE_AI_SLUGS = frozenset(
|
_EXERCISE_AI_SLUGS = frozenset(
|
||||||
{
|
{
|
||||||
"exercise_summary",
|
"exercise_summary",
|
||||||
|
|
@ -26,12 +32,15 @@ class AiPromptContextKind(str, Enum):
|
||||||
ohne bestehende Slugs zu invalidieren.
|
ohne bestehende Slugs zu invalidieren.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
PLANNING_EXERCISE_SEARCH = "planning_exercise_search"
|
||||||
EXERCISE_FORM_AI = "exercise_form_ai"
|
EXERCISE_FORM_AI = "exercise_form_ai"
|
||||||
|
|
||||||
|
|
||||||
def context_kind_for_slug(slug: str) -> Optional[AiPromptContextKind]:
|
def context_kind_for_slug(slug: str) -> Optional[AiPromptContextKind]:
|
||||||
"""Ordnet einen DB-Slug einer Kontext-Art zu, sofern registriert."""
|
"""Ordnet einen DB-Slug einer Kontext-Art zu, sofern registriert."""
|
||||||
s = (slug or "").strip().lower()
|
s = (slug or "").strip().lower()
|
||||||
|
if s in _PLANNING_AI_SLUGS:
|
||||||
|
return AiPromptContextKind.PLANNING_EXERCISE_SEARCH
|
||||||
if s in _EXERCISE_AI_SLUGS:
|
if s in _EXERCISE_AI_SLUGS:
|
||||||
return AiPromptContextKind.EXERCISE_FORM_AI
|
return AiPromptContextKind.EXERCISE_FORM_AI
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -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) = '');
|
||||||
223
backend/planning_exercise_llm_rank.py
Normal file
223
backend/planning_exercise_llm_rank.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -17,6 +17,7 @@ from planning_exercise_profiles import (
|
||||||
load_exercise_match_profiles_bulk,
|
load_exercise_match_profiles_bulk,
|
||||||
score_exercise_against_target,
|
score_exercise_against_target,
|
||||||
)
|
)
|
||||||
|
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
|
||||||
|
|
||||||
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
|
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
|
||||||
from routers.training_planning import (
|
from routers.training_planning import (
|
||||||
|
|
@ -40,6 +41,7 @@ VALID_INTENTS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
_CANDIDATE_POOL_LIMIT = 400
|
_CANDIDATE_POOL_LIMIT = 400
|
||||||
|
_LLM_RERANK_PRE_LIMIT = 32
|
||||||
|
|
||||||
|
|
||||||
class PlanningExerciseSuggestRequest(BaseModel):
|
class PlanningExerciseSuggestRequest(BaseModel):
|
||||||
|
|
@ -51,6 +53,8 @@ class PlanningExerciseSuggestRequest(BaseModel):
|
||||||
progression_graph_id: Optional[int] = Field(default=None, ge=1)
|
progression_graph_id: Optional[int] = Field(default=None, ge=1)
|
||||||
query: Optional[str] = ""
|
query: Optional[str] = ""
|
||||||
intent_hint: Optional[str] = None
|
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)
|
limit: int = Field(default=20, ge=1, le=50)
|
||||||
exercise_kind_any: Optional[List[str]] = None
|
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
|
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(
|
def build_planning_exercise_context_pack(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
|
|
@ -327,6 +367,7 @@ def suggest_planning_exercises(
|
||||||
body: PlanningExerciseSuggestRequest,
|
body: PlanningExerciseSuggestRequest,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
|
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
|
||||||
|
pack = _apply_client_planned_override(cur, pack, body)
|
||||||
query = _normalize_query(body.query)
|
query = _normalize_query(body.query)
|
||||||
intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
||||||
weights = _intent_weights(intent)
|
weights = _intent_weights(intent)
|
||||||
|
|
@ -497,6 +538,38 @@ def suggest_planning_exercises(
|
||||||
)
|
)
|
||||||
|
|
||||||
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
|
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)]
|
hits = hits[: int(body.limit)]
|
||||||
|
|
||||||
context_summary = {
|
context_summary = {
|
||||||
|
|
@ -512,7 +585,8 @@ def suggest_planning_exercises(
|
||||||
return {
|
return {
|
||||||
"context_summary": context_summary,
|
"context_summary": context_summary,
|
||||||
"target_profile_summary": target_profile_summary,
|
"target_profile_summary": target_profile_summary,
|
||||||
"retrieval_phase": "profile_v1",
|
"retrieval_phase": retrieval_phase,
|
||||||
|
"llm_rank_applied": llm_applied,
|
||||||
"intent_resolved": intent,
|
"intent_resolved": intent,
|
||||||
"query_normalized": query or None,
|
"query_normalized": query or None,
|
||||||
"hits": hits,
|
"hits": hits,
|
||||||
|
|
|
||||||
|
|
@ -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
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
|
|
||||||
34
backend/tests/test_planning_exercise_suggest.py
Normal file
34
backend/tests/test_planning_exercise_suggest.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.169"
|
APP_VERSION = "0.8.170"
|
||||||
BUILD_DATE = "2026-05-22"
|
BUILD_DATE = "2026-05-22"
|
||||||
DB_SCHEMA_VERSION = "20260531071"
|
DB_SCHEMA_VERSION = "20260531072"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"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
|
"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_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_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",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"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
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.35.0", # Planungs-KI P0.1: Profil-Score profile_v1 + target_profile_summary
|
"exercises": "2.36.0", # Planungs-KI P2: LLM-Rerank + Client planned_exercise_ids
|
||||||
"planning_exercise_suggest": "0.2.1", # Fix Import library_content_visibility_sql aus tenant_context
|
"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_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.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",
|
"version": "0.8.169",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|
|
||||||
|
|
@ -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`)
|
- **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
|
- **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.
|
- **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
|
- **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
|
- **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`**
|
- **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
|
||||||
|
|
|
||||||
|
|
@ -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 = {}) {
|
export async function suggestPlanningExercises(body = {}) {
|
||||||
return request('/api/planning/exercise-suggest', {
|
return request('/api/planning/exercise-suggest', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export default function ExercisePickerModal({
|
||||||
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||||
const [planningContextSummary, setPlanningContextSummary] = useState(null)
|
const [planningContextSummary, setPlanningContextSummary] = useState(null)
|
||||||
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
|
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
|
||||||
|
const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false)
|
||||||
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
|
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
|
||||||
const pickerScrollRef = useRef(null)
|
const pickerScrollRef = useRef(null)
|
||||||
|
|
||||||
|
|
@ -155,6 +156,7 @@ export default function ExercisePickerModal({
|
||||||
setQuickCreateDraft(null)
|
setQuickCreateDraft(null)
|
||||||
setPlanningContextSummary(null)
|
setPlanningContextSummary(null)
|
||||||
setPlanningTargetProfileSummary(null)
|
setPlanningTargetProfileSummary(null)
|
||||||
|
setPlanningLlmRankApplied(false)
|
||||||
setPlanningIntentResolved(null)
|
setPlanningIntentResolved(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -271,6 +273,11 @@ export default function ExercisePickerModal({
|
||||||
planningContext.anchorExerciseId != null ? Number(planningContext.anchorExerciseId) : null,
|
planningContext.anchorExerciseId != null ? Number(planningContext.anchorExerciseId) : null,
|
||||||
progression_graph_id:
|
progression_graph_id:
|
||||||
planningContext.progressionGraphId != null ? Number(planningContext.progressionGraphId) : null,
|
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,
|
query,
|
||||||
intent_hint: planningContext.intentHint || null,
|
intent_hint: planningContext.intentHint || null,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
|
|
@ -279,6 +286,7 @@ export default function ExercisePickerModal({
|
||||||
})
|
})
|
||||||
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))
|
||||||
setPlanningIntentResolved(res?.intent_resolved || null)
|
setPlanningIntentResolved(res?.intent_resolved || null)
|
||||||
const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({
|
const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({
|
||||||
id: h.id,
|
id: h.id,
|
||||||
|
|
@ -294,6 +302,7 @@ export default function ExercisePickerModal({
|
||||||
} else {
|
} else {
|
||||||
setPlanningContextSummary(null)
|
setPlanningContextSummary(null)
|
||||||
setPlanningTargetProfileSummary(null)
|
setPlanningTargetProfileSummary(null)
|
||||||
|
setPlanningLlmRankApplied(false)
|
||||||
setPlanningIntentResolved(null)
|
setPlanningIntentResolved(null)
|
||||||
const batch = await api.listExercises({
|
const batch = await api.listExercises({
|
||||||
...queryBase,
|
...queryBase,
|
||||||
|
|
@ -312,6 +321,7 @@ export default function ExercisePickerModal({
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
setPlanningContextSummary(null)
|
setPlanningContextSummary(null)
|
||||||
setPlanningTargetProfileSummary(null)
|
setPlanningTargetProfileSummary(null)
|
||||||
|
setPlanningLlmRankApplied(false)
|
||||||
setPlanningIntentResolved(null)
|
setPlanningIntentResolved(null)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
@ -538,6 +548,7 @@ export default function ExercisePickerModal({
|
||||||
{planningIntentResolved ? (
|
{planningIntentResolved ? (
|
||||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||||
Modus: {planningIntentResolved.replace(/_/g, ' ')}
|
Modus: {planningIntentResolved.replace(/_/g, ' ')}
|
||||||
|
{planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
return {
|
||||||
unitId: Number(editingUnit.id),
|
unitId: Number(editingUnit.id),
|
||||||
sectionOrderIndex: sIdx,
|
sectionOrderIndex: sIdx,
|
||||||
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||||
progressionGraphId: null,
|
progressionGraphId: null,
|
||||||
|
plannedExerciseIds,
|
||||||
}
|
}
|
||||||
}, [editingUnit?.id, exercisePickerTarget, formData.sections])
|
}, [editingUnit?.id, exercisePickerTarget, formData.sections])
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user