Integrate Planning AI Features and Update Application Version to 0.8.167
Some checks failed
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Failing after 3m59s
Test Suite / playwright-tests (push) Failing after 3m41s

- Added new planning AI functionality with the introduction of the `suggestPlanningExercises` API endpoint for context-based exercise suggestions.
- Enhanced `ExercisePickerModal` to utilize planning context, allowing for a more tailored exercise selection experience.
- Updated `TrainingUnitEditPage` to pass planning context to the exercise picker, improving integration with the new planning features.
- Incremented application version to 0.8.167 and updated changelog to reflect the new planning AI capabilities and related enhancements.
This commit is contained in:
Lars 2026-05-22 21:52:18 +02:00
parent 9d880e2346
commit d7d45a8927
9 changed files with 887 additions and 21 deletions

View File

@ -0,0 +1,186 @@
# Planungs-KI: Übungssuche & Kontext für Neu-Anlage
**Version:** 0.1
**Datum:** 2026-05-22
**Status:** P0 in Umsetzung (Hybrid-Retrieval ohne LLM-Intent)
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
---
## 1. Ziel
Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können mit natürlichen Anfragen wie:
- „Vertiefung zu Übung XY“
- „Nächste sinnvolle Übung im Progressionsgraph Z“
- „Baut auf der bisherigen Planung auf — Reaktionsschnelligkeit mit Partnern“
- **Preset:** „Schlage mir die nächste Übung vor“
**Suche** (Bibliothek) und **Neu mit KI-Assistent** (Anlage) nutzen dasselbe **`PlanningExerciseContextPack`** — unterschiedliches Ergebnis (Treffer vs. Entwurf).
---
## 2. Architektur (Mehrstufig)
| Stufe | Name | Technik | P0 |
|-------|------|---------|-----|
| **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ |
| **S1a** | Intent strukturieren | Optional LLM `planning_exercise_search_intent` | Heuristik |
| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan | ✅ |
| **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` |
| **S2** | Neu-Anlage | Bestehende `suggestExerciseAi` + Pack als Zusatzkontext | Später |
Zwischen jeder Stufe: **nur erlaubte `exercise_id`s** (Governance / Sichtbarkeit).
---
## 3. Intent-Typen
| `intent_hint` | Bedeutung | Retrieval-Gewichtung (P0) |
|---------------|-----------|---------------------------|
| `suggest_next` | Nächste Übung (Default bei leerer/kurzer Query) | Progression + Skill-Overlap + Plan-Kontinuität |
| `progression_next` | Explizit Graph-Folge | Progression hoch |
| `deepen_exercise` | Vertiefung zu Anker-Übung | Skill-Overlap hoch, ähnlicher Fokus |
| `continue_plan_goal` | Auf bisherigen Plan aufbauen | Plan-Kontinuität, Wiederholungsstrafe |
| `free_search` | Freitext / Stichwort | Volltext hoch |
**S1a (später):** Freitext → JSON `{ intent, skill_hints[], requires_partner, level_hint, … }` validiert per Pydantic.
**P0:** `intent_hint` vom Client oder Keyword-Heuristik auf `query`.
---
## 4. PlanningExerciseContextPack (S0)
Serverseitig aus Request + DB (tokenbewusst für spätere LLM-Stufen):
| Feld | Quelle | UI-Chip |
|------|--------|---------|
| `unit_id`, Titel, `group_id`, Gruppenname | `training_units` + `training_groups` | Gruppe · Einheit |
| `section_order_index`, Abschnittstitel | `training_unit_sections` | Abschnitt |
| `planned_exercise_ids[]` | Items der Einheit (Reihenfolge) | „N Übungen im Plan“ |
| `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker |
| `anchor_skill_ids[]` | `exercise_skills` | (intern) |
| `progression_graph_id` | Request (optional) | Graph |
| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker | (intern) |
| `group_recent_exercise_ids[]` | Letzte Einheiten derselben Gruppe | Wiederholungsstrafe |
| `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) |
**Berechtigung:** `get_tenant_context` + `_assert_training_unit_permission` wie `GET /training-units/{id}`.
---
## 5. Hybrid-Retrieval (S1b, P0)
Kandidaten: sichtbare Übungen (`library_content_visibility_sql`), ohne `archived`, max. ~400 (recent).
**Score** (01, gewichtet nach Intent):
```
score = w_ft * fulltext_rank
+ w_prog * progression_hit
+ w_skill * skill_jaccard(anchor, candidate)
+ w_plan * plan_affinity
+ w_repeat * (candidate in unit_plan ? -1 : 0)
+ w_group_repeat * (candidate in group_recent ? -0.5 : 0)
```
**`reasons[]`** (regelbasiert, Deutsch): z. B. „Nachfolger im Progressionsgraph“, „Fähigkeiten passen zur Anker-Übung“, „Volltext-Treffer“.
---
## 6. API
### `POST /api/planning/exercise-suggest`
**Body:**
```json
{
"unit_id": 123,
"section_order_index": 0,
"phase_order_index": null,
"parallel_stream_order_index": null,
"anchor_exercise_id": 456,
"progression_graph_id": 7,
"query": "Schlage mir die nächste Übung vor",
"intent_hint": "suggest_next",
"limit": 20,
"exercise_kind_any": ["simple"]
}
```
**Response:**
```json
{
"context_summary": {
"unit_title": "…",
"group_name": "…",
"section_title": "Hauptteil",
"planned_count": 4,
"anchor_title": "Partner-Fangspiel"
},
"intent_resolved": "suggest_next",
"hits": [
{
"id": 99,
"title": "…",
"summary": "…",
"score": 0.78,
"reasons": ["Nachfolger im Progressionsgraph", "3 gemeinsame Fähigkeiten mit Anker-Übung"],
"focus_area": "…"
}
]
}
```
**Modul:** `backend/planning_exercise_suggest.py` · Router `backend/routers/planning_exercise_suggest.py`
---
## 7. Frontend
| Ort | Verhalten |
|-----|-----------|
| `ExercisePickerModal` | Prop `planningContext` → Planungs-API statt reiner `listExercises`; Kontext-Chips; `reasons` unter Treffer |
| `TrainingUnitEditPage` | `planningContext` aus Einheit + Picker-Ziel (Anker = letzte Übung im Abschnitt) |
| Rahmen / Kombi-Formular | analog, sobald `unit_id` / Slot-Blueprint bekannt |
| Übungsliste | weiter Volltext; Schalter „Neu mit KI-Assistent“ ohne Planungs-Pack |
**Zweites Suchfeld** im Picker: Query = Volltext + ergänzender Begriff (ODER in P0 als Konkatenation an Backend).
---
## 8. Neu-Anlage (Anbindung, Phase P1)
Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
- Gleiches `context_summary` an `suggestExerciseAi` anhängen (Felder `planning_context_json` o. ä. — noch offen)
- Kurzbeschreibung optional leer (freier Vorschlag) oder aus Intent/Skizze
---
## 9. Phasen-Roadmap
| Phase | Inhalt |
|-------|--------|
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
| **P1** | LLM Intent-JSON; Neu-Anlage mit Pack |
| **P2** | LLM-Rerank + Kurzbegründung |
| **P3** | Skill-Discovery / Framework-Ziele im Pack |
---
## 10. Changelog
- **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).
---
## 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[]`).
- **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API).
- **LLM-Intent / Rerank:** P1/P2 laut Roadmap §9.

View File

@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@ -210,6 +210,7 @@ app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
app.include_router(skill_profiles.router)
app.include_router(training_planning.router)
app.include_router(planning_exercise_suggest.router)
app.include_router(dashboard.router)
app.include_router(training_modules.router)
app.include_router(training_framework_programs.router)

View File

@ -0,0 +1,480 @@
"""
Planungs-KI P0: Kontext-Pack + Hybrid-Retrieval für Übungssuche in der Trainingsplanung.
Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
from fastapi import HTTPException
from pydantic import BaseModel, Field
from tenant_context import TenantContext
from club_tenancy import library_content_visibility_sql
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
from routers.training_planning import (
_assert_training_unit_permission,
_fetch_sections,
_has_planning_role,
)
INTENT_SUGGEST_NEXT = "suggest_next"
INTENT_PROGRESSION_NEXT = "progression_next"
INTENT_DEEPEN_EXERCISE = "deepen_exercise"
INTENT_CONTINUE_PLAN = "continue_plan_goal"
INTENT_FREE_SEARCH = "free_search"
VALID_INTENTS = {
INTENT_SUGGEST_NEXT,
INTENT_PROGRESSION_NEXT,
INTENT_DEEPEN_EXERCISE,
INTENT_CONTINUE_PLAN,
INTENT_FREE_SEARCH,
}
_CANDIDATE_POOL_LIMIT = 400
class PlanningExerciseSuggestRequest(BaseModel):
unit_id: int = Field(..., 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)
anchor_exercise_id: Optional[int] = Field(default=None, ge=1)
progression_graph_id: Optional[int] = Field(default=None, ge=1)
query: Optional[str] = ""
intent_hint: Optional[str] = None
limit: int = Field(default=20, ge=1, le=50)
exercise_kind_any: Optional[List[str]] = None
def resolve_planning_exercise_intent(query: Optional[str], intent_hint: Optional[str]) -> str:
hint = (intent_hint or "").strip().lower()
if hint in VALID_INTENTS:
return hint
q = (query or "").strip().lower()
if not q:
return INTENT_SUGGEST_NEXT
if any(w in q for w in ("nächste", "naechste", "vorschlag", "vorschlagen", "empfehl")):
return INTENT_SUGGEST_NEXT
if "vertief" in q:
return INTENT_DEEPEN_EXERCISE
if "progression" in q or "graph" in q or "pfad" in q:
return INTENT_PROGRESSION_NEXT
if "aufbau" in q or "planung" in q or "bisher" in q:
return INTENT_CONTINUE_PLAN
return INTENT_FREE_SEARCH
def _intent_weights(intent: str) -> Dict[str, float]:
base = {
"fulltext": 0.25,
"progression": 0.25,
"skill": 0.20,
"plan": 0.10,
"repeat_unit": -0.30,
"repeat_group": -0.15,
}
if intent == INTENT_SUGGEST_NEXT:
return {**base, "progression": 0.35, "skill": 0.25, "plan": 0.15, "fulltext": 0.10}
if intent == INTENT_PROGRESSION_NEXT:
return {**base, "progression": 0.50, "fulltext": 0.15, "skill": 0.15}
if intent == INTENT_DEEPEN_EXERCISE:
return {**base, "skill": 0.40, "fulltext": 0.20, "progression": 0.15}
if intent == INTENT_CONTINUE_PLAN:
return {**base, "plan": 0.30, "skill": 0.25, "fulltext": 0.15, "progression": 0.10}
if intent == INTENT_FREE_SEARCH:
return {**base, "fulltext": 0.55, "progression": 0.10, "skill": 0.10}
return base
def _collect_planned_exercise_ids(sections: Sequence[Dict[str, Any]]) -> List[int]:
out: List[int] = []
seen: Set[int] = set()
for sec in sorted(sections, key=lambda s: int(s.get("order_index") or 0)):
items = sec.get("items") or []
for it in sorted(items, 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_anchor_from_plan(
planned_ids: Sequence[int],
anchor_exercise_id: Optional[int],
) -> Optional[int]:
if anchor_exercise_id and int(anchor_exercise_id) > 0:
return int(anchor_exercise_id)
if planned_ids:
return int(planned_ids[-1])
return None
def _load_exercise_titles(cur, exercise_ids: Sequence[int]) -> Dict[int, str]:
if not exercise_ids:
return {}
ids = list(dict.fromkeys(int(x) for x in exercise_ids if int(x) > 0))
ph = ",".join(["%s"] * len(ids))
cur.execute(
f"SELECT id, title FROM exercises WHERE id IN ({ph})",
ids,
)
return {int(r["id"]): str(r["title"] or "").strip() for r in cur.fetchall()}
def _load_skill_ids_for_exercise(cur, exercise_id: Optional[int]) -> Set[int]:
if not exercise_id:
return set()
cur.execute(
"SELECT skill_id FROM exercise_skills WHERE exercise_id = %s",
(int(exercise_id),),
)
return {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")}
def _load_progression_successors(
cur,
graph_id: Optional[int],
from_exercise_id: Optional[int],
) -> Tuple[Set[int], Dict[int, str]]:
if not graph_id or not from_exercise_id:
return set(), {}
cur.execute(
"""
SELECT to_exercise_id, notes
FROM exercise_progression_edges
WHERE graph_id = %s AND from_exercise_id = %s
AND LOWER(TRIM(edge_type)) = 'next_exercise'
""",
(int(graph_id), int(from_exercise_id)),
)
ids: Set[int] = set()
notes: Dict[int, str] = {}
for row in cur.fetchall():
tid = int(row["to_exercise_id"])
ids.add(tid)
n = (row.get("notes") or "").strip()
if n:
notes[tid] = n
return ids, notes
def _load_group_recent_exercise_ids(
cur,
group_id: Optional[int],
exclude_unit_id: int,
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)),
)
out: Set[int] = set()
for r in cur.fetchall():
if r.get("eid") is None:
continue
out.add(int(r["eid"]))
if len(out) >= limit:
break
return out
def _section_title_for_index(sections: Sequence[Dict[str, Any]], section_order_index: Optional[int]) -> Optional[str]:
if section_order_index is None:
return None
for sec in sections:
if int(sec.get("order_index") or -1) == int(section_order_index):
t = (sec.get("title") or "").strip()
return t or None
return None
def _normalize_query(query: Optional[str]) -> str:
return re.sub(r"\s+", " ", (query or "").strip())
def _skill_jaccard(a: Set[int], b: Set[int]) -> float:
if not a or not b:
return 0.0
inter = len(a & b)
union = len(a | b)
return inter / union if union else 0.0
def build_planning_exercise_context_pack(
cur,
*,
tenant: TenantContext,
body: PlanningExerciseSuggestRequest,
) -> Dict[str, Any]:
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Planungs-Vorschläge abrufen")
cur.execute(
"""
SELECT tu.*, tg.name AS group_name
FROM training_units tu
LEFT JOIN training_groups tg ON tg.id = tu.group_id
WHERE tu.id = %s
""",
(body.unit_id,),
)
unit_row = cur.fetchone()
if not unit_row:
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
unit = dict(unit_row)
if unit.get("framework_slot_id"):
if role not in ("admin", "superadmin"):
cur.execute(
"""
SELECT fp.created_by FROM training_framework_slots s
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
WHERE s.id = %s
""",
(unit["framework_slot_id"],),
)
fr = cur.fetchone()
cb = fr["created_by"] if fr else None
if unit.get("created_by") != profile_id and cb != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung")
else:
if not unit.get("group_id"):
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
_assert_training_unit_permission(cur, unit, profile_id, role)
sections = _fetch_sections(cur, int(body.unit_id))
planned_ids = _collect_planned_exercise_ids(sections)
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_recent = _load_group_recent_exercise_ids(cur, unit.get("group_id"), int(body.unit_id))
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": int(body.unit_id),
"unit_title": (unit.get("title") or unit.get("planned_focus") or "").strip() or None,
"group_id": unit.get("group_id"),
"group_name": (unit.get("group_name") or "").strip() or None,
"section_order_index": body.section_order_index,
"section_title": _section_title_for_index(sections, body.section_order_index),
"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),
}
def suggest_planning_exercises(
cur,
*,
tenant: TenantContext,
body: PlanningExerciseSuggestRequest,
) -> Dict[str, Any]:
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
query = _normalize_query(body.query)
intent = resolve_planning_exercise_intent(query, body.intent_hint)
weights = _intent_weights(intent)
profile_id = tenant.profile_id
role = tenant.global_role
vis_sql, vis_params = library_content_visibility_sql(
alias="e",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
where = [vis_sql, "COALESCE(e.status, '') <> %s"]
params: List[Any] = []
if query:
ft_select = "ts_rank_cd(e.search_vector, plainto_tsquery('german', %s)) AS ft_rank"
params.append(query)
else:
ft_select = "0.0::float AS ft_rank"
params.extend(list(vis_params))
params.append("archived")
ek_filtered: List[str] = []
if body.exercise_kind_any:
for raw in body.exercise_kind_any:
s = str(raw or "").strip().lower()
if s in ("simple", "combination") and s not in ek_filtered:
ek_filtered.append(s)
if ek_filtered:
ph = ",".join(["%s"] * len(ek_filtered))
where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))")
params.extend(ek_filtered)
sql = f"""
SELECT e.id, e.title, e.summary,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS primary_focus_name,
{ft_select}
FROM exercises e
WHERE {' AND '.join(where)}
ORDER BY e.updated_at DESC, e.id DESC
LIMIT %s
"""
params.append(_CANDIDATE_POOL_LIMIT)
cur.execute(sql, params)
rows = cur.fetchall()
planned_set = set(pack["planned_exercise_ids"])
group_recent_set = set(pack["group_recent_exercise_ids"])
progression_set = set(pack["progression_successor_ids"])
anchor_skills = set(pack["anchor_skill_ids"])
anchor_id = pack.get("anchor_exercise_id")
progression_notes = pack.get("progression_edge_notes") or {}
last_planned_skills: Set[int] = set()
if pack["planned_exercise_ids"]:
last_planned_skills = _load_skill_ids_for_exercise(cur, pack["planned_exercise_ids"][-1])
# Skill-IDs pro Kandidat (Batch)
cand_ids = [int(r["id"]) for r in rows]
skills_by_ex: Dict[int, Set[int]] = {cid: set() for cid in cand_ids}
if cand_ids:
ph = ",".join(["%s"] * len(cand_ids))
cur.execute(
f"SELECT exercise_id, skill_id FROM exercise_skills WHERE exercise_id IN ({ph})",
cand_ids,
)
for r in cur.fetchall():
skills_by_ex.setdefault(int(r["exercise_id"]), set()).add(int(r["skill_id"]))
max_ft = 0.0
scored: List[Dict[str, Any]] = []
for row in rows:
eid = int(row["id"])
if anchor_id and eid == int(anchor_id):
continue
ft = float(row.get("ft_rank") or 0.0)
if ft > max_ft:
max_ft = ft
scored.append(
{
"row": row,
"eid": eid,
"ft": ft,
"skills": skills_by_ex.get(eid, set()),
}
)
hits: List[Dict[str, Any]] = []
for item in scored:
eid = item["eid"]
row = item["row"]
ft_norm = (item["ft"] / max_ft) if max_ft > 0 else 0.0
prog_hit = 1.0 if eid in progression_set else 0.0
skill_sim = _skill_jaccard(anchor_skills, item["skills"]) if anchor_skills else 0.0
plan_aff = 0.0
if last_planned_skills and item["skills"]:
plan_aff = _skill_jaccard(last_planned_skills, item["skills"])
repeat_unit = 1.0 if eid in planned_set else 0.0
repeat_group = 1.0 if eid in group_recent_set else 0.0
score = (
weights["fulltext"] * ft_norm
+ weights["progression"] * prog_hit
+ weights["skill"] * skill_sim
+ weights["plan"] * plan_aff
+ weights["repeat_unit"] * repeat_unit
+ weights["repeat_group"] * repeat_group
)
reasons: List[str] = []
if query and ft_norm >= 0.35:
reasons.append("Volltext-Treffer")
if prog_hit > 0:
note = progression_notes.get(eid)
reasons.append(
f"Nachfolger im Progressionsgraph{f': {note}' if note else ''}"
)
if skill_sim >= 0.2 and anchor_id:
reasons.append("Fähigkeiten passen zur Anker-Übung")
if plan_aff >= 0.25:
reasons.append("Schließt an Skills der letzten geplanten Übung an")
if repeat_unit > 0:
reasons.append("Bereits in dieser Einheit eingeplant")
if repeat_group > 0 and repeat_unit <= 0:
reasons.append("Kürzlich in der Gruppe verwendet")
if score <= 0 and not reasons and not query:
# Leere Query: trotzdem schwache Kandidaten mit Skill/Progression
if prog_hit or skill_sim or plan_aff:
score = 0.05 + prog_hit * 0.3 + skill_sim * 0.2
hits.append(
{
"id": eid,
"title": row.get("title"),
"summary": row.get("summary"),
"focus_area": row.get("primary_focus_name"),
"score": round(max(0.0, min(1.0, score)), 4),
"reasons": reasons,
}
)
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
hits = hits[: int(body.limit)]
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"),
"anchor_exercise_id": pack.get("anchor_exercise_id"),
"progression_graph_id": pack.get("progression_graph_id"),
}
return {
"context_summary": context_summary,
"intent_resolved": intent,
"query_normalized": query or None,
"hits": hits,
}

View File

@ -0,0 +1,20 @@
"""
POST /api/planning/exercise-suggest planungsgebundene Übungssuche (P0 Hybrid-Retrieval).
"""
from fastapi import APIRouter, Depends
from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
@router.post("/exercise-suggest")
def post_planning_exercise_suggest(
body: PlanningExerciseSuggestRequest,
tenant: TenantContext = Depends(get_tenant_context),
):
with get_db() as conn:
cur = get_cursor(conn)
return suggest_planning_exercises(cur, tenant=tenant, body=body)

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.166"
APP_VERSION = "0.8.167"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260531071"
@ -27,7 +27,8 @@ MODULE_VERSIONS = {
"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.33.0", # KI Schnellanlage: Suche+Anlage kombiniert; Rich-Text-Editor; Übungsliste KI-Schalter
"exercises": "2.34.0", # Planungs-KI P0: POST /planning/exercise-suggest; Picker Kontext
"planning_exercise_suggest": "0.1.0", # Kontext-Pack + Hybrid-Retrieval Übungssuche
"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
@ -42,6 +43,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.167",
"date": "2026-05-22",
"changes": [
"Planungs-KI P0: POST /api/planning/exercise-suggest — Kontext-Pack (Einheit, Plan, Anker, Graph) + Hybrid-Score.",
"ExercisePickerModal: Planungskontext aus TrainingUnitEditPage; Treffer mit Begründungen; Doku PLANNING_EXERCISE_SUGGEST_CONTEXT.md.",
],
},
{
"version": "0.8.166",
"date": "2026-05-22",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-31
**App-Version / DB-Schema:** App **`0.8.166`** (KI Schnellanlage Suche+Anlage); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
**App-Version / DB-Schema:** App **`0.8.167`** (Planungs-KI Übungssuche P0); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -89,9 +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.166**)
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.167**)
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0P4.
- **Planungs-Übungssuche (P0):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Intent-Typen, Context-Pack, Hybrid-Retrieval; **`POST /api/planning/exercise-suggest`**; Frontend **`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`**

View File

@ -76,6 +76,14 @@ export async function quickCreateTrainingUnit(data) {
})
}
/** Planungs-KI P0: kontextgebundene Übungssuche (Hybrid-Retrieval). */
export async function suggestPlanningExercises(body = {}) {
return request('/api/planning/exercise-suggest', {
method: 'POST',
body: JSON.stringify(body),
})
}
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
export async function createTrainingUnitFromFrameworkSlot(data) {
return request('/api/training-units/from-framework-slot', {

View File

@ -38,6 +38,8 @@ export default function ExercisePickerModal({
multiSelect = false,
onSelectExercises = null,
enableQuickCreateDraft = false,
/** Planungs-Kontext für KI-Suche (TrainingUnitEditPage o. ä.) */
planningContext = null,
/** Wenn gesetzt: z. B. ['simple'] oder ['combination'] — sonst alle Übungsarten */
exerciseKindAny = undefined,
}) {
@ -64,8 +66,12 @@ export default function ExercisePickerModal({
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const [planningContextSummary, setPlanningContextSummary] = useState(null)
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
const pickerScrollRef = useRef(null)
const usePlanningSearch = Boolean(planningContext?.unitId && Number(planningContext.unitId) > 0)
const {
title: quickTitle,
sketch: quickSketch,
@ -96,8 +102,8 @@ export default function ExercisePickerModal({
enableQuickCreateDraft &&
catalogsReady &&
!loading &&
debouncedSearch.length >= 3 &&
list.length === 0
list.length === 0 &&
(usePlanningSearch || debouncedSearch.length >= 3)
useEffect(() => {
if (!open) return
@ -146,6 +152,8 @@ export default function ExercisePickerModal({
setQuickSaving(false)
setQuickAiError('')
setQuickCreateDraft(null)
setPlanningContextSummary(null)
setPlanningIntentResolved(null)
return
}
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
@ -245,24 +253,74 @@ export default function ExercisePickerModal({
if (!open || !catalogsReady) return
setLoading(true)
try {
const batch = await api.listExercises({
...queryBase,
include_archived: true,
include_variants: true,
limit: PAGE_SIZE,
offset: 0,
})
setList(Array.isArray(batch) ? batch : [])
setHasMore(batch?.length === PAGE_SIZE)
if (usePlanningSearch) {
const query = [debouncedSearch, debouncedAi].filter(Boolean).join(' ').trim()
const res = await api.suggestPlanningExercises({
unit_id: Number(planningContext.unitId),
section_order_index:
planningContext.sectionOrderIndex != null ? Number(planningContext.sectionOrderIndex) : null,
phase_order_index:
planningContext.phaseOrderIndex != null ? Number(planningContext.phaseOrderIndex) : null,
parallel_stream_order_index:
planningContext.parallelStreamOrderIndex != null
? Number(planningContext.parallelStreamOrderIndex)
: null,
anchor_exercise_id:
planningContext.anchorExerciseId != null ? Number(planningContext.anchorExerciseId) : null,
progression_graph_id:
planningContext.progressionGraphId != null ? Number(planningContext.progressionGraphId) : null,
query,
intent_hint: planningContext.intentHint || null,
limit: PAGE_SIZE,
exercise_kind_any:
Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined,
})
setPlanningContextSummary(res?.context_summary || null)
setPlanningIntentResolved(res?.intent_resolved || null)
const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({
id: h.id,
title: h.title,
summary: h.summary,
focus_area: h.focus_area,
_planningScore: h.score,
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
updated_at: new Date().toISOString(),
}))
setList(hits)
setHasMore(false)
} else {
setPlanningContextSummary(null)
setPlanningIntentResolved(null)
const batch = await api.listExercises({
...queryBase,
include_archived: true,
include_variants: true,
limit: PAGE_SIZE,
offset: 0,
})
setList(Array.isArray(batch) ? batch : [])
setHasMore(batch?.length === PAGE_SIZE)
}
} catch (e) {
console.error(e)
alert(e.message || 'Laden fehlgeschlagen')
setList([])
setHasMore(false)
setPlanningContextSummary(null)
setPlanningIntentResolved(null)
} finally {
setLoading(false)
}
}, [open, catalogsReady, queryBase])
}, [
open,
catalogsReady,
queryBase,
usePlanningSearch,
planningContext,
debouncedSearch,
debouncedAi,
exerciseKindAny,
])
useEffect(() => {
reload()
@ -298,7 +356,11 @@ export default function ExercisePickerModal({
const rowVirtualizer = useVirtualizer({
count: list.length,
getScrollElement: () => pickerScrollRef.current,
estimateSize: () => 88,
estimateSize: (index) => {
const ex = list[index]
const rc = ex?._planningReasons?.length || 0
return rc > 0 ? 96 + Math.min(rc, 3) * 14 : 88
},
overscan: 8,
getItemKey: (index) => String(list[index]?.id ?? index),
})
@ -414,13 +476,59 @@ export default function ExercisePickerModal({
</div>
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
{usePlanningSearch && planningContextSummary ? (
<div
style={{
marginBottom: '10px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text2)',
lineHeight: 1.45,
}}
>
<strong style={{ color: 'var(--text1)', fontSize: '13px' }}>Planungskontext</strong>
<div style={{ marginTop: '4px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{planningContextSummary.group_name ? (
<span className="exercise-tag">{planningContextSummary.group_name}</span>
) : null}
{planningContextSummary.unit_title ? (
<span className="exercise-tag">{planningContextSummary.unit_title}</span>
) : null}
{planningContextSummary.section_title ? (
<span className="exercise-tag">{planningContextSummary.section_title}</span>
) : null}
{planningContextSummary.planned_count != null ? (
<span className="exercise-tag">{planningContextSummary.planned_count} Übungen im Plan</span>
) : null}
{planningContextSummary.anchor_title ? (
<span className="exercise-tag exercise-tag--accent">
Anker: {planningContextSummary.anchor_title}
</span>
) : null}
</div>
{planningIntentResolved ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Modus: {planningIntentResolved.replace(/_/g, ' ')}
</p>
) : null}
</div>
) : null}
<div style={{ display: 'grid', gap: '0.65rem' }}>
<div>
<label className="form-label">Volltextsuche</label>
<label className="form-label">
{usePlanningSearch ? 'Planungs-Suche' : 'Volltextsuche'}
</label>
<input
type="search"
className="form-input"
placeholder="Stichwort, Titelfragment…"
placeholder={
usePlanningSearch
? 'z. B. nächste Übung, Vertiefung, Reaktion mit Partner …'
: 'Stichwort, Titelfragment…'
}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
autoComplete="off"
@ -618,7 +726,8 @@ export default function ExercisePickerModal({
) : (
<>
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
{usePlanningSearch ? `${list.length} KI-Vorschläge` : `${list.length} angezeigt`}
{hasMore ? ' · weiter unten „Mehr laden“' : ''}
</p>
<div
role="list"
@ -661,6 +770,20 @@ export default function ExercisePickerModal({
Kombination
</span>
) : null}
{Array.isArray(ex._planningReasons) && ex._planningReasons.length > 0 ? (
<ul
style={{
margin: '6px 0 0',
paddingLeft: '16px',
fontSize: '11px',
color: 'var(--accent-dark)',
}}
>
{ex._planningReasons.slice(0, 3).map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
</>
)
return (

View File

@ -123,6 +123,43 @@ export default function TrainingUnitEditPage() {
const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
const exercisePickerPlanningContext = useMemo(() => {
if (!editingUnit?.id) return null
const target = exercisePickerTarget
const secs = formData.sections || []
const sIdx = target?.sIdx ?? 0
const sec = secs[sIdx]
let anchorExerciseId = null
if (sec?.items?.length) {
if (typeof target?.iIdx === 'number') {
const item = sec.items[target.iIdx]
if (item?.exercise_id) {
anchorExerciseId = Number(item.exercise_id)
} else {
for (let i = target.iIdx - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id)
break
}
}
}
} else {
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id)
break
}
}
}
}
return {
unitId: Number(editingUnit.id),
sectionOrderIndex: sIdx,
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
progressionGraphId: null,
}
}, [editingUnit?.id, exercisePickerTarget, formData.sections])
const goBack = useCallback(() => {
goNavReturn(navigate, location, {
path: PLANNING_HUB_PATH,
@ -752,6 +789,7 @@ export default function TrainingUnitEditPage() {
open={exercisePickerOpen}
multiSelect
enableQuickCreateDraft
planningContext={exercisePickerPlanningContext}
onClose={() => {
setExercisePickerOpen(false)
setExercisePickerTarget(null)