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
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:
parent
9d880e2346
commit
d7d45a8927
186
.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
Normal file
186
.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
Normal 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** (0–1, 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
480
backend/planning_exercise_suggest.py
Normal file
480
backend/planning_exercise_suggest.py
Normal 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,
|
||||
}
|
||||
20
backend/routers/planning_exercise_suggest.py
Normal file
20
backend/routers/planning_exercise_suggest.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 P0–P4.
|
||||
- **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`**
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user