diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md new file mode 100644 index 0000000..f0309d2 --- /dev/null +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -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. diff --git a/backend/main.py b/backend/main.py index fbe10cf..5c15d97 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py new file mode 100644 index 0000000..0e49c01 --- /dev/null +++ b/backend/planning_exercise_suggest.py @@ -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, + } diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py new file mode 100644 index 0000000..9a341d6 --- /dev/null +++ b/backend/routers/planning_exercise_suggest.py @@ -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) diff --git a/backend/version.py b/backend/version.py index 9a1a661..de47331 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 38485c8..76309f7 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -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`** diff --git a/frontend/src/api/planning.js b/frontend/src/api/planning.js index 8eb6ee1..750a571 100644 --- a/frontend/src/api/planning.js +++ b/frontend/src/api/planning.js @@ -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', { diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 5cf3859..3ee890f 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -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({
+ Modus: {planningIntentResolved.replace(/_/g, ' ')} +
+ ) : null} +- {list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''} + {usePlanningSearch ? `${list.length} KI-Vorschläge` : `${list.length} angezeigt`} + {hasMore ? ' · weiter unten „Mehr laden“' : ''}