From d7d45a89273fb21d20ddded4f5d273ca3a951ec4 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 21:52:18 +0200 Subject: [PATCH 01/16] Integrate Planning AI Features and Update Application Version to 0.8.167 - 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. --- .../PLANNING_EXERCISE_SUGGEST_CONTEXT.md | 186 +++++++ backend/main.py | 3 +- backend/planning_exercise_suggest.py | 480 ++++++++++++++++++ backend/routers/planning_exercise_suggest.py | 20 + backend/version.py | 13 +- docs/HANDOVER.md | 5 +- frontend/src/api/planning.js | 8 + .../src/components/ExercisePickerModal.jsx | 155 +++++- frontend/src/pages/TrainingUnitEditPage.jsx | 38 ++ 9 files changed, 887 insertions(+), 21 deletions(-) create mode 100644 .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md create mode 100644 backend/planning_exercise_suggest.py create mode 100644 backend/routers/planning_exercise_suggest.py 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({
+ {usePlanningSearch && planningContextSummary ? ( +
+ Planungskontext +
+ {planningContextSummary.group_name ? ( + {planningContextSummary.group_name} + ) : null} + {planningContextSummary.unit_title ? ( + {planningContextSummary.unit_title} + ) : null} + {planningContextSummary.section_title ? ( + {planningContextSummary.section_title} + ) : null} + {planningContextSummary.planned_count != null ? ( + {planningContextSummary.planned_count} Übungen im Plan + ) : null} + {planningContextSummary.anchor_title ? ( + + Anker: {planningContextSummary.anchor_title} + + ) : null} +
+ {planningIntentResolved ? ( +

+ Modus: {planningIntentResolved.replace(/_/g, ' ')} +

+ ) : null} +
+ ) : null}
- + setSearchInput(e.target.value)} autoComplete="off" @@ -618,7 +726,8 @@ export default function ExercisePickerModal({ ) : ( <>

- {list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''} + {usePlanningSearch ? `${list.length} KI-Vorschläge` : `${list.length} angezeigt`} + {hasMore ? ' · weiter unten „Mehr laden“' : ''}

) : null} + {Array.isArray(ex._planningReasons) && ex._planningReasons.length > 0 ? ( +
    + {ex._planningReasons.slice(0, 3).map((r) => ( +
  • {r}
  • + ))} +
+ ) : null} ) return ( diff --git a/frontend/src/pages/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx index c277632..974376e 100644 --- a/frontend/src/pages/TrainingUnitEditPage.jsx +++ b/frontend/src/pages/TrainingUnitEditPage.jsx @@ -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) -- 2.43.0 From 128a9d752ed3a5b8026e9f48adffe78a21d06f39 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 22:04:34 +0200 Subject: [PATCH 02/16] Enhance Planning Exercise Suggestion Features and Update Application Version to 0.8.169 - Implemented Phase 1.1 of the planning exercise suggestion functionality, integrating `ExerciseMatchProfile` and `PlanningTargetProfile` for improved exercise scoring based on profile dimensions. - Updated the `suggestPlanningExercises` API to include a new `retrieval_phase` and `target_profile_summary`, enhancing the context provided to the frontend. - Enhanced the `ExercisePickerModal` to display additional information from the planning target profile, including focus areas and top skills, improving user experience during exercise selection. - Incremented application version to 0.8.169 and updated changelog to reflect the new features and improvements in the planning AI capabilities. --- .../PLANNING_EXERCISE_SUGGEST_CONTEXT.md | 104 +++- backend/planning_exercise_profiles.py | 448 ++++++++++++++++++ backend/planning_exercise_suggest.py | 67 ++- backend/version.py | 21 +- docs/HANDOVER.md | 4 +- .../src/components/ExercisePickerModal.jsx | 26 + 6 files changed, 646 insertions(+), 24 deletions(-) create mode 100644 backend/planning_exercise_profiles.py diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index f0309d2..5d337d1 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -2,7 +2,7 @@ **Version:** 0.1 **Datum:** 2026-05-22 -**Status:** P0 in Umsetzung (Hybrid-Retrieval ohne LLM-Intent) +**Status:** P0.1 — Hybrid-Retrieval + Phase-1-Profil-Score (`profile_v1`); LLM-Rerank P2 **Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph) --- @@ -26,7 +26,8 @@ Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können |-------|------|---------|-----| | **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ | | **S1a** | Intent strukturieren | Optional LLM `planning_exercise_search_intent` | Heuristik | -| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan | ✅ | +| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan + **Profil** | ✅ | +| **S1b+** | Profil-Vorselektion | `ExerciseMatchProfile` × `PlanningTargetProfile` | ✅ `profile_v1` | | **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` | | **S2** | Neu-Anlage | Bestehende `suggestExerciseAi` + Pack als Zusatzkontext | Später | @@ -81,11 +82,14 @@ score = w_ft * fulltext_rank + w_prog * progression_hit + w_skill * skill_jaccard(anchor, candidate) + w_plan * plan_affinity + + w_profile * profile_match(exercise, target) + 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“. +**`profile_match`** (0–1): siehe §12–§13 — Katalog-Dimensionen + Skill-Gewichte + Skill-Gap. + +**`reasons[]`** (regelbasiert, Deutsch): z. B. „Nachfolger im Progressionsgraph“, „Fähigkeiten passen zur Anker-Übung“, „Fokusbereich passend zum Planungsziel“, „Deckt Skill-Lücke im bisherigen Plan“, „Volltext-Treffer“. --- @@ -121,6 +125,13 @@ score = w_ft * fulltext_rank "planned_count": 4, "anchor_title": "Partner-Fangspiel" }, + "target_profile_summary": { + "sources": ["framework_catalog", "current_unit_plan", "anchor_exercise"], + "focus_areas": ["Reaktion & Abwehr"], + "top_skills": [{ "skill_id": 12, "name": "Reaktionsgeschwindigkeit", "weight": 1.0 }], + "has_skill_gap": true + }, + "retrieval_phase": "profile_v1", "intent_resolved": "suggest_next", "hits": [ { @@ -128,14 +139,14 @@ score = w_ft * fulltext_rank "title": "…", "summary": "…", "score": 0.78, - "reasons": ["Nachfolger im Progressionsgraph", "3 gemeinsame Fähigkeiten mit Anker-Übung"], + "reasons": ["Nachfolger im Progressionsgraph", "Fokusbereich passend zum Planungsziel"], "focus_area": "…" } ] } ``` -**Modul:** `backend/planning_exercise_suggest.py` · Router `backend/routers/planning_exercise_suggest.py` +**Modul:** `backend/planning_exercise_suggest.py` · `backend/planning_exercise_profiles.py` · Router `backend/routers/planning_exercise_suggest.py` --- @@ -166,6 +177,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: | Phase | Inhalt | |-------|--------| | **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung | +| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` | | **P1** | LLM Intent-JSON; Neu-Anlage mit Pack | | **P2** | LLM-Rerank + Kurzbegründung | | **P3** | Skill-Discovery / Framework-Ziele im Pack | @@ -176,6 +188,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: - **2026-05-22:** Erstfassung; P0 API + Planungs-Picker. - **2026-05-22:** P0 implementiert (`planning_exercise_suggest.py`, Router, Picker); unsaved Formular-Plan noch nicht an API (nur persistierte Einheit). +- **2026-05-22:** P0.1 — `planning_exercise_profiles.py`, Profil-Score in Hybrid-Retrieval, `retrieval_phase: profile_v1`, `target_profile_summary`. --- @@ -184,3 +197,84 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: - **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. + +--- + +## 12. ExerciseMatchProfile & PlanningTargetProfile (Phase 1) + +Ziel: deterministische Vorselektion über **Profil-Dimensionen** statt nur Titel/Jaccard. + +### 12.1 ExerciseMatchProfile (pro Übung) + +| Feld | Quelle | +|------|--------| +| `focus_area_ids` | `exercise_focus_areas` (Primary = 1.0, sonst 0.85) | +| `style_direction_ids` | `exercise_style_directions` | +| `training_type_ids` | `exercise_training_types` | +| `target_group_ids` | `exercise_target_groups` | +| `skill_weights` | `exercise_skills` × Intensitäts-Multiplikator (`skill_scoring._skill_link_multiplier`) | + +Bulk-Lader: `load_exercise_match_profiles_bulk(cur, exercise_ids)`. + +### 12.2 PlanningTargetProfile (Planungsziel) + +Zusammensetzung aus mehreren Quellen (`sources[]`): + +| Quelle | Inhalt | +|--------|--------| +| `framework_catalog` | Fokus/Stil/Trainingsstil/Zielgruppe aus `training_framework_program_*` | +| `framework_slot_skill_profile` | Skill-Profil des Slot-Blueprints (`profile_for_occurrences`) | +| `framework_overall_skill_profile` | Fallback: alle Blueprint-Einheiten des Rahmens | +| `current_unit_plan` | Skill-Profil der bereits eingeplanten Übungen dieser Einheit | +| `anchor_exercise` | Katalog + Skills der Anker-Übung (Intent-abhängig) | +| `skill_gap_vs_plan` | `target_skills − plan_skills` (normalisiert, Schwelle > 0.08) | + +Builder: `build_planning_target_profile(cur, unit=…, planned_exercise_ids=…, anchor_exercise_id=…, intent=…)`. + +Rahmen-Anbindung über `unit.framework_slot_id` oder `origin_framework_slot_id`. + +--- + +## 13. Profil-Score (Formeln) + +**Gewichtete Überlappung** (Katalog + Skills): + +``` +overlap(a, b) = Σ min(a[k], b[k]) / Σ max(a[k], b[k]) +``` + +**Skill-Gap-Abdeckung:** + +``` +gap_coverage(gap, candidate) = Σ min(gap[k], candidate[k]) / Σ gap[k] +``` + +**Profil-Score** (intent-gewichtet, Summe Dimensionen = 1.0): + +``` +profile_score = w_focus * overlap(focus) + + w_style * overlap(style) + + w_tt * overlap(training_type) + + w_tg * overlap(target_group) + + w_skill * overlap(skill_weights) + + w_gap * gap_coverage(skill_gap) +``` + +Intent-Gewichte (Auszug): `deepen_exercise` → Skill hoch; `continue_plan_goal` → Gap hoch; `free_search` → Gap + Skill moderat. + +Scorer: `score_exercise_against_target(exercise_profile, target_profile, intent=…) → (score, reasons[])`. + +--- + +## 14. Hybrid + Profil (P0.1) + +Im Hybrid-Score kommt **`w_profile * profile_score`** hinzu (Intent-abhängig ~0.15–0.35). Jaccard auf Anker-Skills bleibt parallel (schneller Anker-Fokus). + +**Response-Felder:** + +| Feld | Bedeutung | +|------|-----------| +| `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank | +| `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) | + +**Phase 2 (P2):** Top 20–40 Kandidaten nach Hybrid+Profil → LLM `planning_exercise_search_rank` mit **Titel + summary + goal**; nur IDs aus Kandidatenliste. diff --git a/backend/planning_exercise_profiles.py b/backend/planning_exercise_profiles.py new file mode 100644 index 0000000..9f503ed --- /dev/null +++ b/backend/planning_exercise_profiles.py @@ -0,0 +1,448 @@ +""" +ExerciseMatchProfile / PlanningTargetProfile — Phase-1-Vorselektion Planungs-Übungssuche. + +Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §12–§14 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from skill_scoring import ( + ExerciseOccurrence, + collect_unit_exercise_occurrences, + fetch_exercise_skills_bulk, + profile_for_occurrences, + _skill_link_multiplier, + DEFAULT_ITEM_MINUTES, +) + + +def _ids_to_weights(ids: Sequence[int], primary_id: Optional[int] = None) -> Dict[int, float]: + out: Dict[int, float] = {} + for raw in ids or []: + try: + fid = int(raw) + except (TypeError, ValueError): + continue + if fid < 1: + continue + w = 1.0 if primary_id is not None and fid == int(primary_id) else 0.85 + out[fid] = max(out.get(fid, 0.0), w) + return out + + +def _merge_weight_maps(*maps: Optional[Dict[int, float]], scale: float = 1.0) -> Dict[int, float]: + out: Dict[int, float] = {} + for m in maps: + if not m: + continue + for k, v in m.items(): + try: + kid = int(k) + val = float(v) * scale + except (TypeError, ValueError): + continue + if kid < 1 or val <= 0: + continue + out[kid] = max(out.get(kid, 0.0), val) + return out + + +def _normalize_weight_map(m: Dict[int, float]) -> Dict[int, float]: + if not m: + return {} + mx = max(m.values()) + if mx <= 0: + return {} + return {k: v / mx for k, v in m.items() if v > 0} + + +def weighted_overlap(a: Dict[int, float], b: Dict[int, float]) -> float: + """Gewichtete Überlappung 0..1 (min-Summe / max-Summe).""" + if not a or not b: + return 0.0 + keys = set(a) | set(b) + num = sum(min(a.get(k, 0.0), b.get(k, 0.0)) for k in keys) + den = sum(max(a.get(k, 0.0), b.get(k, 0.0)) for k in keys) + return num / den if den > 0 else 0.0 + + +def gap_coverage(gap: Dict[int, float], candidate: Dict[int, float]) -> float: + """Anteil der Skill-Lücke, den der Kandidat abdeckt (0..1).""" + if not gap: + return 0.0 + total_gap = sum(gap.values()) + if total_gap <= 0: + return 0.0 + covered = sum(min(gap.get(k, 0.0), candidate.get(k, 0.0)) for k in gap) + return covered / total_gap + + +@dataclass +class ExerciseMatchProfile: + exercise_id: int + focus_area_ids: Dict[int, float] = field(default_factory=dict) + style_direction_ids: Dict[int, float] = field(default_factory=dict) + training_type_ids: Dict[int, float] = field(default_factory=dict) + target_group_ids: Dict[int, float] = field(default_factory=dict) + skill_weights: Dict[int, float] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "exercise_id": self.exercise_id, + "focus_area_ids": self.focus_area_ids, + "style_direction_ids": self.style_direction_ids, + "training_type_ids": self.training_type_ids, + "target_group_ids": self.target_group_ids, + "skill_weights": self.skill_weights, + } + + +@dataclass +class PlanningTargetProfile: + focus_area_ids: Dict[int, float] = field(default_factory=dict) + style_direction_ids: Dict[int, float] = field(default_factory=dict) + training_type_ids: Dict[int, float] = field(default_factory=dict) + target_group_ids: Dict[int, float] = field(default_factory=dict) + skill_weights: Dict[int, float] = field(default_factory=dict) + skill_gap_weights: Dict[int, float] = field(default_factory=dict) + sources: List[str] = field(default_factory=list) + + def to_summary_dict(self, cur, limit_skills: int = 5) -> Dict[str, Any]: + focus_labels = _load_focus_labels(cur, list(self.focus_area_ids.keys())[:6]) + top_skills = sorted(self.skill_weights.items(), key=lambda x: -x[1])[:limit_skills] + skill_names = _load_skill_names(cur, [s[0] for s in top_skills]) + return { + "sources": list(self.sources), + "focus_areas": focus_labels, + "top_skills": [ + {"skill_id": sid, "name": skill_names.get(sid, f"#{sid}"), "weight": round(w, 2)} + for sid, w in top_skills + ], + "has_skill_gap": bool(self.skill_gap_weights), + } + + +def _load_focus_labels(cur, ids: Sequence[int]) -> List[str]: + if not ids: + return [] + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f"SELECT id, name FROM focus_areas WHERE id IN ({ph}) ORDER BY name", + list(ids), + ) + return [f"{r['name'] or r['id']}" for r in cur.fetchall()] + + +def _load_skill_names(cur, ids: Sequence[int]) -> Dict[int, str]: + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", list(ids)) + return {int(r["id"]): str(r["name"] or "") for r in cur.fetchall()} + + +def _skill_weights_from_profile(skills_out: Sequence[Dict[str, Any]]) -> Dict[int, float]: + out: Dict[int, float] = {} + for row in skills_out or []: + sid = row.get("skill_id") + if sid is None: + continue + w = float(row.get("weight") or row.get("score") or 0) + if w > 0: + out[int(sid)] = w + return out + + +def _single_exercise_skill_weights( + skill_rows: Sequence[Dict[str, Any]], + *, + minutes: float = DEFAULT_ITEM_MINUTES, +) -> Dict[int, float]: + out: Dict[int, float] = {} + for link in skill_rows or []: + sid = link.get("skill_id") + if sid is None: + continue + sid = int(sid) + mult = _skill_link_multiplier( + intensity=link.get("intensity"), + required_level=link.get("required_level"), + target_level=link.get("target_level"), + ) + w = minutes * mult + if w > 0: + out[sid] = out.get(sid, 0.0) + w + return out + + +def _load_relation_maps_bulk( + cur, + exercise_ids: Sequence[int], + table: str, + id_column: str, +) -> Dict[int, Dict[int, float]]: + ids = [int(x) for x in exercise_ids if int(x) > 0] + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT exercise_id, {id_column} AS rel_id, is_primary + FROM {table} + WHERE exercise_id IN ({ph}) + """, + ids, + ) + out: Dict[int, Dict[int, float]] = {eid: {} for eid in ids} + for row in cur.fetchall(): + eid = int(row["exercise_id"]) + rid = int(row["rel_id"]) + w = 1.0 if row.get("is_primary") else 0.85 + out.setdefault(eid, {})[rid] = max(out[eid].get(rid, 0.0), w) + return out + + +def load_exercise_match_profiles_bulk(cur, exercise_ids: Sequence[int]) -> Dict[int, ExerciseMatchProfile]: + ids = sorted({int(x) for x in exercise_ids if int(x) > 0}) + if not ids: + return {} + + focus_map = _load_relation_maps_bulk(cur, ids, "exercise_focus_areas", "focus_area_id") + style_map = _load_relation_maps_bulk(cur, ids, "exercise_style_directions", "style_direction_id") + type_map = _load_relation_maps_bulk(cur, ids, "exercise_training_types", "training_type_id") + tg_map = _load_relation_maps_bulk(cur, ids, "exercise_target_groups", "target_group_id") + skills_bulk = fetch_exercise_skills_bulk(cur, ids) + + profiles: Dict[int, ExerciseMatchProfile] = {} + for eid in ids: + profiles[eid] = ExerciseMatchProfile( + exercise_id=eid, + focus_area_ids=focus_map.get(eid, {}), + style_direction_ids=style_map.get(eid, {}), + training_type_ids=type_map.get(eid, {}), + target_group_ids=tg_map.get(eid, {}), + skill_weights=_single_exercise_skill_weights(skills_bulk.get(eid, [])), + ) + return profiles + + +def _resolve_framework_for_unit(cur, unit: Dict[str, Any]) -> Optional[Dict[str, Any]]: + slot_id = unit.get("framework_slot_id") or unit.get("origin_framework_slot_id") + if not slot_id: + return None + cur.execute( + """ + SELECT s.id AS slot_id, s.framework_program_id, s.sort_order, s.title AS slot_title, + fp.title AS framework_title, fp.focus_area_id AS header_focus_area_id + FROM training_framework_slots s + JOIN training_framework_programs fp ON fp.id = s.framework_program_id + WHERE s.id = %s + """, + (int(slot_id),), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def _framework_catalog_weights(cur, framework_id: int) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float]]: + cur.execute( + "SELECT focus_area_id FROM training_framework_programs WHERE id = %s", + (framework_id,), + ) + hdr = cur.fetchone() + header_fa = int(hdr["focus_area_id"]) if hdr and hdr.get("focus_area_id") else None + + cur.execute( + "SELECT focus_area_id FROM training_framework_program_focus_areas WHERE framework_program_id = %s", + (framework_id,), + ) + fa_ids = [int(r["focus_area_id"]) for r in cur.fetchall()] + if header_fa and header_fa not in fa_ids: + fa_ids.insert(0, header_fa) + focus = _ids_to_weights(fa_ids, primary_id=header_fa) + + cur.execute( + "SELECT style_direction_id FROM training_framework_program_style_directions WHERE framework_program_id = %s", + (framework_id,), + ) + style = _ids_to_weights([int(r["style_direction_id"]) for r in cur.fetchall()]) + + cur.execute( + "SELECT training_type_id FROM training_framework_program_training_types WHERE framework_program_id = %s", + (framework_id,), + ) + tt = _ids_to_weights([int(r["training_type_id"]) for r in cur.fetchall()]) + + cur.execute( + "SELECT target_group_id FROM training_framework_program_target_groups WHERE framework_program_id = %s", + (framework_id,), + ) + tg = _ids_to_weights([int(r["target_group_id"]) for r in cur.fetchall()]) + + return focus, style, tt, tg + + +def _profile_from_unit_occurrences(cur, unit_id: int) -> Dict[int, float]: + occ = collect_unit_exercise_occurrences(cur, int(unit_id)) + if not occ: + return {} + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None) + return _skill_weights_from_profile(prof.get("skills") or []) + + +def build_planning_target_profile( + cur, + *, + unit: Dict[str, Any], + planned_exercise_ids: Sequence[int], + anchor_exercise_id: Optional[int], + intent: str, +) -> PlanningTargetProfile: + sources: List[str] = [] + focus: Dict[int, float] = {} + style: Dict[int, float] = {} + tt: Dict[int, float] = {} + tg: Dict[int, float] = {} + skill_target: Dict[int, float] = {} + skill_plan: Dict[int, float] = {} + + fw = _resolve_framework_for_unit(cur, unit) + if fw: + fid = int(fw["framework_program_id"]) + f_focus, f_style, f_tt, f_tg = _framework_catalog_weights(cur, fid) + focus = _merge_weight_maps(focus, f_focus) + style = _merge_weight_maps(style, f_style) + tt = _merge_weight_maps(tt, f_tt) + tg = _merge_weight_maps(tg, f_tg) + sources.append("framework_catalog") + + slot_id = fw.get("slot_id") + cur.execute( + "SELECT id FROM training_units WHERE framework_slot_id = %s LIMIT 1", + (int(slot_id),), + ) + bp = cur.fetchone() + if bp and bp.get("id"): + slot_skills = _profile_from_unit_occurrences(cur, int(bp["id"])) + if slot_skills: + skill_target = _merge_weight_maps(skill_target, slot_skills, scale=1.0) + sources.append("framework_slot_skill_profile") + if not skill_target: + cur.execute( + """ + SELECT tu.id FROM training_framework_slots s + LEFT JOIN training_units tu ON tu.framework_slot_id = s.id + WHERE s.framework_program_id = %s AND tu.id IS NOT NULL + """, + (fid,), + ) + all_occ: List[ExerciseOccurrence] = [] + for r in cur.fetchall(): + all_occ.extend(collect_unit_exercise_occurrences(cur, int(r["id"]))) + if all_occ: + prof = profile_for_occurrences(cur, all_occ, reference_max_by_skill=None) + skill_target = _merge_weight_maps( + skill_target, _skill_weights_from_profile(prof.get("skills") or []), scale=0.85 + ) + sources.append("framework_overall_skill_profile") + + if planned_exercise_ids: + occ = [ExerciseOccurrence(exercise_id=int(eid)) for eid in planned_exercise_ids] + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None) + skill_plan = _skill_weights_from_profile(prof.get("skills") or []) + if skill_plan: + sources.append("current_unit_plan") + + if anchor_exercise_id: + anchor_profiles = load_exercise_match_profiles_bulk(cur, [int(anchor_exercise_id)]) + ap = anchor_profiles.get(int(anchor_exercise_id)) + if ap: + if intent in ("deepen_exercise", "suggest_next", "progression_next", "continue_plan_goal"): + skill_target = _merge_weight_maps(skill_target, ap.skill_weights, scale=1.0) + focus = _merge_weight_maps(focus, ap.focus_area_ids, scale=0.9) + style = _merge_weight_maps(style, ap.style_direction_ids, scale=0.75) + tt = _merge_weight_maps(tt, ap.training_type_ids, scale=0.75) + tg = _merge_weight_maps(tg, ap.target_group_ids, scale=0.75) + sources.append("anchor_exercise") + + skill_target = _normalize_weight_map(skill_target) + skill_plan_norm = _normalize_weight_map(skill_plan) + skill_gap: Dict[int, float] = {} + for sid, tw in skill_target.items(): + pw = skill_plan_norm.get(sid, 0.0) + gap = tw - pw * 0.85 + if gap > 0.08: + skill_gap[sid] = gap + if skill_gap: + sources.append("skill_gap_vs_plan") + + return PlanningTargetProfile( + focus_area_ids=_normalize_weight_map(focus) if focus else focus, + style_direction_ids=_normalize_weight_map(style) if style else style, + training_type_ids=_normalize_weight_map(tt) if tt else tt, + target_group_ids=_normalize_weight_map(tg) if tg else tg, + skill_weights=skill_target, + skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else skill_gap, + sources=sources, + ) + + +def score_exercise_against_target( + exercise: ExerciseMatchProfile, + target: PlanningTargetProfile, + *, + intent: str, +) -> Tuple[float, List[str]]: + """Profil-Match 0..1 + deutschsprachige Gründe.""" + reasons: List[str] = [] + + focus_sim = weighted_overlap(exercise.focus_area_ids, target.focus_area_ids) + style_sim = weighted_overlap(exercise.style_direction_ids, target.style_direction_ids) + tt_sim = weighted_overlap(exercise.training_type_ids, target.training_type_ids) + tg_sim = weighted_overlap(exercise.target_group_ids, target.target_group_ids) + skill_sim = weighted_overlap( + _normalize_weight_map(exercise.skill_weights), + target.skill_weights, + ) + gap_sim = gap_coverage(target.skill_gap_weights, _normalize_weight_map(exercise.skill_weights)) + + if focus_sim >= 0.5 and target.focus_area_ids: + reasons.append("Fokusbereich passend zum Planungsziel") + if style_sim >= 0.5 and target.style_direction_ids: + reasons.append("Stilrichtung passend") + if tt_sim >= 0.5 and target.training_type_ids: + reasons.append("Trainingsstil passend") + if tg_sim >= 0.5 and target.target_group_ids: + reasons.append("Zielgruppe passend") + if skill_sim >= 0.35 and target.skill_weights: + reasons.append("Fähigkeiten-Schwerpunkt passend (Profilmetrik)") + if gap_sim >= 0.25 and target.skill_gap_weights: + reasons.append("Deckt Skill-Lücke im bisherigen Plan") + + # Intent-gewichtete Dimensionen (Summe = 1.0) + if intent == INTENT_FREE_SEARCH: + weights = {"focus": 0.15, "style": 0.10, "tt": 0.10, "tg": 0.10, "skill": 0.25, "gap": 0.30} + elif intent == INTENT_DEEPEN_EXERCISE: + weights = {"focus": 0.15, "style": 0.10, "tt": 0.10, "tg": 0.05, "skill": 0.45, "gap": 0.15} + elif intent == INTENT_PROGRESSION_NEXT: + weights = {"focus": 0.20, "style": 0.10, "tt": 0.10, "tg": 0.05, "skill": 0.35, "gap": 0.20} + else: + weights = {"focus": 0.20, "style": 0.10, "tt": 0.10, "tg": 0.10, "skill": 0.30, "gap": 0.20} + + score = ( + weights["focus"] * focus_sim + + weights["style"] * style_sim + + weights["tt"] * tt_sim + + weights["tg"] * tg_sim + + weights["skill"] * skill_sim + + weights["gap"] * gap_sim + ) + return max(0.0, min(1.0, score)), reasons + + +# Re-export intent constants for typing (avoid circular import at runtime in suggest module) +INTENT_FREE_SEARCH = "free_search" +INTENT_DEEPEN_EXERCISE = "deepen_exercise" +INTENT_PROGRESSION_NEXT = "progression_next" diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 0e49c01..f0538a3 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -11,8 +11,12 @@ 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 +from tenant_context import TenantContext, library_content_visibility_sql +from planning_exercise_profiles import ( + build_planning_target_profile, + load_exercise_match_profiles_bulk, + score_exercise_against_target, +) # Planungs-Berechtigung + Sektionen (bestehende Implementierung) from routers.training_planning import ( @@ -71,23 +75,31 @@ def resolve_planning_exercise_intent(query: Optional[str], intent_hint: Optional def _intent_weights(intent: str) -> Dict[str, float]: base = { - "fulltext": 0.25, - "progression": 0.25, - "skill": 0.20, - "plan": 0.10, + "fulltext": 0.18, + "progression": 0.18, + "skill": 0.12, + "plan": 0.08, + "profile": 0.22, "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} + return { + **base, + "progression": 0.28, + "skill": 0.12, + "plan": 0.10, + "profile": 0.25, + "fulltext": 0.08, + } if intent == INTENT_PROGRESSION_NEXT: - return {**base, "progression": 0.50, "fulltext": 0.15, "skill": 0.15} + return {**base, "progression": 0.42, "fulltext": 0.12, "skill": 0.10, "profile": 0.20} if intent == INTENT_DEEPEN_EXERCISE: - return {**base, "skill": 0.40, "fulltext": 0.20, "progression": 0.15} + return {**base, "skill": 0.15, "profile": 0.35, "fulltext": 0.15, "progression": 0.10} if intent == INTENT_CONTINUE_PLAN: - return {**base, "plan": 0.30, "skill": 0.25, "fulltext": 0.15, "progression": 0.10} + return {**base, "plan": 0.12, "skill": 0.10, "profile": 0.30, "fulltext": 0.10, "progression": 0.08} if intent == INTENT_FREE_SEARCH: - return {**base, "fulltext": 0.55, "progression": 0.10, "skill": 0.10} + return {**base, "fulltext": 0.45, "progression": 0.08, "skill": 0.08, "profile": 0.15} return base @@ -287,6 +299,11 @@ def build_planning_exercise_context_pack( return { "unit_id": int(body.unit_id), + "unit": { + "id": int(body.unit_id), + "framework_slot_id": unit.get("framework_slot_id"), + "origin_framework_slot_id": unit.get("origin_framework_slot_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, @@ -313,6 +330,14 @@ def suggest_planning_exercises( query = _normalize_query(body.query) intent = resolve_planning_exercise_intent(query, body.intent_hint) weights = _intent_weights(intent) + target_profile = build_planning_target_profile( + cur, + unit=pack["unit"], + planned_exercise_ids=pack["planned_exercise_ids"], + anchor_exercise_id=pack.get("anchor_exercise_id"), + intent=intent, + ) + target_profile_summary = target_profile.to_summary_dict(cur) profile_id = tenant.profile_id role = tenant.global_role @@ -374,9 +399,10 @@ def suggest_planning_exercises( if pack["planned_exercise_ids"]: last_planned_skills = _load_skill_ids_for_exercise(cur, pack["planned_exercise_ids"][-1]) - # Skill-IDs pro Kandidat (Batch) + # Skill-IDs + ExerciseMatchProfile 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} + match_profiles = load_exercise_match_profiles_bulk(cur, cand_ids) if cand_ids: ph = ",".join(["%s"] * len(cand_ids)) cur.execute( @@ -416,12 +442,20 @@ def suggest_planning_exercises( 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 + profile_score = 0.0 + profile_reasons: List[str] = [] + emp = match_profiles.get(eid) + if emp: + profile_score, profile_reasons = score_exercise_against_target( + emp, target_profile, intent=intent + ) score = ( weights["fulltext"] * ft_norm + weights["progression"] * prog_hit + weights["skill"] * skill_sim + weights["plan"] * plan_aff + + weights["profile"] * profile_score + weights["repeat_unit"] * repeat_unit + weights["repeat_group"] * repeat_group ) @@ -442,11 +476,14 @@ def suggest_planning_exercises( reasons.append("Bereits in dieser Einheit eingeplant") if repeat_group > 0 and repeat_unit <= 0: reasons.append("Kürzlich in der Gruppe verwendet") + for pr in profile_reasons: + if pr not in reasons: + reasons.append(pr) 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 + if prog_hit or skill_sim or plan_aff or profile_score: + score = 0.05 + prog_hit * 0.3 + skill_sim * 0.2 + profile_score * 0.25 hits.append( { @@ -474,6 +511,8 @@ def suggest_planning_exercises( return { "context_summary": context_summary, + "target_profile_summary": target_profile_summary, + "retrieval_phase": "profile_v1", "intent_resolved": intent, "query_normalized": query or None, "hits": hits, diff --git a/backend/version.py b/backend/version.py index de47331..233cd86 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.167" +APP_VERSION = "0.8.169" BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260531071" @@ -27,8 +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.34.0", # Planungs-KI P0: POST /planning/exercise-suggest; Picker Kontext - "planning_exercise_suggest": "0.1.0", # Kontext-Pack + Hybrid-Retrieval Übungssuche + "exercises": "2.35.0", # Planungs-KI P0.1: Profil-Score profile_v1 + target_profile_summary + "planning_exercise_suggest": "0.2.1", # Fix Import library_content_visibility_sql aus tenant_context "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -43,6 +43,21 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.169", + "date": "2026-05-22", + "changes": [ + "Fix: planning_exercise_suggest Import library_content_visibility_sql aus tenant_context (Backend-Start/502).", + ], + }, + { + "version": "0.8.168", + "date": "2026-05-22", + "changes": [ + "Planungs-KI P0.1: ExerciseMatchProfile + PlanningTargetProfile — Profil-Score (Fokus, Stil, Skills, Gap) im Hybrid-Retrieval.", + "API exercise-suggest: retrieval_phase profile_v1, target_profile_summary; Doku §12–§14.", + ], + }, { "version": "0.8.167", "date": "2026-05-22", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 76309f7..a965601 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -89,10 +89,10 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions -### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.167**) +### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.168**) - **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`**. +- **Planungs-Übungssuche (P0.1):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Context-Pack, Hybrid-Retrieval + **Profil-Score** (`profile_v1`, `ExerciseMatchProfile` / `PlanningTargetProfile`); **`POST /api/planning/exercise-suggest`**; Frontend **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**. - **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/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 3ee890f..e8b0cd4 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -67,6 +67,7 @@ export default function ExercisePickerModal({ const [quickAiError, setQuickAiError] = useState('') const [quickCreateDraft, setQuickCreateDraft] = useState(null) const [planningContextSummary, setPlanningContextSummary] = useState(null) + const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null) const [planningIntentResolved, setPlanningIntentResolved] = useState(null) const pickerScrollRef = useRef(null) @@ -153,6 +154,7 @@ export default function ExercisePickerModal({ setQuickAiError('') setQuickCreateDraft(null) setPlanningContextSummary(null) + setPlanningTargetProfileSummary(null) setPlanningIntentResolved(null) return } @@ -276,6 +278,7 @@ export default function ExercisePickerModal({ Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined, }) setPlanningContextSummary(res?.context_summary || null) + setPlanningTargetProfileSummary(res?.target_profile_summary || null) setPlanningIntentResolved(res?.intent_resolved || null) const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({ id: h.id, @@ -290,6 +293,7 @@ export default function ExercisePickerModal({ setHasMore(false) } else { setPlanningContextSummary(null) + setPlanningTargetProfileSummary(null) setPlanningIntentResolved(null) const batch = await api.listExercises({ ...queryBase, @@ -307,6 +311,7 @@ export default function ExercisePickerModal({ setList([]) setHasMore(false) setPlanningContextSummary(null) + setPlanningTargetProfileSummary(null) setPlanningIntentResolved(null) } finally { setLoading(false) @@ -508,7 +513,28 @@ export default function ExercisePickerModal({ Anker: {planningContextSummary.anchor_title} ) : null} + {Array.isArray(planningTargetProfileSummary?.focus_areas) && + planningTargetProfileSummary.focus_areas.length > 0 + ? planningTargetProfileSummary.focus_areas.map((fa) => ( + + Fokus: {fa} + + )) + : null} + {Array.isArray(planningTargetProfileSummary?.top_skills) && + planningTargetProfileSummary.top_skills.length > 0 + ? planningTargetProfileSummary.top_skills.slice(0, 3).map((sk) => ( + + {sk.name} + + )) + : null}
+ {planningTargetProfileSummary?.has_skill_gap ? ( +

+ Skill-Lücke zum bisherigen Plan berücksichtigt +

+ ) : null} {planningIntentResolved ? (

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

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

) : null}
diff --git a/frontend/src/pages/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx index 974376e..dc62558 100644 --- a/frontend/src/pages/TrainingUnitEditPage.jsx +++ b/frontend/src/pages/TrainingUnitEditPage.jsx @@ -152,11 +152,23 @@ export default function TrainingUnitEditPage() { } } } + const plannedExerciseIds = [] + const seenPlan = new Set() + for (const sec of secs) { + for (const it of sec?.items || []) { + if (String(it?.item_type || '').toLowerCase() === 'note') continue + const eid = Number(it?.exercise_id) + if (!Number.isFinite(eid) || eid < 1 || seenPlan.has(eid)) continue + seenPlan.add(eid) + plannedExerciseIds.push(eid) + } + } return { unitId: Number(editingUnit.id), sectionOrderIndex: sIdx, anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null, progressionGraphId: null, + plannedExerciseIds, } }, [editingUnit?.id, exercisePickerTarget, formData.sections]) -- 2.43.0 From 45e3b5f4f66587c3664ddd5976a3ab13cad68d59 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 22:15:19 +0200 Subject: [PATCH 04/16] Implement Phase 1 of Planning Exercise Suggestion with Scenario Pipeline and LLM Intent Overlay - Introduced the Scenario Pipeline for planning exercises, allowing for more nuanced query handling and exercise suggestions based on user intent. - Enhanced the `suggestPlanningExercises` API to include `include_llm_intent`, `scenario_kind`, and `query_intent_summary`, improving the context provided to the frontend. - Updated the `ExercisePickerModal` to display new information related to query intent and scenario classification, enhancing user experience during exercise selection. - Incremented application version to 0.8.171 and updated changelog to document the new features and improvements in the planning AI capabilities. --- .../PLANNING_EXERCISE_SUGGEST_CONTEXT.md | 56 +++- backend/ai_prompt_runtime.py | 1 + ...prompt_planning_exercise_search_intent.sql | 74 +++++ backend/planning_exercise_intent.py | 272 +++++++++++++++++ backend/planning_exercise_profiles.py | 4 + backend/planning_exercise_suggest.py | 38 ++- backend/planning_exercise_target_pipeline.py | 284 ++++++++++++++++++ .../tests/test_planning_exercise_suggest.py | 57 +++- backend/version.py | 16 +- docs/HANDOVER.md | 5 +- .../src/components/ExercisePickerModal.jsx | 15 + 11 files changed, 794 insertions(+), 28 deletions(-) create mode 100644 backend/migrations/073_ai_prompt_planning_exercise_search_intent.sql create mode 100644 backend/planning_exercise_intent.py create mode 100644 backend/planning_exercise_target_pipeline.py diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index bdfd06e..f602dae 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -2,7 +2,7 @@ **Version:** 0.1 **Datum:** 2026-05-22 -**Status:** P0.1 — Hybrid-Retrieval + Phase-1-Profil-Score (`profile_v1`); LLM-Rerank P2 +**Status:** P1 — Szenario-Pipeline + LLM Query-Intent-Overlay; P2 LLM-Rerank optional **Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph) --- @@ -25,7 +25,7 @@ Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können | Stufe | Name | Technik | P0 | |-------|------|---------|-----| | **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ | -| **S1a** | Intent strukturieren | Optional LLM `planning_exercise_search_intent` | Heuristik | +| **S1a** | Intent strukturieren | LLM `planning_exercise_search_intent` (Szenario-Pipeline) | ✅ P1 | | **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan + **Profil** | ✅ | | **S1b+** | Profil-Vorselektion | `ExerciseMatchProfile` × `PlanningTargetProfile` | ✅ `profile_v1` | | **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` | @@ -179,7 +179,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: | **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung | | **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` | | **P2** ✅ (optional) | LLM-Rerank `planning_exercise_search_rank`, `include_llm_rank`, `llm_rank_applied` | -| **P1** | LLM Intent-JSON; Neu-Anlage mit Pack | +| **P1** ✅ | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil-Overlay | | **P3** | Skill-Discovery / Framework-Ziele im Pack | --- @@ -197,7 +197,55 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: - **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage). - **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API). -- **LLM-Intent:** P1 laut Roadmap §9. +- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073). + +--- + +## 16. Szenario-Pipeline & Query-Erwartungsprofil (P1) + +Komplexe Planungsanfragen brauchen **Schritte vor** dem Profil-Match — nicht jede Query ist gleich. + +### 16.1 Szenario-Klassen + +| `scenario_kind` | Typische Anfrage | LLM Intent? | +|-----------------|------------------|-------------| +| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Nein — nur Basis-Profil | +| `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) | +| `deepen` | Vertiefung Anker | Ja | +| `continue_plan` | Auf bisherigen Plan aufbauen | Ja | +| `additive_constraint` | Plan **+** Zusatz (z. B. Schnellkraft) | Ja | +| `free_search` | Offene Stichwortsuche | Ja | + +**Routing:** `planning_exercise_target_pipeline.classify_planning_scenario()` → `should_run_llm_intent_pipeline()`. + +### 16.2 Pipeline (Reihenfolge) + +``` +S0 Kontext-Pack + → Heuristik-Intent + Szenario + → [optional] LLM planning_exercise_search_intent + → Basis PlanningTargetProfile (Rahmen, Plan, Anker, Gap) + → Merge Query-Overlay (Katalog-IDs aus Hints) + → Hybrid-Retrieval + Profil-Score + → [optional] LLM-Rerank +``` + +Module: `planning_exercise_target_pipeline.py` · `planning_exercise_intent.py` + +### 16.3 API (Erweiterung) + +| Request | Default | Bedeutung | +|---------|---------|-----------| +| `include_llm_intent` | `true` | LLM nur wenn Szenario ≠ preset_next und Query nicht leer | + +| Response | Bedeutung | +|----------|-----------| +| `scenario_kind` | Szenario-Klasse | +| `query_intent_summary` | intent, llm_applied, rationale, skill_hints_resolved | +| `intent_heuristic` | Heuristik vor LLM | +| `retrieval_phase` | z. B. `profile_v1+query_intent+llm_rank` | + +**Prompt 073:** `planning_exercise_search_intent` — Ausgabe JSON mit `skill_hints`, `focus_hints`, `emphasis` (`additive`|`replace`). --- diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py index a7c4bdb..f942ad1 100644 --- a/backend/ai_prompt_runtime.py +++ b/backend/ai_prompt_runtime.py @@ -14,6 +14,7 @@ from prompt_resolver import MustacheRenderResult, render_mustache_template _PLANNING_AI_SLUGS = frozenset( { "planning_exercise_search_rank", + "planning_exercise_search_intent", } ) diff --git a/backend/migrations/073_ai_prompt_planning_exercise_search_intent.sql b/backend/migrations/073_ai_prompt_planning_exercise_search_intent.sql new file mode 100644 index 0000000..7aa780e --- /dev/null +++ b/backend/migrations/073_ai_prompt_planning_exercise_search_intent.sql @@ -0,0 +1,74 @@ +-- Migration 073: KI-Prompt Planungs-Übungssuche — Intent/Query-Overlay (P1) +-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16 + +INSERT INTO ai_prompts ( + slug, display_name, description, template, + category, output_format, output_schema, is_system_default, default_template, active, sort_order +) +SELECT + 'planning_exercise_search_intent', + 'Planungs-Übungssuche Intent', + 'Strukturiert Freitext-Anfrage in Intent, Szenario und Katalog-Hints für Erwartungsprofil-Overlay.', + $t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung. +Analysiere die Suchanfrage im Kontext der Einheit und des bisherigen Plans. + +Ziel: JSON für ein Erwartungsprofil-Overlay (Fähigkeiten, Fokus, Stil …) — NICHT Übungs-IDs erfinden. + +Szenario-Klassen (scenario): +- preset_next: nur „nächste Übung“ ohne Zusatz — selten bei Freitext +- progression: Progressionsgraph / Pfad / Folgeübung im Graph +- deepen: Vertiefung zur Anker-Übung +- continue_plan: baut auf bisherigem Plan der Einheit auf +- additive_constraint: Plan beibehalten UND zusätzliche Anforderung (z. B. „außerdem Schnellkraft“) +- free_search: offene Stichwortsuche / neues Thema + +Intent (intent): suggest_next | progression_next | deepen_exercise | continue_plan_goal | free_search + +emphasis: +- additive: Zusatz zur bestehenden Planung (Default bei „zusätzlich/auch/dazu“) +- replace: Suchanfrage soll Schwerpunkt eher ersetzen +- neutral: nur leichte Gewichtung + +Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung). +Bei requires_partner: true/false/null wenn Partnerbezug erkennbar. + +Eingabe: +Suchanfrage: {{search_query}} +Heuristik-Intent: {{heuristic_intent}} +Szenario-Hinweis (Server): {{scenario_hint}} +Planungskontext: {{planning_context_json}} +Basis-Zielprofil (deterministisch): {{target_profile_json}} + +Kataloge (Auszug — nur diese Namen/IDs verwenden): +Skills: {{skills_catalog_json}} +Fokus: {{focus_areas_catalog_json}} +Trainingsstil: {{training_types_catalog_json}} +Stilrichtung: {{style_directions_catalog_json}} +Zielgruppe: {{target_groups_catalog_json}} + +Antworte NUR mit JSON: +{ + "intent": "continue_plan_goal", + "scenario": "additive_constraint", + "skill_hints": [{"name": "Schnellkraft", "weight": 1.0}], + "focus_hints": [], + "style_hints": [], + "training_type_hints": [], + "target_group_hints": [], + "requires_partner": null, + "emphasis": "additive", + "rationale": "Kurz auf Deutsch, 1 Satz" +}$t$, + 'training', + 'json', + '{"type":"object","required":["intent","scenario"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb, + true, + NULL, + true, + 11 +WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_intent'); + +UPDATE ai_prompts +SET default_template = template +WHERE slug = 'planning_exercise_search_intent' + AND (default_template IS NULL OR TRIM(default_template) = ''); diff --git a/backend/planning_exercise_intent.py b/backend/planning_exercise_intent.py new file mode 100644 index 0000000..43a64b7 --- /dev/null +++ b/backend/planning_exercise_intent.py @@ -0,0 +1,272 @@ +""" +P1: LLM-Intent aus Planungs-Suchfrage → strukturiertes Query-Overlay für PlanningTargetProfile. + +Prompt: planning_exercise_search_intent (Migration 073) +""" +from __future__ import annotations + +import json +import logging +import re +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple + +from pydantic import BaseModel, Field, field_validator + +from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt +from openrouter_chat import ( + effective_openrouter_model_for_prompt_row, + normalize_openrouter_env, + openrouter_chat_completion, +) + +_logger = logging.getLogger("shinkan.planning_exercise_intent") + +VALID_PARSED_INTENTS = { + "suggest_next", + "progression_next", + "deepen_exercise", + "continue_plan_goal", + "free_search", +} + +VALID_SCENARIOS = { + "preset_next", + "progression", + "deepen", + "continue_plan", + "additive_constraint", + "free_search", +} + +VALID_EMPHASIS = {"additive", "replace", "neutral"} + + +class SkillHint(BaseModel): + name: str = Field(..., min_length=1, max_length=120) + weight: float = Field(default=1.0, ge=0.1, le=1.0) + + +class PlanningQueryIntentParsed(BaseModel): + intent: str = "free_search" + scenario: str = "free_search" + skill_hints: List[SkillHint] = Field(default_factory=list) + focus_hints: List[str] = Field(default_factory=list) + style_hints: List[str] = Field(default_factory=list) + training_type_hints: List[str] = Field(default_factory=list) + target_group_hints: List[str] = Field(default_factory=list) + requires_partner: Optional[bool] = None + emphasis: str = "additive" + rationale: Optional[str] = Field(default=None, max_length=400) + + @field_validator("intent") + @classmethod + def _intent(cls, v: str) -> str: + s = (v or "").strip().lower() + return s if s in VALID_PARSED_INTENTS else "free_search" + + @field_validator("scenario") + @classmethod + def _scenario(cls, v: str) -> str: + s = (v or "").strip().lower() + return s if s in VALID_SCENARIOS else "free_search" + + @field_validator("emphasis") + @classmethod + def _emphasis(cls, v: str) -> str: + s = (v or "").strip().lower() + return s if s in VALID_EMPHASIS else "additive" + + @field_validator("focus_hints", "style_hints", "training_type_hints", "target_group_hints", mode="before") + @classmethod + def _str_list(cls, v: Any) -> List[str]: + if not v: + return [] + if isinstance(v, str): + return [v.strip()] if v.strip() else [] + out: List[str] = [] + for item in v: + s = str(item or "").strip() + if s and s not in out: + out.append(s[:120]) + return out[:8] + + +def _extract_json_object(text: str) -> Dict[str, Any]: + s = (text or "").strip() + if s.startswith("```"): + s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) + if s.endswith("```"): + s = s[:-3].strip() + start = s.find("{") + end = s.rfind("}") + if start < 0 or end <= start: + raise ValueError("Kein JSON-Objekt in LLM-Antwort") + obj = json.loads(s[start : end + 1]) + if not isinstance(obj, dict): + raise ValueError("LLM-Antwort ist kein JSON-Objekt") + return obj + + +def parse_planning_query_intent_response(text: str) -> PlanningQueryIntentParsed: + obj = _extract_json_object(text) + return PlanningQueryIntentParsed.model_validate(obj) + + +def _compact_json(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +def _load_compact_catalog(cur, table: str, id_col: str, name_col: str = "name", limit: int = 80) -> List[Dict[str, Any]]: + cur.execute( + f""" + SELECT {id_col} AS id, {name_col} AS name + FROM {table} + ORDER BY {name_col} ASC NULLS LAST + LIMIT %s + """, + (limit,), + ) + return [{"id": int(r["id"]), "name": str(r["name"] or "")[:80]} for r in cur.fetchall()] + + +def _load_skills_catalog_compact(cur, limit: int = 120) -> List[Dict[str, Any]]: + cur.execute( + """ + SELECT id, name, category + FROM skills + WHERE status IS NULL OR status = 'active' + ORDER BY name ASC + LIMIT %s + """, + (limit,), + ) + return [ + { + "id": int(r["id"]), + "name": str(r["name"] or "")[:80], + "category": str(r.get("category") or "")[:40], + } + for r in cur.fetchall() + ] + + +def _resolve_name_hint(cur, table: str, hint: str, *, extra_where: str = "") -> Optional[int]: + h = (hint or "").strip() + if len(h) < 2: + return None + q = h.lower() + cur.execute( + f""" + SELECT id, name + FROM {table} + WHERE LOWER(name) LIKE %s {extra_where} + ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END, + LENGTH(name) ASC + LIMIT 1 + """, + (f"%{q}%", q, f"{q}%"), + ) + row = cur.fetchone() + return int(row["id"]) if row else None + + +def resolve_query_intent_catalog_ids( + cur, + parsed: PlanningQueryIntentParsed, +) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], List[Dict[str, Any]]]: + """ + Mappt Text-Hints auf Katalog-IDs. Returns (focus, style, tt, tg, skills, resolved_skills_meta). + """ + focus: Dict[int, float] = {} + style: Dict[int, float] = {} + tt: Dict[int, float] = {} + tg: Dict[int, float] = {} + skills: Dict[int, float] = {} + resolved_skills: List[Dict[str, Any]] = [] + + for hint in parsed.focus_hints: + fid = _resolve_name_hint(cur, "focus_areas", hint) + if fid: + focus[fid] = max(focus.get(fid, 0.0), 0.9) + + for hint in parsed.style_hints: + sid = _resolve_name_hint(cur, "style_directions", hint) + if sid: + style[sid] = max(style.get(sid, 0.0), 0.85) + + for hint in parsed.training_type_hints: + tid = _resolve_name_hint(cur, "training_types", hint) + if tid: + tt[tid] = max(tt.get(tid, 0.0), 0.85) + + for hint in parsed.target_group_hints: + gid = _resolve_name_hint(cur, "target_groups", hint) + if gid: + tg[gid] = max(tg.get(gid, 0.0), 0.85) + + for sh in parsed.skill_hints[:8]: + cur.execute( + """ + SELECT id, name FROM skills + WHERE (status IS NULL OR status = 'active') + AND LOWER(name) LIKE %s + ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END, + LENGTH(name) ASC + LIMIT 1 + """, + (f"%{sh.name.lower()}%", sh.name.lower(), f"{sh.name.lower()}%"), + ) + row = cur.fetchone() + if row: + sid = int(row["id"]) + skills[sid] = max(skills.get(sid, 0.0), float(sh.weight)) + resolved_skills.append({"skill_id": sid, "name": str(row["name"] or sh.name), "weight": skills[sid]}) + + return focus, style, tt, tg, skills, resolved_skills + + +def try_parse_planning_query_intent( + cur, + *, + query: str, + heuristic_intent: str, + scenario_hint: str, + context_summary: Mapping[str, Any], + target_profile_summary: Mapping[str, Any], +) -> Tuple[Optional[PlanningQueryIntentParsed], bool]: + api_key, _ = normalize_openrouter_env() + if not api_key or not (query or "").strip(): + return None, False + + variables = { + "search_query": (query or "").strip(), + "heuristic_intent": heuristic_intent or "", + "scenario_hint": scenario_hint or "", + "planning_context_json": _compact_json(dict(context_summary or {})), + "target_profile_json": _compact_json(dict(target_profile_summary or {})), + "skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)), + "focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")), + "training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")), + "style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")), + "target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")), + } + + try: + prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_intent", variables) + model = effective_openrouter_model_for_prompt_row(prow) + raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text) + parsed = parse_planning_query_intent_response(raw) + return parsed, True + except AiPromptUnavailableError: + return None, False + except Exception as exc: + _logger.warning("Planungs-Intent-LLM fehlgeschlagen: %s", exc) + return None, False + + +__all__ = [ + "PlanningQueryIntentParsed", + "parse_planning_query_intent_response", + "resolve_query_intent_catalog_ids", + "try_parse_planning_query_intent", +] diff --git a/backend/planning_exercise_profiles.py b/backend/planning_exercise_profiles.py index 9f503ed..b346cba 100644 --- a/backend/planning_exercise_profiles.py +++ b/backend/planning_exercise_profiles.py @@ -107,6 +107,7 @@ class PlanningTargetProfile: target_group_ids: Dict[int, float] = field(default_factory=dict) skill_weights: Dict[int, float] = field(default_factory=dict) skill_gap_weights: Dict[int, float] = field(default_factory=dict) + skill_plan_weights: Dict[int, float] = field(default_factory=dict) sources: List[str] = field(default_factory=list) def to_summary_dict(self, cur, limit_skills: int = 5) -> Dict[str, Any]: @@ -385,6 +386,7 @@ def build_planning_target_profile( target_group_ids=_normalize_weight_map(tg) if tg else tg, skill_weights=skill_target, skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else skill_gap, + skill_plan_weights=skill_plan_norm, sources=sources, ) @@ -420,6 +422,8 @@ def score_exercise_against_target( reasons.append("Fähigkeiten-Schwerpunkt passend (Profilmetrik)") if gap_sim >= 0.25 and target.skill_gap_weights: reasons.append("Deckt Skill-Lücke im bisherigen Plan") + if "query_intent" in (target.sources or []): + reasons.append("Passt zur KI-interpretierten Suchanfrage") # Intent-gewichtete Dimensionen (Summe = 1.0) if intent == INTENT_FREE_SEARCH: diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 83f31e4..47aef4a 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -13,11 +13,14 @@ from pydantic import BaseModel, Field from tenant_context import TenantContext, library_content_visibility_sql from planning_exercise_profiles import ( - build_planning_target_profile, load_exercise_match_profiles_bulk, score_exercise_against_target, ) from planning_exercise_llm_rank import try_llm_rerank_planning_hits +from planning_exercise_target_pipeline import ( + build_planning_target_with_query_pipeline, + compose_retrieval_phase, +) # Planungs-Berechtigung + Sektionen (bestehende Implementierung) from routers.training_planning import ( @@ -54,6 +57,7 @@ class PlanningExerciseSuggestRequest(BaseModel): query: Optional[str] = "" intent_hint: Optional[str] = None planned_exercise_ids: Optional[List[int]] = None + include_llm_intent: bool = True include_llm_rank: bool = False limit: int = Field(default=20, ge=1, le=50) exercise_kind_any: Optional[List[str]] = None @@ -369,16 +373,30 @@ def suggest_planning_exercises( pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body) pack = _apply_client_planned_override(cur, pack, body) query = _normalize_query(body.query) - intent = resolve_planning_exercise_intent(query, body.intent_hint) - weights = _intent_weights(intent) - target_profile = build_planning_target_profile( + heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint) + + pipeline_context = { + "unit_title": pack.get("unit_title"), + "group_name": pack.get("group_name"), + "section_title": pack.get("section_title"), + "planned_count": len(pack.get("planned_exercise_ids") or []), + "anchor_title": pack.get("anchor_title"), + "anchor_exercise_id": pack.get("anchor_exercise_id"), + "progression_graph_id": pack.get("progression_graph_id"), + } + target_profile, intent, scenario_kind, query_intent_summary = build_planning_target_with_query_pipeline( cur, unit=pack["unit"], planned_exercise_ids=pack["planned_exercise_ids"], anchor_exercise_id=pack.get("anchor_exercise_id"), - intent=intent, + query=query, + heuristic_intent=heuristic_intent, + include_llm_intent=body.include_llm_intent, + context_summary=pipeline_context, ) + weights = _intent_weights(intent) target_profile_summary = target_profile.to_summary_dict(cur) + query_intent_applied = bool(query_intent_summary.get("llm_applied")) profile_id = tenant.profile_id role = tenant.global_role @@ -540,7 +558,7 @@ def suggest_planning_exercises( hits.sort(key=lambda h: (-h["score"], h.get("title") or "")) llm_applied = False - retrieval_phase = "profile_v1" + retrieval_phase = compose_retrieval_phase(query_intent=query_intent_applied, llm_rank=False) if body.include_llm_rank: pre_limit = max(int(body.limit), _LLM_RERANK_PRE_LIMIT) pool_hits = hits[:pre_limit] @@ -562,7 +580,10 @@ def suggest_planning_exercises( limit=int(body.limit), ) if llm_applied: - retrieval_phase = "profile_v1+llm_rank" + retrieval_phase = compose_retrieval_phase( + query_intent=query_intent_applied, + llm_rank=True, + ) tail = hits[pre_limit:] hits = pool_hits + tail else: @@ -585,9 +606,12 @@ def suggest_planning_exercises( return { "context_summary": context_summary, "target_profile_summary": target_profile_summary, + "scenario_kind": scenario_kind, + "query_intent_summary": query_intent_summary, "retrieval_phase": retrieval_phase, "llm_rank_applied": llm_applied, "intent_resolved": intent, + "intent_heuristic": heuristic_intent, "query_normalized": query or None, "hits": hits, } diff --git a/backend/planning_exercise_target_pipeline.py b/backend/planning_exercise_target_pipeline.py new file mode 100644 index 0000000..f76763b --- /dev/null +++ b/backend/planning_exercise_target_pipeline.py @@ -0,0 +1,284 @@ +""" +Szenario-Routing und Erwartungsprofil-Pipeline für Planungs-Übungssuche (P1). + +Ablauf: + 1. Heuristik: Intent + Szenario-Klasse aus Query/Kontext + 2. Optional LLM (planning_exercise_search_intent) bei komplexen Anfragen + 3. Deterministisches Basis-Profil (Rahmen, Plan, Anker) + 4. Query-Overlay mergen → PlanningTargetProfile für Vorselektion +""" +from __future__ import annotations + +import re +from typing import Any, Dict, List, Mapping, Optional, Tuple + +from planning_exercise_intent import ( + PlanningQueryIntentParsed, + resolve_query_intent_catalog_ids, + try_parse_planning_query_intent, +) +from planning_exercise_profiles import ( + PlanningTargetProfile, + _merge_weight_maps, + _normalize_weight_map, + build_planning_target_profile, +) + +SCENARIO_PRESET_NEXT = "preset_next" +SCENARIO_PROGRESSION = "progression" +SCENARIO_DEEPEN = "deepen" +SCENARIO_CONTINUE_PLAN = "continue_plan" +SCENARIO_ADDITIVE = "additive_constraint" +SCENARIO_FREE_SEARCH = "free_search" + +_SIMPLE_PRESET_PATTERNS = ( + r"^(schlage?\s+(mir\s+)?(die\s+)?(n[aä]chste|naechste)\s+(sinnvolle\s+)?(übung|uebung)\s*(vor)?\.?)$", + r"^(n[aä]chste|naechste)\s+(übung|uebung)\s*(vorschlag|vorschlagen|empfehl\w*)?\.?$", + r"^(vorschlag|vorschlagen|empfehl\w*)\s*(für|fuer)?\s*(die\s+)?(n[aä]chste|naechste)?\s*(übung|uebung)?\.?$", + r"^n[aä]chste\s+übung$", + r"^n[aä]chste\s+uebung$", +) + +_ADDITIVE_MARKERS = ( + "zusätzlich", + "zusaetzlich", + "auch ", + " außerdem", + " ausserdem", + " dazu", + " extra", + " mehr ", + " und dabei", + " sowie ", +) + + +def _normalize_query(q: Optional[str]) -> str: + return re.sub(r"\s+", " ", (q or "").strip()) + + +def is_simple_preset_query(query: Optional[str]) -> bool: + q = _normalize_query(query).lower() + if not q: + return True + for pat in _SIMPLE_PRESET_PATTERNS: + if re.match(pat, q, flags=re.IGNORECASE): + return True + return False + + +def classify_planning_scenario( + query: Optional[str], + heuristic_intent: str, +) -> str: + q = _normalize_query(query).lower() + if not q or is_simple_preset_query(q): + return SCENARIO_PRESET_NEXT + if heuristic_intent == "progression_next": + return SCENARIO_PROGRESSION + if heuristic_intent == "deepen_exercise": + return SCENARIO_DEEPEN + if any(m in f" {q} " for m in _ADDITIVE_MARKERS): + return SCENARIO_ADDITIVE + if heuristic_intent == "continue_plan_goal": + return SCENARIO_CONTINUE_PLAN + if heuristic_intent == "free_search": + return SCENARIO_FREE_SEARCH + if heuristic_intent == "suggest_next": + return SCENARIO_CONTINUE_PLAN + return SCENARIO_FREE_SEARCH + + +def should_run_llm_intent_pipeline( + query: Optional[str], + scenario: str, + *, + include_llm_intent: bool, +) -> bool: + if not include_llm_intent: + return False + if scenario == SCENARIO_PRESET_NEXT: + return False + return bool(_normalize_query(query)) + + +def _recalculate_skill_gap(target: PlanningTargetProfile) -> PlanningTargetProfile: + skill_target = _normalize_weight_map(dict(target.skill_weights)) + skill_plan_norm = _normalize_weight_map(dict(target.skill_plan_weights)) + skill_gap: Dict[int, float] = {} + for sid, tw in skill_target.items(): + pw = skill_plan_norm.get(sid, 0.0) + gap = tw - pw * 0.85 + if gap > 0.08: + skill_gap[sid] = gap + sources = list(target.sources) + if skill_gap and "skill_gap_vs_plan" not in sources: + sources.append("skill_gap_vs_plan") + elif not skill_gap: + sources = [s for s in sources if s != "skill_gap_vs_plan"] + return PlanningTargetProfile( + focus_area_ids=target.focus_area_ids, + style_direction_ids=target.style_direction_ids, + training_type_ids=target.training_type_ids, + target_group_ids=target.target_group_ids, + skill_weights=skill_target, + skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else {}, + skill_plan_weights=target.skill_plan_weights, + sources=sources, + ) + + +def merge_query_overlay_into_target( + base: PlanningTargetProfile, + *, + focus: Dict[int, float], + style: Dict[int, float], + tt: Dict[int, float], + tg: Dict[int, float], + skills: Dict[int, float], + emphasis: str = "additive", + scenario: str, +) -> PlanningTargetProfile: + sources = list(base.sources) + if "query_intent" not in sources: + sources.append("query_intent") + + if emphasis == "replace" or scenario == SCENARIO_FREE_SEARCH: + skill_w = _merge_weight_maps({}, skills, scale=1.0) + if skills: + skill_w = _normalize_weight_map(_merge_weight_maps(base.skill_weights, skills, scale=0.55)) + if emphasis == "replace": + skill_w = _normalize_weight_map(skills) + focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.5 if emphasis == "replace" else 0.85) + style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.5) + tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.5) + tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.5) + else: + skill_scale = 1.0 if scenario == SCENARIO_ADDITIVE else 0.85 + skill_w = _merge_weight_maps(base.skill_weights, skills, scale=skill_scale) + focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.9) + style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.75) + tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.75) + tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.75) + + out = PlanningTargetProfile( + focus_area_ids=_normalize_weight_map(focus_w) if focus_w else focus_w, + style_direction_ids=_normalize_weight_map(style_w) if style_w else style_w, + training_type_ids=_normalize_weight_map(tt_w) if tt_w else tt_w, + target_group_ids=_normalize_weight_map(tg_w) if tg_w else tg_w, + skill_weights=_normalize_weight_map(skill_w) if skill_w else skill_w, + skill_gap_weights=dict(base.skill_gap_weights), + skill_plan_weights=dict(base.skill_plan_weights), + sources=sources, + ) + return _recalculate_skill_gap(out) + + +def build_planning_target_with_query_pipeline( + cur, + *, + unit: Dict[str, Any], + planned_exercise_ids: List[int], + anchor_exercise_id: Optional[int], + query: Optional[str], + heuristic_intent: str, + include_llm_intent: bool, + context_summary: Mapping[str, Any], +) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]: + """ + Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict + """ + scenario = classify_planning_scenario(query, heuristic_intent) + resolved_intent = heuristic_intent + llm_applied = False + parsed: Optional[PlanningQueryIntentParsed] = None + resolved_skills: List[Dict[str, Any]] = [] + + base = build_planning_target_profile( + cur, + unit=unit, + planned_exercise_ids=planned_exercise_ids, + anchor_exercise_id=anchor_exercise_id, + intent=heuristic_intent, + ) + base_summary = base.to_summary_dict(cur) + + if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent): + parsed, llm_applied = try_parse_planning_query_intent( + cur, + query=_normalize_query(query), + heuristic_intent=heuristic_intent, + scenario_hint=scenario, + context_summary=context_summary, + target_profile_summary=base_summary, + ) + + target = base + if parsed and llm_applied: + if parsed.intent in { + "suggest_next", + "progression_next", + "deepen_exercise", + "continue_plan_goal", + "free_search", + }: + resolved_intent = parsed.intent + if parsed.scenario in VALID_SCENARIOS_SET: + scenario = parsed.scenario + + focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed) + if focus or style or tt or tg or skills: + target = merge_query_overlay_into_target( + base, + focus=focus, + style=style, + tt=tt, + tg=tg, + skills=skills, + emphasis=parsed.emphasis, + scenario=scenario, + ) + + query_intent_summary: Dict[str, Any] = { + "scenario": scenario, + "intent": resolved_intent, + "heuristic_intent": heuristic_intent, + "llm_applied": llm_applied, + "emphasis": parsed.emphasis if parsed else None, + "rationale": (parsed.rationale if parsed else None), + "skill_hints_resolved": resolved_skills, + "requires_partner": parsed.requires_partner if parsed else None, + } + + return target, resolved_intent, scenario, query_intent_summary + + +VALID_SCENARIOS_SET = { + SCENARIO_PRESET_NEXT, + SCENARIO_PROGRESSION, + SCENARIO_DEEPEN, + SCENARIO_CONTINUE_PLAN, + SCENARIO_ADDITIVE, + SCENARIO_FREE_SEARCH, +} + + +def compose_retrieval_phase(*, query_intent: bool, llm_rank: bool) -> str: + parts = ["profile_v1"] + if query_intent: + parts.append("query_intent") + if llm_rank: + parts.append("llm_rank") + return "+".join(parts) + + +__all__ = [ + "SCENARIO_ADDITIVE", + "SCENARIO_PRESET_NEXT", + "build_planning_target_with_query_pipeline", + "classify_planning_scenario", + "compose_retrieval_phase", + "is_simple_preset_query", + "merge_query_overlay_into_target", + "should_run_llm_intent_pipeline", +] diff --git a/backend/tests/test_planning_exercise_suggest.py b/backend/tests/test_planning_exercise_suggest.py index 293f47c..bd0ee25 100644 --- a/backend/tests/test_planning_exercise_suggest.py +++ b/backend/tests/test_planning_exercise_suggest.py @@ -1,6 +1,15 @@ -"""Tests für Planungs-Übungssuche (Intent, LLM-Rerank-Parser).""" +"""Tests Planungs-Übungssuche: Intent, Szenario-Pipeline, LLM-Parser.""" from planning_exercise_suggest import resolve_planning_exercise_intent +from planning_exercise_intent import parse_planning_query_intent_response from planning_exercise_llm_rank import parse_planning_exercise_rank_response +from planning_exercise_target_pipeline import ( + SCENARIO_ADDITIVE, + SCENARIO_PRESET_NEXT, + classify_planning_scenario, + compose_retrieval_phase, + is_simple_preset_query, + should_run_llm_intent_pipeline, +) def test_resolve_planning_exercise_intent_defaults(): @@ -14,6 +23,43 @@ def test_resolve_planning_exercise_intent_keywords(): assert resolve_planning_exercise_intent("progression graph", None) == "progression_next" +def test_classify_planning_scenario_preset(): + assert is_simple_preset_query("Schlage mir die nächste Übung vor") + assert classify_planning_scenario("", "suggest_next") == SCENARIO_PRESET_NEXT + assert classify_planning_scenario("nächste übung", "suggest_next") == SCENARIO_PRESET_NEXT + + +def test_classify_planning_scenario_additive(): + q = "Baut auf der Planung auf und trainiert zusätzlich Schnellkraft" + assert classify_planning_scenario(q, "continue_plan_goal") == SCENARIO_ADDITIVE + assert should_run_llm_intent_pipeline(q, SCENARIO_ADDITIVE, include_llm_intent=True) + + +def test_should_skip_llm_for_preset(): + assert not should_run_llm_intent_pipeline("", SCENARIO_PRESET_NEXT, include_llm_intent=True) + assert not should_run_llm_intent_pipeline( + "nächste übung", + SCENARIO_PRESET_NEXT, + include_llm_intent=True, + ) + + +def test_compose_retrieval_phase(): + assert compose_retrieval_phase(query_intent=False, llm_rank=False) == "profile_v1" + assert compose_retrieval_phase(query_intent=True, llm_rank=True) == "profile_v1+query_intent+llm_rank" + + +def test_parse_planning_query_intent_response(): + parsed = parse_planning_query_intent_response( + '{"intent":"continue_plan_goal","scenario":"additive_constraint",' + '"skill_hints":[{"name":"Schnellkraft","weight":1}],"emphasis":"additive",' + '"rationale":"Zusatz Schnellkraft"}' + ) + assert parsed.intent == "continue_plan_goal" + assert parsed.scenario == "additive_constraint" + assert parsed.skill_hints[0].name == "Schnellkraft" + + def test_parse_planning_exercise_rank_response_filters_ids(): allowed = {10, 20, 30} ranked, reasons = parse_planning_exercise_rank_response( @@ -23,12 +69,3 @@ def test_parse_planning_exercise_rank_response_filters_ids(): assert ranked == [20, 10] assert reasons[20] == "Passt gut" assert 999 not in reasons - - -def test_parse_planning_exercise_rank_response_reasons_by_id_alias(): - ranked, reasons = parse_planning_exercise_rank_response( - '{"ranked_ids":[5],"reasons_by_id":{"5":"Skill-Lücke"}}', - {5}, - ) - assert ranked == [5] - assert reasons[5] == "Skill-Lücke" diff --git a/backend/version.py b/backend/version.py index c5ced1c..c0cb927 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.170" +APP_VERSION = "0.8.171" BUILD_DATE = "2026-05-22" -DB_SCHEMA_VERSION = "20260531072" +DB_SCHEMA_VERSION = "20260531073" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -27,8 +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.36.0", # Planungs-KI P2: LLM-Rerank + Client planned_exercise_ids - "planning_exercise_suggest": "0.3.0", # include_llm_rank, planned_exercise_ids Override + "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay + "planning_exercise_suggest": "0.4.0", # include_llm_intent, scenario_kind, query_intent_summary "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -43,6 +43,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.171", + "date": "2026-05-22", + "changes": [ + "Planungs-KI P1: Szenario-Pipeline (preset/progression/additive/…) + LLM Intent planning_exercise_search_intent → Erwartungsprofil-Overlay.", + "API: scenario_kind, query_intent_summary, include_llm_intent; Migration 073.", + ], + }, { "version": "0.8.170", "date": "2026-05-22", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index bc852cf..59a3904 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -89,10 +89,9 @@ 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.170**) +### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.171**) -- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4. -- **Planungs-Übungssuche (P2):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Hybrid + Profil-Score + optional **LLM-Rerank** (`include_llm_rank`, Prompt `planning_exercise_search_rank`); Client **`planned_exercise_ids`**; **`POST /api/planning/exercise-suggest`**; **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**. +- **Planungs-Übungssuche (P1):** Szenario-Pipeline + **LLM Query-Intent** (`planning_exercise_search_intent`) → Erwartungsprofil-Overlay; danach Hybrid + optional LLM-Rerank — `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §16. - **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/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 133d802..5010408 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -69,6 +69,7 @@ export default function ExercisePickerModal({ const [planningContextSummary, setPlanningContextSummary] = useState(null) const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null) const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false) + const [planningQueryIntentSummary, setPlanningQueryIntentSummary] = useState(null) const [planningIntentResolved, setPlanningIntentResolved] = useState(null) const pickerScrollRef = useRef(null) @@ -157,6 +158,7 @@ export default function ExercisePickerModal({ setPlanningContextSummary(null) setPlanningTargetProfileSummary(null) setPlanningLlmRankApplied(false) + setPlanningQueryIntentSummary(null) setPlanningIntentResolved(null) return } @@ -277,6 +279,7 @@ export default function ExercisePickerModal({ Array.isArray(planningContext.plannedExerciseIds) && planningContext.plannedExerciseIds.length > 0 ? planningContext.plannedExerciseIds.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0) : undefined, + include_llm_intent: Boolean(query), include_llm_rank: true, query, intent_hint: planningContext.intentHint || null, @@ -287,6 +290,7 @@ export default function ExercisePickerModal({ setPlanningContextSummary(res?.context_summary || null) setPlanningTargetProfileSummary(res?.target_profile_summary || null) setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied)) + setPlanningQueryIntentSummary(res?.query_intent_summary || null) setPlanningIntentResolved(res?.intent_resolved || null) const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({ id: h.id, @@ -303,6 +307,7 @@ export default function ExercisePickerModal({ setPlanningContextSummary(null) setPlanningTargetProfileSummary(null) setPlanningLlmRankApplied(false) + setPlanningQueryIntentSummary(null) setPlanningIntentResolved(null) const batch = await api.listExercises({ ...queryBase, @@ -322,6 +327,7 @@ export default function ExercisePickerModal({ setPlanningContextSummary(null) setPlanningTargetProfileSummary(null) setPlanningLlmRankApplied(false) + setPlanningQueryIntentSummary(null) setPlanningIntentResolved(null) } finally { setLoading(false) @@ -545,10 +551,19 @@ export default function ExercisePickerModal({ Skill-Lücke zum bisherigen Plan berücksichtigt

) : null} + {planningQueryIntentSummary?.rationale ? ( +

+ {planningQueryIntentSummary.rationale} +

+ ) : null} {planningIntentResolved ? (

Modus: {planningIntentResolved.replace(/_/g, ' ')} + {planningQueryIntentSummary?.scenario + ? ` · ${String(planningQueryIntentSummary.scenario).replace(/_/g, ' ')}` + : null} {planningLlmRankApplied ? ' · KI-Ranking aktiv' : null} + {planningQueryIntentSummary?.llm_applied ? ' · KI-Intent aktiv' : null}

) : null}
-- 2.43.0 From 905bce198fefae8aea904f898544bbb4ed46fe8d Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 22:21:06 +0200 Subject: [PATCH 05/16] Refactor ExercisePickerModal to Utilize Effective Query for AI Suggestions - Introduced `effectivePickerQuery` to streamline search input handling, combining `debouncedSearch` and `debouncedAi` for improved query accuracy. - Updated the `useExerciseAiQuickCreateFields` hook to use the new effective query, enhancing the quick create functionality. - Modified conditional checks to utilize `effectivePickerQuery`, ensuring better user feedback based on search input. - Improved placeholder text and labels for clarity in the search fields, enhancing user experience during exercise selection. --- .../src/components/ExercisePickerModal.jsx | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 5010408..9661100 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -75,6 +75,11 @@ export default function ExercisePickerModal({ const usePlanningSearch = Boolean(planningContext?.unitId && Number(planningContext.unitId) > 0) + const effectivePickerQuery = useMemo( + () => [debouncedSearch, debouncedAi].filter(Boolean).join(' ').trim(), + [debouncedSearch, debouncedAi] + ) + const { title: quickTitle, sketch: quickSketch, @@ -83,7 +88,7 @@ export default function ExercisePickerModal({ setSketch: setQuickSketch, setFocusAreaId: setQuickFocusAreaId, resetQuickCreateFields, - } = useExerciseAiQuickCreateFields(debouncedSearch, { enabled: open && enableQuickCreateDraft }) + } = useExerciseAiQuickCreateFields(effectivePickerQuery, { enabled: open && enableQuickCreateDraft }) const toggleMultiPick = (ex) => { setMultiPicked((prev) => @@ -106,7 +111,7 @@ export default function ExercisePickerModal({ catalogsReady && !loading && list.length === 0 && - (usePlanningSearch || debouncedSearch.length >= 3) + (usePlanningSearch || effectivePickerQuery.length >= 3) useEffect(() => { if (!open) return @@ -246,6 +251,7 @@ export default function ExercisePickerModal({ if (filters.include_archived) q.include_archived = true if (debouncedSearch) q.search = debouncedSearch if (debouncedAi) q.ai_search = debouncedAi + if (!debouncedSearch && debouncedAi) q.search = debouncedAi if ( Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 @@ -260,7 +266,7 @@ export default function ExercisePickerModal({ setLoading(true) try { if (usePlanningSearch) { - const query = [debouncedSearch, debouncedAi].filter(Boolean).join(' ').trim() + const query = effectivePickerQuery const res = await api.suggestPlanningExercises({ unit_id: Number(planningContext.unitId), section_order_index: @@ -338,6 +344,7 @@ export default function ExercisePickerModal({ queryBase, usePlanningSearch, planningContext, + effectivePickerQuery, debouncedSearch, debouncedAi, exerciseKindAny, @@ -571,14 +578,14 @@ export default function ExercisePickerModal({
setAiSearchInput(e.target.value)} autoComplete="off" /> + {usePlanningSearch ? ( +

+ Beide Felder bilden eine gemeinsame Planungs-Anfrage. +

+ ) : null}
) : null} + {!usePlanningSearch ? ( +

+ Bibliothekssuche (Volltext) — Planungs-KI mit + Kontext (Einheit, Plan, Anker) gibt es in der{' '} + Trainingseinheit bearbeiten, nach dem Speichern der Einheit. +

+ ) : null}
-
- - setSearchInput(e.target.value)} - autoComplete="off" - /> -
-
- - setAiSearchInput(e.target.value)} - autoComplete="off" - /> - {usePlanningSearch ? ( + {usePlanningSearch ? ( +
+ + { + const v = e.target.value + setSearchInput(v) + setAiSearchInput(v) + }} + autoComplete="off" + />

- Beide Felder bilden eine gemeinsame Planungs-Anfrage. + Leer lassen = nächste Übung aus Planungskontext. Mit Text = KI-Intent + Profil + Ranking.

- ) : null} -
+
+ ) : ( + <> +
+ + setSearchInput(e.target.value)} + autoComplete="off" + /> +
+
+ + setAiSearchInput(e.target.value)} + autoComplete="off" + /> +
+ + )}
) : null} - {!usePlanningSearch ? ( + {planningSearchBlocked ? ( +

+ Planungs-KI noch nicht verfügbar. Die Einheit hat noch keine gespeicherte ID — + bitte zuerst Speichern, dann den Übungspicker erneut öffnen. Bis dahin gilt nur die + Bibliothekssuche (Volltext). +

+ ) : null} + {!usePlanningSearch && !planningSearchBlocked ? (

0 ? unitId : null) + } planningContext={exercisePickerPlanningContext} onClose={() => { setExercisePickerOpen(false) -- 2.43.0 From 614c2dcfaa01c9345cbb862f7db5391a66d611fb Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 22:38:21 +0200 Subject: [PATCH 08/16] Enhance Planning Exercise Suggestion with Client Context and Group ID Support - Made `unit_id` and `group_id` optional in `PlanningExerciseSuggestRequest` to support client context without a saved unit. - Refactored `_load_group_recent_exercise_ids` to handle cases where `exclude_unit_id` is optional. - Introduced `build_client_planning_context_pack` for improved context handling in client-free searches. - Updated `suggest_planning_exercises` to utilize the new client context pack when `unit_id` is not provided. - Incremented version to 0.8.172 and updated changelog to reflect these enhancements in the planning AI capabilities. --- backend/planning_exercise_suggest.py | 123 +++++++++++++++--- backend/version.py | 12 +- .../src/components/ExercisePickerModal.jsx | 96 +++++++++++--- .../components/TrainingUnitSectionsEditor.jsx | 34 ++++- .../planning/TrainingUnitFormShell.jsx | 2 + .../TrainingFrameworkProgramEditPage.jsx | 86 ++++++++++-- frontend/src/pages/TrainingUnitEditPage.jsx | 44 +++++-- 7 files changed, 330 insertions(+), 67 deletions(-) diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 47aef4a..8f5617b 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -48,7 +48,8 @@ _LLM_RERANK_PRE_LIMIT = 32 class PlanningExerciseSuggestRequest(BaseModel): - unit_id: int = Field(..., ge=1) + unit_id: Optional[int] = Field(default=None, ge=1) + group_id: Optional[int] = Field(default=None, 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) @@ -196,26 +197,42 @@ def _load_progression_successors( def _load_group_recent_exercise_ids( cur, group_id: Optional[int], - exclude_unit_id: int, + exclude_unit_id: Optional[int] = None, 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)), - ) + if exclude_unit_id is not None: + 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)), + ) + else: + 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 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),), + ) out: Set[int] = set() for r in cur.fetchall(): if r.get("eid") is None: @@ -364,13 +381,82 @@ def build_planning_exercise_context_pack( } +def build_client_planning_context_pack( + cur, + *, + tenant: TenantContext, + body: PlanningExerciseSuggestRequest, +) -> Dict[str, Any]: + """Freie / Client-Kontext-Suche ohne persistierte training_units.id (Formular, Rahmen-Slot).""" + role = tenant.global_role + if not _has_planning_role(role): + raise HTTPException(status_code=403, detail="Nur Trainer dürfen Planungs-Vorschläge abrufen") + + planned_ids: List[int] = [] + if body.planned_exercise_ids: + seen: Set[int] = set() + for raw in body.planned_exercise_ids: + try: + eid = int(raw) + except (TypeError, ValueError): + continue + if eid < 1 or eid in seen: + continue + seen.add(eid) + planned_ids.append(eid) + + 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_id = body.group_id + group_name = None + if group_id: + cur.execute("SELECT name FROM training_groups WHERE id = %s", (int(group_id),)) + gr = cur.fetchone() + if gr: + group_name = (gr.get("name") or "").strip() or None + + group_recent = _load_group_recent_exercise_ids(cur, group_id, exclude_unit_id=None) + 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": None, + "unit": { + "id": None, + "framework_slot_id": None, + "origin_framework_slot_id": None, + }, + "unit_title": None, + "group_id": group_id, + "group_name": group_name, + "section_order_index": body.section_order_index, + "section_title": None, + "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), + "context_mode": "client_free", + } + + def suggest_planning_exercises( cur, *, tenant: TenantContext, body: PlanningExerciseSuggestRequest, ) -> Dict[str, Any]: - pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body) + if body.unit_id: + pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body) + else: + pack = build_client_planning_context_pack(cur, tenant=tenant, body=body) pack = _apply_client_planned_override(cur, pack, body) query = _normalize_query(body.query) heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint) @@ -601,6 +687,7 @@ def suggest_planning_exercises( "anchor_title": pack.get("anchor_title"), "anchor_exercise_id": pack.get("anchor_exercise_id"), "progression_graph_id": pack.get("progression_graph_id"), + "context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"), } return { diff --git a/backend/version.py b/backend/version.py index c0cb927..4808aa8 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.171" +APP_VERSION = "0.8.172" BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260531073" @@ -28,7 +28,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay - "planning_exercise_suggest": "0.4.0", # include_llm_intent, scenario_kind, query_intent_summary + "planning_exercise_suggest": "0.4.1", # unit_id optional; client_free Kontext; group_id "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -43,6 +43,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.172", + "date": "2026-05-22", + "changes": [ + "Planungs-KI UI: Menüpunkt „Planungs-KI: Übung vorschlagen“ im +-Dialog; Freitext-Suche ohne gespeicherte unit_id (client_free).", + "API exercise-suggest: unit_id optional, group_id für Client-Kontext.", + ], + }, { "version": "0.8.171", "date": "2026-05-22", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 7a28d26..e5a7518 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -40,7 +40,11 @@ export default function ExercisePickerModal({ enableQuickCreateDraft = false, /** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */ planningUnitId = null, - /** true auf TrainingUnitEditPage: Hinweis wenn Einheit noch keine ID hat. */ + /** 'planning' = Planungs-KI-API; 'library' = Volltext-Bibliothek */ + pickerMode = 'library', + /** Planungs-KI auch ohne gespeicherte unit_id (Client-Kontext / Freitext). */ + enableFreePlanningSearch = false, + /** true auf TrainingUnitEditPage: Hinweis wenn Planungs-KI ohne Einheit und ohne Freitext. */ expectPlanningSearch = false, /** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */ planningContext = null, @@ -84,9 +88,11 @@ export default function ExercisePickerModal({ }, [planningUnitId, planningContext?.unitId]) const activePlanningContext = useMemo(() => { - if (!resolvedPlanningUnitId) return null - return { - unitId: resolvedPlanningUnitId, + if (pickerMode !== 'planning') return null + const groupIdRaw = planningContext?.groupId + const groupId = Number(groupIdRaw) + const base = { + groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null, sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0, phaseOrderIndex: planningContext?.phaseOrderIndex ?? null, parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null, @@ -97,10 +103,24 @@ export default function ExercisePickerModal({ : [], intentHint: planningContext?.intentHint ?? null, } - }, [resolvedPlanningUnitId, planningContext]) + if (!resolvedPlanningUnitId) { + if (!enableFreePlanningSearch && !planningContext) return null + return { unitId: null, ...base } + } + return { + unitId: resolvedPlanningUnitId, + ...base, + } + }, [pickerMode, resolvedPlanningUnitId, enableFreePlanningSearch, planningContext]) - const usePlanningSearch = resolvedPlanningUnitId != null - const planningSearchBlocked = Boolean(expectPlanningSearch && !usePlanningSearch) + const usePlanningSearch = pickerMode === 'planning' && activePlanningContext != null + const useFreePlanningSearch = usePlanningSearch && !resolvedPlanningUnitId + const planningSearchBlocked = Boolean( + pickerMode === 'planning' && + expectPlanningSearch && + !resolvedPlanningUnitId && + !enableFreePlanningSearch + ) /** Gemeinsamer Suchtext — in Planung nur ein Feld; in Bibliothek beide Felder kombiniert. */ const effectivePickerQuery = useMemo(() => { @@ -306,8 +326,7 @@ export default function ExercisePickerModal({ try { if (usePlanningSearch && activePlanningContext) { const query = effectivePickerQuery - const res = await api.suggestPlanningExercises({ - unit_id: Number(activePlanningContext.unitId), + const requestBody = { section_order_index: activePlanningContext.sectionOrderIndex != null ? Number(activePlanningContext.sectionOrderIndex) @@ -336,13 +355,20 @@ export default function ExercisePickerModal({ .filter((x) => Number.isFinite(x) && x > 0) : undefined, include_llm_intent: Boolean(query), - include_llm_rank: true, + include_llm_rank: Boolean(query), query, - intent_hint: activePlanningContext.intentHint || null, + intent_hint: activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null), limit: PAGE_SIZE, exercise_kind_any: Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined, - }) + } + if (resolvedPlanningUnitId) { + requestBody.unit_id = Number(resolvedPlanningUnitId) + } + if (activePlanningContext.groupId) { + requestBody.group_id = Number(activePlanningContext.groupId) + } + const res = await api.suggestPlanningExercises(requestBody) setPlanningContextSummary(res?.context_summary || null) setPlanningTargetProfileSummary(res?.target_profile_summary || null) setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied)) @@ -397,6 +423,8 @@ export default function ExercisePickerModal({ activePlanningContext, effectivePickerQuery, exerciseKindAny, + resolvedPlanningUnitId, + useFreePlanningSearch, ]) useEffect(() => { @@ -643,12 +671,11 @@ export default function ExercisePickerModal({ lineHeight: 1.45, }} > - Planungs-KI noch nicht verfügbar. Die Einheit hat noch keine gespeicherte ID — - bitte zuerst Speichern, dann den Übungspicker erneut öffnen. Bis dahin gilt nur die - Bibliothekssuche (Volltext). + Planungs-KI noch nicht verfügbar. Bitte zuerst Speichern oder den + Menüpunkt Planungs-KI: Übung vorschlagen nutzen (Freitext ohne gespeicherte Einheit).

) : null} - {!usePlanningSearch && !planningSearchBlocked ? ( + {pickerMode === 'library' && expectPlanningSearch ? (

- Bibliothekssuche (Volltext) — Planungs-KI mit - Kontext (Einheit, Plan, Anker) gibt es in der{' '} - Trainingseinheit bearbeiten, nach dem Speichern der Einheit. + Bibliothekssuche (Volltext) — für Planungs-KI mit + Kontext oder Freitext-Anfrage den Menüpunkt{' '} + Planungs-KI: Übung vorschlagen … unter dem + wählen. +

+ ) : null} + {useFreePlanningSearch ? ( +

+ Freie Planungs-KI — Anker und bisherige Übungen aus + dem Formular; nach Speichern kommen Gruppe, Historie und Rahmen dazu. +

+ ) : null} + {!usePlanningSearch && !planningSearchBlocked && !expectPlanningSearch ? ( +

+ Bibliothekssuche (Volltext)

) : null}
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 8c65d3c..1955d1e 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -253,6 +253,7 @@ export default function TrainingUnitSectionsEditor({ sections, onSectionsChange, onRequestExercisePick, + onRequestPlanningExercisePick, onRequestTrainingModulePick, onPeekExercise, showExecutionExtras = false, @@ -2591,12 +2592,23 @@ export default function TrainingUnitSectionsEditor({

) : (
+ {onRequestPlanningExercisePick ? ( + + ) : null} + ) : null} {onRequestTrainingModulePick ? (
@@ -729,20 +782,51 @@ export default function ExercisePickerModal({ {usePlanningSearch ? (
- { - const v = e.target.value - setSearchInput(v) - setAiSearchInput(v) - }} - autoComplete="off" - /> +
+ { + const v = e.target.value + setSearchInput(v) + setAiSearchInput(v) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submitPlanningSearch() + } + }} + autoComplete="off" + /> + + +

- Leer lassen = nächste Übung aus Planungskontext. Mit Text = KI-Intent + Profil + Ranking. + Suche startet erst per Button (oder Enter) — nicht beim Tippen. LLM nur bei längeren Anfragen, + maximal ein KI-Call pro Suche.

) : ( @@ -943,11 +1027,13 @@ export default function ExercisePickerModal({ onRunAi={runQuickCreateAiSuggest} /> ) : ( -

+

{usePlanningSearch - ? effectivePickerQuery - ? 'Keine KI-Vorschläge für diese Anfrage.' - : 'Keine Vorschläge — Einheit speichern und Planungskontext prüfen, oder Anfrage eingeben.' + ? !planningHasSearched + ? 'Anfrage formulieren und „Vorschläge laden“ klicken — oder „Nächste aus Kontext“ ohne Freitext.' + : effectivePickerQuery + ? 'Keine KI-Vorschläge für diese Anfrage.' + : 'Keine Vorschläge aus dem Planungskontext — Anker, Plan oder Profil prüfen.' : effectivePickerQuery.length >= 3 ? 'Keine Treffer.' : 'Suchbegriff eingeben (mind. 3 Zeichen) …'} -- 2.43.0 From 04cc77d5011dc2deaef4a98d57cb4629a7329b06 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 23:00:31 +0200 Subject: [PATCH 11/16] Enhance Planning Exercise Profiles and Context Handling - Introduced new functions to generate skill profiles from exercise IDs, improving the ability to summarize skills for both units and sections. - Updated the planning target profile to incorporate section-specific exercise IDs, allowing for more granular skill tracking and context. - Enhanced the ExercisePickerModal and related pages to support section context, including titles, guidance notes, and exercise counts. - Implemented expectation mode handling in the planning target pipeline to differentiate between planning references and query-only scenarios. - Incremented version to 0.8.174 and updated changelog to reflect these enhancements in planning AI capabilities. --- backend/planning_exercise_profiles.py | 46 +++++ backend/planning_exercise_suggest.py | 158 +++++++++++++++++- backend/planning_exercise_target_pipeline.py | 37 +++- .../tests/test_planning_exercise_suggest.py | 24 +++ backend/version.py | 12 +- .../src/components/ExercisePickerModal.jsx | 52 +++++- .../TrainingFrameworkProgramEditPage.jsx | 25 +++ frontend/src/pages/TrainingUnitEditPage.jsx | 25 +++ 8 files changed, 361 insertions(+), 18 deletions(-) diff --git a/backend/planning_exercise_profiles.py b/backend/planning_exercise_profiles.py index b346cba..26df100 100644 --- a/backend/planning_exercise_profiles.py +++ b/backend/planning_exercise_profiles.py @@ -293,11 +293,50 @@ def _profile_from_unit_occurrences(cur, unit_id: int) -> Dict[int, float]: return _skill_weights_from_profile(prof.get("skills") or []) +def _profile_from_exercise_ids(cur, exercise_ids: Sequence[int]) -> Dict[int, float]: + ids = [int(x) for x in exercise_ids if int(x) > 0] + if not ids: + return {} + occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids] + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None) + return _skill_weights_from_profile(prof.get("skills") or []) + + +def skill_profile_summary_from_exercise_ids( + cur, + exercise_ids: Sequence[int], + *, + limit_skills: int = 8, +) -> Dict[str, Any]: + """Kompaktes Fähigkeitenprofil für LLM-Kontext und UI.""" + ids = [int(x) for x in exercise_ids if int(x) > 0] + if not ids: + return {"exercise_count": 0, "skills": []} + occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids] + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None) + skills_out = prof.get("skills") or [] + top = sorted(skills_out, key=lambda s: -float(s.get("weight") or s.get("score") or 0))[:limit_skills] + names = _load_skill_names(cur, [int(s["skill_id"]) for s in top if s.get("skill_id") is not None]) + return { + "exercise_count": len(ids), + "skills": [ + { + "skill_id": int(s["skill_id"]), + "name": names.get(int(s["skill_id"]), f"#{s['skill_id']}"), + "weight": round(float(s.get("weight") or s.get("score") or 0), 3), + } + for s in top + if s.get("skill_id") is not None + ], + } + + def build_planning_target_profile( cur, *, unit: Dict[str, Any], planned_exercise_ids: Sequence[int], + section_planned_exercise_ids: Optional[Sequence[int]] = None, anchor_exercise_id: Optional[int], intent: str, ) -> PlanningTargetProfile: @@ -356,6 +395,13 @@ def build_planning_target_profile( if skill_plan: sources.append("current_unit_plan") + section_ids = [int(x) for x in (section_planned_exercise_ids or []) if int(x) > 0] + if section_ids: + section_skills = _profile_from_exercise_ids(cur, section_ids) + if section_skills: + skill_target = _merge_weight_maps(skill_target, section_skills, scale=1.0) + sources.append("current_section_plan") + if anchor_exercise_id: anchor_profiles = load_exercise_match_profiles_bulk(cur, [int(anchor_exercise_id)]) ap = anchor_profiles.get(int(anchor_exercise_id)) diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index c9f0277..ffb53a0 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -12,6 +12,7 @@ from fastapi import HTTPException from pydantic import BaseModel, Field from tenant_context import TenantContext, library_content_visibility_sql +from planning_exercise_profiles import skill_profile_summary_from_exercise_ids from planning_exercise_retrieval import run_multistage_planning_retrieval from planning_exercise_llm_rank import try_llm_rerank_planning_hits from planning_exercise_target_pipeline import ( @@ -56,6 +57,9 @@ class PlanningExerciseSuggestRequest(BaseModel): query: Optional[str] = "" intent_hint: Optional[str] = None planned_exercise_ids: Optional[List[int]] = None + section_title: Optional[str] = None + section_guidance_notes: Optional[str] = None + section_planned_exercise_ids: Optional[List[int]] = None include_llm_intent: bool = True include_llm_rank: bool = False limit: int = Field(default=20, ge=1, le=50) @@ -241,6 +245,131 @@ def _load_group_recent_exercise_ids( return out +def _section_for_context( + sections: Sequence[Dict[str, Any]], + section_order_index: Optional[int], +) -> Optional[Dict[str, Any]]: + if section_order_index is None: + return None + target = int(section_order_index) + for sec in sections: + if int(sec.get("order_index") or -1) == target: + return sec + if 0 <= target < len(sections): + return sections[target] + return None + + +def _collect_exercise_ids_from_section(sec: Optional[Dict[str, Any]]) -> List[int]: + if not sec: + return [] + out: List[int] = [] + seen: Set[int] = set() + for it in sorted(sec.get("items") or [], 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_last_exercise_in_section(sec: Optional[Dict[str, Any]]) -> Tuple[Optional[int], Optional[str]]: + if not sec: + return None, None + last_id: Optional[int] = None + last_title: Optional[str] = None + for it in sorted(sec.get("items") or [], 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: + continue + last_id = eid + t = (it.get("exercise_title") or "").strip() + last_title = t or None + return last_id, last_title + + +def _attach_planning_context_details( + cur, + pack: Dict[str, Any], + *, + sections: Optional[Sequence[Dict[str, Any]]] = None, + body: Optional[PlanningExerciseSuggestRequest] = None, +) -> Dict[str, Any]: + """Abschnitt, Fähigkeitenprofile und letzte Übung anreichern.""" + sec: Optional[Dict[str, Any]] = None + section_idx = pack.get("section_order_index") + if sections is not None and section_idx is not None: + sec = _section_for_context(sections, section_idx) + + section_ids = _collect_exercise_ids_from_section(sec) + if body and body.section_planned_exercise_ids: + section_ids = [] + seen: Set[int] = set() + for raw in body.section_planned_exercise_ids: + try: + eid = int(raw) + except (TypeError, ValueError): + continue + if eid < 1 or eid in seen: + continue + seen.add(eid) + section_ids.append(eid) + elif pack.get("section_planned_exercise_ids"): + section_ids = list(pack.get("section_planned_exercise_ids") or []) + + section_title = pack.get("section_title") + if body and (body.section_title or "").strip(): + section_title = (body.section_title or "").strip() + elif sec and (sec.get("title") or "").strip(): + section_title = (sec.get("title") or "").strip() + + guidance = None + if body and (body.section_guidance_notes or "").strip(): + guidance = (body.section_guidance_notes or "").strip() + elif sec and (sec.get("guidance_notes") or "").strip(): + guidance = (sec.get("guidance_notes") or "").strip() + + last_in_section_id, last_in_section_title = _resolve_last_exercise_in_section(sec) + if body and not last_in_section_id and pack.get("anchor_exercise_id"): + last_in_section_id = pack.get("anchor_exercise_id") + last_in_section_title = pack.get("anchor_title") + + unit_ids = list(pack.get("planned_exercise_ids") or []) + pack["section_title"] = section_title + pack["section_guidance_notes"] = guidance + pack["section_planned_exercise_ids"] = section_ids + pack["section_exercise_count"] = len(section_ids) + pack["last_section_exercise_id"] = last_in_section_id + pack["last_section_exercise_title"] = last_in_section_title + pack["unit_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, unit_ids) + pack["section_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, section_ids) + pack["has_planning_reference"] = bool( + unit_ids + or section_ids + or pack.get("anchor_exercise_id") + or (pack.get("unit") or {}).get("framework_slot_id") + or (pack.get("unit") or {}).get("origin_framework_slot_id") + ) + return pack + + 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 @@ -348,7 +477,7 @@ def build_planning_exercise_context_pack( 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 { + pack = { "unit_id": int(body.unit_id), "unit": { "id": int(body.unit_id), @@ -369,6 +498,7 @@ def build_planning_exercise_context_pack( "progression_edge_notes": progression_notes, "group_recent_exercise_ids": sorted(group_recent), } + return _attach_planning_context_details(cur, pack, sections=sections, body=body) def build_client_planning_context_pack( @@ -413,7 +543,7 @@ def build_client_planning_context_pack( 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 { + pack = { "unit_id": None, "unit": { "id": None, @@ -424,7 +554,7 @@ def build_client_planning_context_pack( "group_id": group_id, "group_name": group_name, "section_order_index": body.section_order_index, - "section_title": None, + "section_title": (body.section_title or "").strip() or None, "planned_exercise_ids": planned_ids, "anchor_exercise_id": anchor_id, "anchor_title": anchor_title, @@ -435,6 +565,7 @@ def build_client_planning_context_pack( "group_recent_exercise_ids": sorted(group_recent), "context_mode": "client_free", } + return _attach_planning_context_details(cur, pack, sections=None, body=body) def suggest_planning_exercises( @@ -448,27 +579,40 @@ def suggest_planning_exercises( else: pack = build_client_planning_context_pack(cur, tenant=tenant, body=body) pack = _apply_client_planned_override(cur, pack, body) + pack = _attach_planning_context_details(cur, pack, body=body) query = _normalize_query(body.query) heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint) + has_plan_ref = bool(pack.get("has_planning_reference")) + expectation_mode = "planning_hybrid" if has_plan_ref else "query_only" + pipeline_context = { "unit_title": pack.get("unit_title"), "group_name": pack.get("group_name"), "section_title": pack.get("section_title"), + "section_guidance_notes": pack.get("section_guidance_notes"), + "section_exercise_count": pack.get("section_exercise_count"), "planned_count": len(pack.get("planned_exercise_ids") or []), "anchor_title": pack.get("anchor_title"), "anchor_exercise_id": pack.get("anchor_exercise_id"), + "last_section_exercise_title": pack.get("last_section_exercise_title"), "progression_graph_id": pack.get("progression_graph_id"), + "unit_skill_profile": pack.get("unit_skill_profile_summary"), + "section_skill_profile": pack.get("section_skill_profile_summary"), + "has_planning_reference": has_plan_ref, + "expectation_mode": expectation_mode, } target_profile, intent, scenario_kind, query_intent_summary = build_planning_target_with_query_pipeline( cur, unit=pack["unit"], planned_exercise_ids=pack["planned_exercise_ids"], + section_planned_exercise_ids=pack.get("section_planned_exercise_ids") or [], anchor_exercise_id=pack.get("anchor_exercise_id"), query=query, heuristic_intent=heuristic_intent, include_llm_intent=body.include_llm_intent, context_summary=pipeline_context, + has_planning_reference=has_plan_ref, ) weights = _intent_weights(intent) target_profile_summary = target_profile.to_summary_dict(cur) @@ -549,11 +693,18 @@ def suggest_planning_exercises( "unit_title": pack.get("unit_title"), "group_name": pack.get("group_name"), "section_title": pack.get("section_title"), + "section_guidance_notes": pack.get("section_guidance_notes"), + "section_exercise_count": pack.get("section_exercise_count"), "planned_count": len(planned_set), "anchor_title": pack.get("anchor_title"), "anchor_exercise_id": pack.get("anchor_exercise_id"), + "last_section_exercise_title": pack.get("last_section_exercise_title"), "progression_graph_id": pack.get("progression_graph_id"), "context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"), + "unit_skill_profile": pack.get("unit_skill_profile_summary"), + "section_skill_profile": pack.get("section_skill_profile_summary"), + "has_planning_reference": pack.get("has_planning_reference"), + "expectation_mode": expectation_mode, } return { @@ -568,5 +719,6 @@ def suggest_planning_exercises( "intent_resolved": intent, "intent_heuristic": heuristic_intent, "query_normalized": query or None, + "expectation_mode": expectation_mode, "hits": hits, } diff --git a/backend/planning_exercise_target_pipeline.py b/backend/planning_exercise_target_pipeline.py index 6abce68..182c90d 100644 --- a/backend/planning_exercise_target_pipeline.py +++ b/backend/planning_exercise_target_pipeline.py @@ -224,14 +224,19 @@ def build_planning_target_with_query_pipeline( *, unit: Dict[str, Any], planned_exercise_ids: List[int], + section_planned_exercise_ids: Optional[List[int]] = None, anchor_exercise_id: Optional[int], query: Optional[str], heuristic_intent: str, include_llm_intent: bool, context_summary: Mapping[str, Any], + has_planning_reference: bool = True, ) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]: """ Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict + + Ohne Planungsbezug (keine Übungen/Anker/Rahmen): Erwartungsprofil primär aus Suchtext (query_only). + Mit Planungsbezug: hybrid aus Plan + optional Query-Overlay. """ scenario = classify_planning_scenario(query, heuristic_intent) resolved_intent = heuristic_intent @@ -239,13 +244,18 @@ def build_planning_target_with_query_pipeline( parsed: Optional[PlanningQueryIntentParsed] = None resolved_skills: List[Dict[str, Any]] = [] - base = build_planning_target_profile( - cur, - unit=unit, - planned_exercise_ids=planned_exercise_ids, - anchor_exercise_id=anchor_exercise_id, - intent=heuristic_intent, - ) + if has_planning_reference: + base = build_planning_target_profile( + cur, + unit=unit, + planned_exercise_ids=planned_exercise_ids, + section_planned_exercise_ids=section_planned_exercise_ids or [], + anchor_exercise_id=anchor_exercise_id, + intent=heuristic_intent, + ) + else: + base = PlanningTargetProfile(sources=["query_only"]) + base_summary = base.to_summary_dict(cur) if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent): @@ -273,6 +283,11 @@ def build_planning_target_with_query_pipeline( focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed) if focus or style or tt or tg or skills: + overlay_scenario = scenario + overlay_emphasis = parsed.emphasis + if not has_planning_reference: + overlay_scenario = SCENARIO_FREE_SEARCH + overlay_emphasis = "replace" target = merge_query_overlay_into_target( base, focus=focus, @@ -280,9 +295,12 @@ def build_planning_target_with_query_pipeline( tt=tt, tg=tg, skills=skills, - emphasis=parsed.emphasis, - scenario=scenario, + emphasis=overlay_emphasis, + scenario=overlay_scenario, ) + elif not has_planning_reference and _normalize_query(query): + # Kein LLM, aber Freitext: leichtes Profil bleibt leer — Retrieval nutzt Volltext + target = PlanningTargetProfile(sources=["query_only"]) query_intent_summary: Dict[str, Any] = { "scenario": scenario, @@ -293,6 +311,7 @@ def build_planning_target_with_query_pipeline( "rationale": (parsed.rationale if parsed else None), "skill_hints_resolved": resolved_skills, "requires_partner": parsed.requires_partner if parsed else None, + "expectation_mode": "planning_hybrid" if has_planning_reference else "query_only", } return target, resolved_intent, scenario, query_intent_summary diff --git a/backend/tests/test_planning_exercise_suggest.py b/backend/tests/test_planning_exercise_suggest.py index 6daf854..93c6a5d 100644 --- a/backend/tests/test_planning_exercise_suggest.py +++ b/backend/tests/test_planning_exercise_suggest.py @@ -78,6 +78,30 @@ def test_compose_retrieval_phase(): ) +def test_query_only_expectation_without_planning_reference(): + from planning_exercise_profiles import PlanningTargetProfile + from planning_exercise_target_pipeline import build_planning_target_with_query_pipeline + + class _Cur: + pass + + target, intent, scenario, summary = build_planning_target_with_query_pipeline( + _Cur(), + unit={"id": None, "framework_slot_id": None, "origin_framework_slot_id": None}, + planned_exercise_ids=[], + section_planned_exercise_ids=[], + anchor_exercise_id=None, + query="Partnerübung Reaktion", + heuristic_intent="free_search", + include_llm_intent=False, + context_summary={"expectation_mode": "query_only"}, + has_planning_reference=False, + ) + assert intent == "free_search" + assert summary.get("expectation_mode") == "query_only" + assert target.sources == ["query_only"] or "query_only" in target.sources + + def test_parse_planning_query_intent_response(): parsed = parse_planning_query_intent_response( '{"intent":"continue_plan_goal","scenario":"additive_constraint",' diff --git a/backend/version.py b/backend/version.py index 410f26a..f4beeaa 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.173" +APP_VERSION = "0.8.174" BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260531073" @@ -28,7 +28,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay - "planning_exercise_suggest": "0.5.0", # Mehrstufiges Profil-Retrieval; LLM-Gates (max 1 Call) + "planning_exercise_suggest": "0.6.0", # Abschnitts-/Skill-Kontext; expectation_mode hybrid|query_only "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -43,6 +43,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.174", + "date": "2026-05-22", + "changes": [ + "Planungs-KI: Abschnitts-Kontext (guidance_notes, Übungszahl, letzte Übung), Fähigkeitenprofil Einheit/Abschnitt an LLM.", + "Erwartungsprofil hybrid (Planungsbezug) vs. query_only (nur Suchtext); current_section_plan im Target-Profil.", + ], + }, { "version": "0.8.173", "date": "2026-05-22", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 333737c..7770f56 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -104,6 +104,13 @@ export default function ExercisePickerModal({ const base = { groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null, sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0, + sectionTitle: planningContext?.sectionTitle ?? null, + sectionGuidanceNotes: planningContext?.sectionGuidanceNotes ?? null, + sectionPlannedExerciseIds: Array.isArray(planningContext?.sectionPlannedExerciseIds) + ? planningContext.sectionPlannedExerciseIds + : [], + sectionExerciseCount: planningContext?.sectionExerciseCount ?? null, + lastExerciseTitle: planningContext?.lastExerciseTitle ?? null, phaseOrderIndex: planningContext?.phaseOrderIndex ?? null, parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null, anchorExerciseId: planningContext?.anchorExerciseId ?? null, @@ -424,10 +431,24 @@ export default function ExercisePickerModal({ if (resolvedPlanningUnitId) { requestBody.unit_id = Number(resolvedPlanningUnitId) } - if (activePlanningContext.groupId) { - requestBody.group_id = Number(activePlanningContext.groupId) - } - const res = await api.suggestPlanningExercises(requestBody) + if (activePlanningContext.groupId) { + requestBody.group_id = Number(activePlanningContext.groupId) + } + if (activePlanningContext.sectionTitle) { + requestBody.section_title = String(activePlanningContext.sectionTitle) + } + if (activePlanningContext.sectionGuidanceNotes) { + requestBody.section_guidance_notes = String(activePlanningContext.sectionGuidanceNotes) + } + if ( + Array.isArray(activePlanningContext.sectionPlannedExerciseIds) && + activePlanningContext.sectionPlannedExerciseIds.length > 0 + ) { + requestBody.section_planned_exercise_ids = activePlanningContext.sectionPlannedExerciseIds + .map((x) => Number(x)) + .filter((x) => Number.isFinite(x) && x > 0) + } + const res = await api.suggestPlanningExercises(requestBody) setPlanningContextSummary(res?.context_summary || null) setPlanningTargetProfileSummary(res?.target_profile_summary || null) setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied)) @@ -662,6 +683,16 @@ export default function ExercisePickerModal({ {planningContextSummary.section_title ? ( {planningContextSummary.section_title} ) : null} + {planningContextSummary.section_exercise_count != null ? ( + + {planningContextSummary.section_exercise_count} Übungen im Abschnitt + + ) : null} + {planningContextSummary.last_section_exercise_title ? ( + + Letzte: {planningContextSummary.last_section_exercise_title} + + ) : null} {planningContextSummary.planned_count != null ? ( {planningContextSummary.planned_count} Übungen im Plan ) : null} @@ -687,6 +718,19 @@ export default function ExercisePickerModal({ )) : null}

+ {planningContextSummary.section_guidance_notes ? ( +

+ Abschnitt: {planningContextSummary.section_guidance_notes} +

+ ) : null} + {planningContextSummary.expectation_mode ? ( +

+ Erwartungsprofil:{' '} + {planningContextSummary.expectation_mode === 'query_only' + ? 'nur Suchtext' + : 'Planung + optional Suchtext'} +

+ ) : null} {planningTargetProfileSummary?.has_skill_gap ? (

Skill-Lücke zum bisherigen Plan berücksichtigt diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index bc6f29c..7d75f82 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -286,10 +286,35 @@ export default function TrainingFrameworkProgramEditPage() { plannedExerciseIds.push(eid) } } + const sectionPlannedExerciseIds = [] + const seenSec = new Set() + for (const it of sec?.items || []) { + if (String(it?.item_type || '').toLowerCase() === 'note') continue + const eid = Number(it?.exercise_id) + if (!Number.isFinite(eid) || eid < 1 || seenSec.has(eid)) continue + seenSec.add(eid) + sectionPlannedExerciseIds.push(eid) + } + let lastExerciseTitle = null + if (sec?.items?.length) { + for (let i = sec.items.length - 1; i >= 0; i -= 1) { + const it = sec.items[i] + if (String(it?.item_type || '').toLowerCase() === 'note') continue + if (it?.exercise_id) { + lastExerciseTitle = (it.exercise_title || '').trim() || null + break + } + } + } return { unitId: null, groupId: null, sectionOrderIndex: sIdx, + sectionTitle: (sec?.title || '').trim() || null, + sectionGuidanceNotes: (sec?.guidance_notes || '').trim() || null, + sectionPlannedExerciseIds, + sectionExerciseCount: sectionPlannedExerciseIds.length, + lastExerciseTitle, anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null, progressionGraphId: null, plannedExerciseIds, diff --git a/frontend/src/pages/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx index 84ce9a7..22453a4 100644 --- a/frontend/src/pages/TrainingUnitEditPage.jsx +++ b/frontend/src/pages/TrainingUnitEditPage.jsx @@ -173,11 +173,36 @@ export default function TrainingUnitEditPage() { plannedExerciseIds.push(eid) } } + const sectionPlannedExerciseIds = [] + const seenSec = new Set() + for (const it of sec?.items || []) { + if (String(it?.item_type || '').toLowerCase() === 'note') continue + const eid = Number(it?.exercise_id) + if (!Number.isFinite(eid) || eid < 1 || seenSec.has(eid)) continue + seenSec.add(eid) + sectionPlannedExerciseIds.push(eid) + } + let lastExerciseTitle = null + if (sec?.items?.length) { + for (let i = sec.items.length - 1; i >= 0; i -= 1) { + const it = sec.items[i] + if (String(it?.item_type || '').toLowerCase() === 'note') continue + if (it?.exercise_id) { + lastExerciseTitle = (it.exercise_title || '').trim() || null + break + } + } + } const groupIdRaw = Number(formData.group_id) return { unitId: resolvedUnitId ? Number(resolvedUnitId) : null, groupId: Number.isFinite(groupIdRaw) && groupIdRaw > 0 ? groupIdRaw : null, sectionOrderIndex: sIdx, + sectionTitle: (sec?.title || '').trim() || null, + sectionGuidanceNotes: (sec?.guidance_notes || '').trim() || null, + sectionPlannedExerciseIds, + sectionExerciseCount: sectionPlannedExerciseIds.length, + lastExerciseTitle, anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null, progressionGraphId: null, plannedExerciseIds, -- 2.43.0 From 5c882985e0217a1df16b3755c6e0447a7006c622 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 23:08:53 +0200 Subject: [PATCH 12/16] Enhance Planning Exercise Functionality and LLM Integration - Added support for the new planning exercise expectation profile slug in the AI prompt runtime. - Refactored SQL parameter handling in the planning exercise retrieval process to ensure correct binding for full-text search. - Updated the planning exercise suggestion logic to incorporate LLM expectation handling, improving the accuracy of exercise recommendations. - Introduced new functions to determine when to run the LLM expectation pipeline, enhancing the decision-making process for exercise suggestions. - Incremented version to 0.8.176 and updated changelog to reflect these enhancements in planning AI capabilities. --- backend/ai_prompt_runtime.py | 1 + ..._planning_exercise_expectation_profile.sql | 70 ++++++++++++++++++ backend/planning_exercise_expectation.py | 69 ++++++++++++++++++ backend/planning_exercise_retrieval.py | 7 +- backend/planning_exercise_suggest.py | 7 ++ backend/planning_exercise_target_pipeline.py | 72 +++++++++++++++++-- .../tests/test_planning_exercise_retrieval.py | 43 +++++++++++ .../tests/test_planning_exercise_suggest.py | 43 +++++++++++ backend/version.py | 24 +++++-- .../src/components/ExercisePickerModal.jsx | 11 ++- 10 files changed, 332 insertions(+), 15 deletions(-) create mode 100644 backend/migrations/074_ai_prompt_planning_exercise_expectation_profile.sql create mode 100644 backend/planning_exercise_expectation.py create mode 100644 backend/tests/test_planning_exercise_retrieval.py diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py index f942ad1..5944f36 100644 --- a/backend/ai_prompt_runtime.py +++ b/backend/ai_prompt_runtime.py @@ -15,6 +15,7 @@ _PLANNING_AI_SLUGS = frozenset( { "planning_exercise_search_rank", "planning_exercise_search_intent", + "planning_exercise_expectation_profile", } ) diff --git a/backend/migrations/074_ai_prompt_planning_exercise_expectation_profile.sql b/backend/migrations/074_ai_prompt_planning_exercise_expectation_profile.sql new file mode 100644 index 0000000..ed8fa19 --- /dev/null +++ b/backend/migrations/074_ai_prompt_planning_exercise_expectation_profile.sql @@ -0,0 +1,70 @@ +-- Migration 074: KI-Prompt Planungs-Übungssuche — Erwartungsprofil aus Planungskontext (Preset) +-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16 + +INSERT INTO ai_prompts ( + slug, display_name, description, template, + category, output_format, output_schema, is_system_default, default_template, active, sort_order +) +SELECT + 'planning_exercise_expectation_profile', + 'Planungs-Übungssuche Erwartungsprofil', + 'Leitet aus Einheit, Abschnitt, Anker und bisherigem Plan ein Erwartungsprofil für die nächste Übung ab (ohne Freitext-Anfrage).', + $t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung. +Der Trainer wählt „nächste Übung aus Kontext“ — es gibt KEINE zusätzliche Freitext-Suchanfrage. + +Deine Aufgabe: Aus dem Planungskontext und dem deterministischen Basis-Zielprofil ein präzises Erwartungsprofil ableiten: +- Was soll die nächste Übung fachlich leisten (Fortsetzen, Vertiefen, Lücke schließen, Abwechslung)? +- Welche Fähigkeiten, Fokus-Bereiche, Trainingsstile passen dazu? +- Berücksichtige: Rahmen/Einheit, Abschnittsziel (guidance_notes), letzte Übung im Abschnitt, Anker-Übung, Skill-Profile Einheit vs. Abschnitt, Skill-Lücken im Basisprofil. + +Intent (intent): meist suggest_next oder continue_plan_goal; progression_next nur wenn Progressionsgraph/Anker klar nahelegt; deepen_exercise nur bei klarer Vertiefungslage. + +continuation (optional, Kurzlabel): +- build_on_section: nahtlos an Abschnitt/letzte Übung anknüpfen +- close_skill_gap: fehlende Fähigkeiten aus Plan/Rahmen nachziehen +- deepen_anchor: Anker-Übung vertiefen +- variety: bewusst variieren nach bisherigem Block +- balance_load: Belastung ausgleichen / Tempo wechseln + +Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung). +emphasis: fast immer additive (baut auf Basisprofil auf), nur replace wenn Kontext eindeutig neuen Schwerpunkt verlangt. + +Eingabe: +Heuristik-Intent: {{heuristic_intent}} +Planungskontext: {{planning_context_json}} +Basis-Zielprofil (deterministisch): {{target_profile_json}} + +Kataloge (Auszug — nur diese Namen/IDs verwenden): +Skills: {{skills_catalog_json}} +Fokus: {{focus_areas_catalog_json}} +Trainingsstil: {{training_types_catalog_json}} +Stilrichtung: {{style_directions_catalog_json}} +Zielgruppe: {{target_groups_catalog_json}} + +Antworte NUR mit JSON: +{ + "intent": "suggest_next", + "scenario": "preset_next", + "continuation": "build_on_section", + "skill_hints": [{"name": "Kime", "weight": 0.9}], + "focus_hints": [], + "style_hints": [], + "training_type_hints": [], + "target_group_hints": [], + "requires_partner": null, + "emphasis": "additive", + "rationale": "Kurz auf Deutsch, 1–2 Sätze: warum diese nächste Übung sinnvoll ist" +}$t$, + 'training', + 'json', + '{"type":"object","required":["intent","scenario","rationale"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"continuation":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb, + true, + NULL, + true, + 12 +WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_expectation_profile'); + +UPDATE ai_prompts +SET default_template = template +WHERE slug = 'planning_exercise_expectation_profile' + AND (default_template IS NULL OR TRIM(default_template) = ''); diff --git a/backend/planning_exercise_expectation.py b/backend/planning_exercise_expectation.py new file mode 100644 index 0000000..15d42d8 --- /dev/null +++ b/backend/planning_exercise_expectation.py @@ -0,0 +1,69 @@ +""" +Preset „Nächste aus Kontext“: LLM leitet Erwartungsprofil aus Planungskontext ab. + +Prompt: planning_exercise_expectation_profile (Migration 074) +""" +from __future__ import annotations + +import logging +from typing import Any, Dict, Mapping, Optional, Tuple + +from planning_exercise_intent import ( + PlanningQueryIntentParsed, + _compact_json, + _load_compact_catalog, + _load_skills_catalog_compact, + parse_planning_query_intent_response, +) +from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt +from openrouter_chat import ( + effective_openrouter_model_for_prompt_row, + normalize_openrouter_env, + openrouter_chat_completion, +) + +_logger = logging.getLogger("shinkan.planning_exercise_expectation") + + +def try_build_planning_expectation_from_context( + cur, + *, + heuristic_intent: str, + context_summary: Mapping[str, Any], + target_profile_summary: Mapping[str, Any], +) -> Tuple[Optional[PlanningQueryIntentParsed], bool]: + """ + LLM-Erwartungsprofil für preset_next / leere Anfrage mit Planungsbezug. + Returns (parsed overlay, applied). + """ + api_key, _ = normalize_openrouter_env() + if not api_key: + return None, False + + variables = { + "heuristic_intent": heuristic_intent or "suggest_next", + "planning_context_json": _compact_json(dict(context_summary or {})), + "target_profile_json": _compact_json(dict(target_profile_summary or {})), + "skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)), + "focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")), + "training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")), + "style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")), + "target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")), + } + + try: + prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_expectation_profile", variables) + model = effective_openrouter_model_for_prompt_row(prow) + raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text) + parsed = parse_planning_query_intent_response(raw) + if parsed.scenario not in ("preset_next", "continue_plan", "free_search"): + parsed = parsed.model_copy(update={"scenario": "preset_next"}) + return parsed, True + except AiPromptUnavailableError: + return None, False + except Exception as exc: + _logger.warning("Planungs-Erwartungsprofil-LLM fehlgeschlagen: %s", exc) + return None, False + + +__all__ = ["try_build_planning_expectation_from_context"] diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index 217c51a..4800eae 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -62,15 +62,18 @@ def fetch_retrieval_candidate_rows( ) -> List[Dict[str, Any]]: """S1b-0: Profil-geführter Kandidaten-Pool.""" where = [vis_sql, "COALESCE(e.status, '') <> %s"] - params: List[Any] = list(vis_params) - params.append("archived") + params: List[Any] = [] if query: ft_select = "ts_rank_cd(e.search_vector, plainto_tsquery('german', %s)) AS ft_rank" + # SELECT-Platzhalter steht im SQL vor WHERE — Query zuerst binden. params.append(query) else: ft_select = "0.0::float AS ft_rank" + params.extend(vis_params) + params.append("archived") + ek_filtered: List[str] = [] if exercise_kind_any: for raw in exercise_kind_any: diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index ffb53a0..1b25080 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -617,6 +617,8 @@ def suggest_planning_exercises( weights = _intent_weights(intent) target_profile_summary = target_profile.to_summary_dict(cur) query_intent_applied = bool(query_intent_summary.get("llm_applied")) + llm_expectation_applied = bool(query_intent_summary.get("llm_expectation_applied")) + profile_llm_applied = bool(query_intent_summary.get("profile_llm_applied")) profile_id = tenant.profile_id role = tenant.global_role @@ -645,6 +647,7 @@ def suggest_planning_exercises( retrieval_phase = compose_retrieval_phase( profile_preselect=profile_preselect_applied, query_intent=query_intent_applied, + llm_expectation=llm_expectation_applied, llm_rank=False, ) run_llm_rank = should_run_llm_rank_pipeline( @@ -652,6 +655,7 @@ def suggest_planning_exercises( scenario_kind, include_llm_rank=body.include_llm_rank, query_intent_applied=query_intent_applied, + llm_expectation_applied=llm_expectation_applied, hits=hits, ) if run_llm_rank: @@ -678,6 +682,7 @@ def suggest_planning_exercises( retrieval_phase = compose_retrieval_phase( profile_preselect=profile_preselect_applied, query_intent=query_intent_applied, + llm_expectation=llm_expectation_applied, llm_rank=True, ) tail = hits[pre_limit:] @@ -716,6 +721,8 @@ def suggest_planning_exercises( "profile_preselect_applied": profile_preselect_applied, "llm_rank_applied": llm_rank_applied, "llm_intent_applied": query_intent_applied, + "llm_expectation_applied": llm_expectation_applied, + "profile_llm_applied": profile_llm_applied, "intent_resolved": intent, "intent_heuristic": heuristic_intent, "query_normalized": query or None, diff --git a/backend/planning_exercise_target_pipeline.py b/backend/planning_exercise_target_pipeline.py index 182c90d..0026d16 100644 --- a/backend/planning_exercise_target_pipeline.py +++ b/backend/planning_exercise_target_pipeline.py @@ -12,6 +12,7 @@ from __future__ import annotations import re from typing import Any, Dict, List, Mapping, Optional, Tuple +from planning_exercise_expectation import try_build_planning_expectation_from_context from planning_exercise_intent import ( PlanningQueryIntentParsed, resolve_query_intent_catalog_ids, @@ -37,6 +38,7 @@ _SIMPLE_PRESET_PATTERNS = ( r"^(vorschlag|vorschlagen|empfehl\w*)\s*(für|fuer)?\s*(die\s+)?(n[aä]chste|naechste)?\s*(übung|uebung)?\.?$", r"^n[aä]chste\s+übung$", r"^n[aä]chste\s+uebung$", + r"^(n[aä]chste|naechste)\s+(übung|uebung)\s+planen\.?$", ) _ADDITIVE_MARKERS = ( @@ -89,6 +91,20 @@ def classify_planning_scenario( return SCENARIO_FREE_SEARCH +def should_run_llm_expectation_pipeline( + scenario: str, + *, + include_llm_intent: bool, + has_planning_reference: bool, +) -> bool: + """Preset/leere Anfrage mit Planungsbezug → LLM-Erwartungsprofil statt Query-Intent.""" + if not include_llm_intent: + return False + if not has_planning_reference: + return False + return scenario == SCENARIO_PRESET_NEXT + + def should_run_llm_intent_pipeline( query: Optional[str], scenario: str, @@ -125,15 +141,16 @@ def should_run_llm_rank_pipeline( *, include_llm_rank: bool, query_intent_applied: bool, + llm_expectation_applied: bool = False, hits: Sequence[Mapping[str, Any]], ) -> bool: """ - Maximal ein LLM-Call pro Request: wenn Intent-LLM lief, kein Rerank. + Maximal ein LLM-Call pro Request: wenn Intent- oder Erwartungs-LLM lief, kein Rerank. Rerank nur bei längerer, komplexer Anfrage und unklarem Hybrid-Ranking. """ if not include_llm_rank: return False - if query_intent_applied: + if query_intent_applied or llm_expectation_applied: return False if scenario == SCENARIO_PRESET_NEXT: return False @@ -241,7 +258,9 @@ def build_planning_target_with_query_pipeline( scenario = classify_planning_scenario(query, heuristic_intent) resolved_intent = heuristic_intent llm_applied = False + llm_expectation_applied = False parsed: Optional[PlanningQueryIntentParsed] = None + expectation_parsed: Optional[PlanningQueryIntentParsed] = None resolved_skills: List[Dict[str, Any]] = [] if has_planning_reference: @@ -257,8 +276,44 @@ def build_planning_target_with_query_pipeline( base = PlanningTargetProfile(sources=["query_only"]) base_summary = base.to_summary_dict(cur) + target = base - if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent): + if should_run_llm_expectation_pipeline( + scenario, + include_llm_intent=include_llm_intent, + has_planning_reference=has_planning_reference, + ): + expectation_parsed, llm_expectation_applied = try_build_planning_expectation_from_context( + cur, + heuristic_intent=heuristic_intent, + context_summary=context_summary, + target_profile_summary=base_summary, + ) + parsed = expectation_parsed + if parsed and llm_expectation_applied: + if parsed.intent in { + "suggest_next", + "progression_next", + "deepen_exercise", + "continue_plan_goal", + "free_search", + }: + resolved_intent = parsed.intent + focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed) + if focus or style or tt or tg or skills or parsed.rationale: + target = merge_query_overlay_into_target( + base, + focus=focus, + style=style, + tt=tt, + tg=tg, + skills=skills, + emphasis=parsed.emphasis or "additive", + scenario=SCENARIO_PRESET_NEXT, + ) + if "context_expectation" not in target.sources: + target.sources.append("context_expectation") + elif should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent): parsed, llm_applied = try_parse_planning_query_intent( cur, query=_normalize_query(query), @@ -268,8 +323,7 @@ def build_planning_target_with_query_pipeline( target_profile_summary=base_summary, ) - target = base - if parsed and llm_applied: + if parsed and llm_applied and not llm_expectation_applied: if parsed.intent in { "suggest_next", "progression_next", @@ -307,6 +361,8 @@ def build_planning_target_with_query_pipeline( "intent": resolved_intent, "heuristic_intent": heuristic_intent, "llm_applied": llm_applied, + "llm_expectation_applied": llm_expectation_applied, + "profile_llm_applied": llm_applied or llm_expectation_applied, "emphasis": parsed.emphasis if parsed else None, "rationale": (parsed.rationale if parsed else None), "skill_hints_resolved": resolved_skills, @@ -331,12 +387,15 @@ def compose_retrieval_phase( *, profile_preselect: bool = False, query_intent: bool = False, + llm_expectation: bool = False, llm_rank: bool = False, ) -> str: parts = ["profile_v1"] if profile_preselect: parts.append("profile_preselect") - if query_intent: + if llm_expectation: + parts.append("llm_expectation") + elif query_intent: parts.append("query_intent") if llm_rank: parts.append("llm_rank") @@ -351,6 +410,7 @@ __all__ = [ "compose_retrieval_phase", "is_simple_preset_query", "merge_query_overlay_into_target", + "should_run_llm_expectation_pipeline", "should_run_llm_intent_pipeline", "should_run_llm_rank_pipeline", "deterministic_rank_confident", diff --git a/backend/tests/test_planning_exercise_retrieval.py b/backend/tests/test_planning_exercise_retrieval.py new file mode 100644 index 0000000..8e4b834 --- /dev/null +++ b/backend/tests/test_planning_exercise_retrieval.py @@ -0,0 +1,43 @@ +"""Tests Planungs-Retrieval SQL-Parameter.""" +from planning_exercise_retrieval import fetch_retrieval_candidate_rows + + +def test_fetch_retrieval_binds_query_before_visibility_params(): + captured = {} + + class _Cur: + def execute(self, sql, params): + captured["sql"] = sql + captured["params"] = list(params) + + def fetchall(self): + return [ + { + "id": 1, + "title": "Test", + "summary": "", + "primary_focus_name": None, + "ft_rank": 0.2, + } + ] + + fetch_retrieval_candidate_rows( + _Cur(), + vis_sql="(e.visibility = 'official' OR (e.visibility = 'private' AND e.created_by = %s))", + vis_params=[42], + query="nächste Übung planen", + exercise_kind_any=None, + target=__import__( + "planning_exercise_profiles", fromlist=["PlanningTargetProfile"] + ).PlanningTargetProfile(), + progression_successor_ids=set(), + anchor_skill_ids={7}, + raw_pool_limit=10, + ) + + params = captured["params"] + assert params[0] == "nächste Übung planen" + assert params[1] == 42 + assert params[2] == "archived" + assert params[-2] == "nächste Übung planen" + assert params[-1] == 10 diff --git a/backend/tests/test_planning_exercise_suggest.py b/backend/tests/test_planning_exercise_suggest.py index 93c6a5d..5c6e194 100644 --- a/backend/tests/test_planning_exercise_suggest.py +++ b/backend/tests/test_planning_exercise_suggest.py @@ -25,8 +25,10 @@ def test_resolve_planning_exercise_intent_keywords(): def test_classify_planning_scenario_preset(): assert is_simple_preset_query("Schlage mir die nächste Übung vor") + assert is_simple_preset_query("nächste Übung planen") assert classify_planning_scenario("", "suggest_next") == SCENARIO_PRESET_NEXT assert classify_planning_scenario("nächste übung", "suggest_next") == SCENARIO_PRESET_NEXT + assert classify_planning_scenario("nächste Übung planen", "suggest_next") == SCENARIO_PRESET_NEXT def test_classify_planning_scenario_additive(): @@ -78,6 +80,47 @@ def test_compose_retrieval_phase(): ) +def test_should_run_llm_expectation_for_preset_with_planning_ref(): + from planning_exercise_target_pipeline import should_run_llm_expectation_pipeline + + assert should_run_llm_expectation_pipeline( + SCENARIO_PRESET_NEXT, + include_llm_intent=True, + has_planning_reference=True, + ) + assert not should_run_llm_expectation_pipeline( + SCENARIO_PRESET_NEXT, + include_llm_intent=False, + has_planning_reference=True, + ) + assert not should_run_llm_expectation_pipeline( + SCENARIO_ADDITIVE, + include_llm_intent=True, + has_planning_reference=True, + ) + + +def test_should_skip_llm_rank_when_expectation_applied(): + from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline + + hits = [{"score": 0.5}, {"score": 0.48}, {"score": 0.47}, {"score": 0.46}] + assert not should_run_llm_rank_pipeline( + "", + SCENARIO_PRESET_NEXT, + include_llm_rank=True, + query_intent_applied=False, + llm_expectation_applied=True, + hits=hits, + ) + + +def test_compose_retrieval_phase_llm_expectation(): + assert ( + compose_retrieval_phase(llm_expectation=True) + == "profile_v1+llm_expectation" + ) + + def test_query_only_expectation_without_planning_reference(): from planning_exercise_profiles import PlanningTargetProfile from planning_exercise_target_pipeline import build_planning_target_with_query_pipeline diff --git a/backend/version.py b/backend/version.py index f4beeaa..3f6c0ae 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.174" +APP_VERSION = "0.8.176" BUILD_DATE = "2026-05-22" -DB_SCHEMA_VERSION = "20260531073" +DB_SCHEMA_VERSION = "20260531074" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -22,13 +22,13 @@ MODULE_VERSIONS = { "admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail "ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion "ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text - "ai_prompt_runtime": "0.2.1", # Kontext-Art planning_exercise_search; load_and_render_ai_prompt + "ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile "groups": "0.1.0", "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay - "planning_exercise_suggest": "0.6.0", # Abschnitts-/Skill-Kontext; expectation_mode hybrid|query_only + "planning_exercise_suggest": "0.7.0", # LLM-Erwartungsprofil aus Kontext (preset); Migration 074 "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -43,6 +43,22 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.176", + "date": "2026-05-22", + "changes": [ + "Fix: Planungs-Übungssuche mit Suchtext — SQL-Parameter für ts_rank/plainto_tsquery korrekt gebunden (500).", + "Preset-Erkennung: „nächste Übung planen“.", + ], + }, + { + "version": "0.8.175", + "date": "2026-05-22", + "changes": [ + "Planungs-KI: „Nächste aus Kontext“ — LLM leitet Erwartungsprofil aus Planungskontext ab (Prompt 074).", + "API: llm_expectation_applied, profile_llm_applied; Retrieval-Phase llm_expectation; max. 1 LLM-Call.", + ], + }, { "version": "0.8.174", "date": "2026-05-22", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 7770f56..70736ae 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -419,7 +419,8 @@ export default function ExercisePickerModal({ .map((x) => Number(x)) .filter((x) => Number.isFinite(x) && x > 0) : undefined, - include_llm_intent: query.length >= PLANNING_LLM_INTENT_MIN_CHARS, + include_llm_intent: + query.length >= PLANNING_LLM_INTENT_MIN_CHARS || !(query || '').trim(), include_llm_rank: query.length >= PLANNING_LLM_RANK_MIN_CHARS, query, intent_hint: @@ -452,7 +453,7 @@ export default function ExercisePickerModal({ setPlanningContextSummary(res?.context_summary || null) setPlanningTargetProfileSummary(res?.target_profile_summary || null) setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied)) - setPlanningLlmIntentApplied(Boolean(res?.llm_intent_applied)) + setPlanningLlmIntentApplied(Boolean(res?.profile_llm_applied ?? res?.llm_intent_applied)) setPlanningRetrievalPhase(res?.retrieval_phase || '') setPlanningQueryIntentSummary(res?.query_intent_summary || null) setPlanningIntentResolved(res?.intent_resolved || null) @@ -748,7 +749,11 @@ export default function ExercisePickerModal({ ? ` · ${String(planningQueryIntentSummary.scenario).replace(/_/g, ' ')}` : null} {planningLlmRankApplied ? ' · KI-Ranking aktiv' : null} - {planningLlmIntentApplied ? ' · KI-Intent aktiv' : null} + {planningLlmIntentApplied + ? planningQueryIntentSummary?.llm_expectation_applied + ? ' · KI-Erwartungsprofil aktiv' + : ' · KI-Intent aktiv' + : null} {!planningLlmRankApplied && !planningLlmIntentApplied && usePlanningSearch ? ' · ohne LLM (Profil/Hybrid)' : null} -- 2.43.0 From a8633235f276170acdfe299c6b1c21c885c053de Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 23 May 2026 06:16:37 +0200 Subject: [PATCH 13/16] Add ExerciseAiQuickCreateTeaser Component and Update ExercisePickerModal - Introduced the ExerciseAiQuickCreateTeaser component for a compact entry point in the exercise creation process. - Updated ExercisePickerModal to integrate the new teaser, allowing users to expand and create exercises directly from the search results. - Enhanced the quick create functionality with dynamic headlines and hints based on user input and context. - Refactored conditional rendering logic to improve user experience when no exercises are found. --- .../components/ExerciseAiQuickCreateOffer.jsx | 36 ++++++++- .../src/components/ExercisePickerModal.jsx | 75 ++++++++++++++----- 2 files changed, 90 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/ExerciseAiQuickCreateOffer.jsx b/frontend/src/components/ExerciseAiQuickCreateOffer.jsx index 206d6b4..e55112b 100644 --- a/frontend/src/components/ExerciseAiQuickCreateOffer.jsx +++ b/frontend/src/components/ExerciseAiQuickCreateOffer.jsx @@ -3,6 +3,39 @@ import React from 'react' /** * Inline-Angebot: aus Suchstring neue Übung per KI anlegen (Fokusbereich + optional Titel/Skizze). */ +/** Kompakter Einstieg unter Trefferliste — expandiert zum vollen Formular. */ +export function ExerciseAiQuickCreateTeaser({ onExpand, disabled = false }) { + return ( +

+
+ + Nichts Richtiges dabei?{' '} + Neue Übung mit KI anlegen und direkt übernehmen. + + +
+
+ ) +} + export default function ExerciseAiQuickCreateOffer({ searchLabel, title, @@ -19,6 +52,7 @@ export default function ExerciseAiQuickCreateOffer({ showSketchField = true, sketchOptional = true, hint, + headline, }) { const canRun = !busy && @@ -37,7 +71,7 @@ export default function ExerciseAiQuickCreateOffer({ }} > - Keine passende Übung gefunden + {headline || 'Keine passende Übung gefunden'}

{hint || diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 70736ae..6269d0a 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -18,7 +18,7 @@ import SkillTreeMultiSelect from './SkillTreeMultiSelect' import ExerciseFocusRulePicker from './ExerciseFocusRulePicker' import CatalogRulePicker from './CatalogRulePicker' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' -import ExerciseAiQuickCreateOffer from './ExerciseAiQuickCreateOffer' +import ExerciseAiQuickCreateOffer, { ExerciseAiQuickCreateTeaser } from './ExerciseAiQuickCreateOffer' import { useExerciseAiQuickCreateFields } from '../hooks/useExerciseAiQuickCreateFields' import { buildQuickCreateAiPreview, @@ -79,6 +79,7 @@ export default function ExercisePickerModal({ const [quickSaving, setQuickSaving] = useState(false) const [quickAiError, setQuickAiError] = useState('') const [quickCreateDraft, setQuickCreateDraft] = useState(null) + const [quickCreateExpanded, setQuickCreateExpanded] = useState(false) const [planningContextSummary, setPlanningContextSummary] = useState(null) const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null) const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false) @@ -154,6 +155,7 @@ export default function ExercisePickerModal({ : (searchInput || aiSearchInput).trim() setPlanningSubmittedQuery(q) setPlanningHasSearched(true) + setQuickCreateExpanded(false) setList([]) setPlanningSearchTick((t) => t + 1) }, [searchInput, aiSearchInput]) @@ -174,6 +176,10 @@ export default function ExercisePickerModal({ ) } + useEffect(() => { + if (!usePlanningSearch) setQuickCreateExpanded(false) + }, [effectivePickerQuery, usePlanningSearch]) + useEffect(() => { const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350) return () => clearTimeout(t) @@ -184,13 +190,45 @@ export default function ExercisePickerModal({ return () => clearTimeout(t) }, [aiSearchInput]) - const showQuickCreateOffer = + const canOfferQuickCreate = enableQuickCreateDraft && catalogsReady && !loading && - list.length === 0 && - planningHasSearched && - (usePlanningSearch ? true : effectivePickerQuery.length >= 3) + (usePlanningSearch ? planningHasSearched : effectivePickerQuery.length >= 3) + + const showQuickCreateFull = canOfferQuickCreate && (list.length === 0 || quickCreateExpanded) + const showQuickCreateTeaser = canOfferQuickCreate && list.length > 0 && !quickCreateExpanded + + const quickCreateHeadline = usePlanningSearch + ? 'Nichts Richtiges dabei?' + : list.length > 0 + ? 'Neue Übung anlegen' + : undefined + + const quickCreateHint = usePlanningSearch + ? effectivePickerQuery + ? `Aus Planungsanfrage „${effectivePickerQuery}“ oder eigener Idee — KI schlägt Texte vor, danach bearbeiten und übernehmen.` + : 'Aus Planungskontext oder eigener Idee — KI schlägt Texte vor, danach bearbeiten und übernehmen.' + : undefined + + const renderQuickCreateOffer = () => ( + + ) useEffect(() => { if (!open) return @@ -239,6 +277,7 @@ export default function ExercisePickerModal({ setQuickSaving(false) setQuickAiError('') setQuickCreateDraft(null) + setQuickCreateExpanded(false) setPlanningContextSummary(null) setPlanningTargetProfileSummary(null) setPlanningLlmRankApplied(false) @@ -1060,21 +1099,8 @@ export default function ExercisePickerModal({

) : list.length === 0 ? ( - showQuickCreateOffer ? ( - + showQuickCreateFull ? ( + renderQuickCreateOffer() ) : (

{usePlanningSearch @@ -1223,6 +1249,15 @@ export default function ExercisePickerModal({

)} + {showQuickCreateTeaser ? ( + setQuickCreateExpanded(true)} + /> + ) : null} + {showQuickCreateFull && quickCreateExpanded ? ( +
{renderQuickCreateOffer()}
+ ) : null} {multiSelect && typeof onSelectExercises === 'function' ? (
Date: Sat, 23 May 2026 06:35:45 +0200 Subject: [PATCH 14/16] Refactor Planning Exercise Retrieval and Suggestion Logic - Updated the planning exercise retrieval process to implement a multistage approach, ranking the entire visible library deterministically against the expectation profile. - Removed the previous profile OR pool mechanism, simplifying the retrieval logic and ensuring full-text search is only used as a scoring signal. - Adjusted the `compose_retrieval_phase` function to accommodate the new full library ranking strategy. - Incremented version to 0.8.177 and updated changelog to reflect these changes in planning exercise capabilities. --- backend/planning_exercise_retrieval.py | 318 ++++++------------ backend/planning_exercise_suggest.py | 9 +- backend/planning_exercise_target_pipeline.py | 5 +- .../tests/test_planning_exercise_retrieval.py | 114 +++++-- .../tests/test_planning_exercise_suggest.py | 9 +- backend/version.py | 12 +- 6 files changed, 208 insertions(+), 259 deletions(-) diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index 4800eae..a15bd04 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -1,10 +1,9 @@ """ -Mehrstufiges Retrieval für Planungs-Übungssuche (S1b). +Mehrstufiges Retrieval für Planungs-Übungssuche (Phase A). Stufen: - S1b-0 Kandidaten-Pool (Profil-Signale, Volltext, Progressions-Nachfolger) - S1b-1 Profil-Vorselektion → Top-K vor teurem Hybrid-Score - S1b-2 Hybrid-Score (Volltext, Graph, Skills, Plan, Profil, Wiederholung) + S1b-0 Gesamte sichtbare Bibliothek (Governance + Hard-Filter, kein Profil-OR-Pool) + S1b-1 Deterministischer Hybrid-Score auf allen Kandidaten → sortiert """ from __future__ import annotations @@ -16,8 +15,8 @@ from planning_exercise_profiles import ( score_exercise_against_target, ) -_RAW_POOL_LIMIT = 500 -_PROFILE_PRESELECT_LIMIT = 160 +_MAX_LIBRARY_ROWS = 8000 +_PROFILE_LOAD_BATCH = 400 def _skill_jaccard(a: Set[int], b: Set[int]) -> float: @@ -28,45 +27,37 @@ def _skill_jaccard(a: Set[int], b: Set[int]) -> float: return inter / union if union else 0.0 -def _top_weight_keys(weights: Mapping[int, float], limit: int) -> List[int]: - if not weights: - return [] - return [ - int(k) - for k, _ in sorted(weights.items(), key=lambda x: -float(x[1]))[:limit] - if int(k) > 0 - ] +def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> List[str]: + out: List[str] = [] + if not exercise_kind_any: + return out + for raw in exercise_kind_any: + s = str(raw or "").strip().lower() + if s in ("simple", "combination") and s not in out: + out.append(s) + return out -def _target_profile_signals(target: PlanningTargetProfile) -> Tuple[List[int], List[int], List[int]]: - skill_ids = _top_weight_keys(target.skill_weights, 8) - for sid in _top_weight_keys(target.skill_gap_weights, 6): - if sid not in skill_ids: - skill_ids.append(sid) - focus_ids = _top_weight_keys(target.focus_area_ids, 6) - style_ids = _top_weight_keys(target.style_direction_ids, 4) - return skill_ids[:12], focus_ids, style_ids - - -def fetch_retrieval_candidate_rows( +def fetch_all_visible_exercise_rows( cur, *, vis_sql: str, vis_params: Sequence[Any], query: str, exercise_kind_any: Optional[List[str]], - target: PlanningTargetProfile, - progression_successor_ids: Set[int], - anchor_skill_ids: Set[int], - raw_pool_limit: int = _RAW_POOL_LIMIT, + max_rows: int = _MAX_LIBRARY_ROWS, ) -> List[Dict[str, Any]]: - """S1b-0: Profil-geführter Kandidaten-Pool.""" + """ + S1b-0: Alle sichtbaren Übungen (ohne Profil-/Volltext-Pool-Vorselektion). + + Hard-Filter: Governance, nicht archiviert, optional exercise_kind. + Volltext-Rank nur als Score-Signal in SELECT, nicht als WHERE-Filter. + """ 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" - # SELECT-Platzhalter steht im SQL vor WHERE — Query zuerst binden. params.append(query) else: ft_select = "0.0::float AS ft_rank" @@ -74,56 +65,12 @@ def fetch_retrieval_candidate_rows( params.extend(vis_params) params.append("archived") - ek_filtered: List[str] = [] - if exercise_kind_any: - for raw in exercise_kind_any: - s = str(raw or "").strip().lower() - if s in ("simple", "combination") and s not in ek_filtered: - ek_filtered.append(s) + ek_filtered = _normalize_exercise_kind_filter(exercise_kind_any) 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) - skill_ids, focus_ids, style_ids = _target_profile_signals(target) - if not skill_ids and anchor_skill_ids: - skill_ids = sorted(anchor_skill_ids)[:10] - - profile_clauses: List[str] = [] - if skill_ids: - ph = ",".join(["%s"] * len(skill_ids)) - profile_clauses.append( - f"EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id IN ({ph}))" - ) - params.extend(skill_ids) - if focus_ids: - ph = ",".join(["%s"] * len(focus_ids)) - profile_clauses.append( - f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))" - ) - params.extend(focus_ids) - if style_ids: - ph = ",".join(["%s"] * len(style_ids)) - profile_clauses.append( - f"EXISTS (SELECT 1 FROM exercise_style_directions esd WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))" - ) - params.extend(style_ids) - if progression_successor_ids: - ph = ",".join(["%s"] * len(progression_successor_ids)) - profile_clauses.append(f"e.id IN ({ph})") - params.extend(sorted(progression_successor_ids)) - if query: - profile_clauses.append("e.search_vector @@ plainto_tsquery('german', %s)") - params.append(query) - - use_profile_pool = bool(profile_clauses) - if use_profile_pool: - where.append(f"({' OR '.join(profile_clauses)})") - - order_by = "e.updated_at DESC, e.id DESC" - if query: - order_by = "ft_rank DESC NULLS LAST, e.updated_at DESC, e.id DESC" - sql = f""" SELECT e.id, e.title, e.summary, ( @@ -136,129 +83,46 @@ def fetch_retrieval_candidate_rows( {ft_select} FROM exercises e WHERE {' AND '.join(where)} - ORDER BY {order_by} + ORDER BY e.id ASC LIMIT %s """ - params.append(int(raw_pool_limit)) + params.append(int(max_rows)) cur.execute(sql, params) - rows = [dict(r) for r in cur.fetchall()] - - if rows or not use_profile_pool: - return rows - - return _fetch_broad_fallback_pool( - cur, - vis_sql=vis_sql, - vis_params=vis_params, - query=query, - ek_filtered=ek_filtered, - raw_pool_limit=raw_pool_limit, - ) - - -def _fetch_broad_fallback_pool( - cur, - *, - vis_sql: str, - vis_params: Sequence[Any], - query: str, - ek_filtered: List[str], - raw_pool_limit: int, -) -> List[Dict[str, Any]]: - fallback_where = [vis_sql, "COALESCE(e.status, '') <> %s"] - fallback_params: List[Any] = list(vis_params) - fallback_params.append("archived") - if ek_filtered: - ph = ",".join(["%s"] * len(ek_filtered)) - fallback_where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))") - fallback_params.extend(ek_filtered) - if query: - ft_fb = "ts_rank_cd(e.search_vector, plainto_tsquery('german', %s)) AS ft_rank" - fb_order = "ft_rank DESC NULLS LAST, e.updated_at DESC, e.id DESC" - fallback_params.insert(0, query) - else: - ft_fb = "0.0::float AS ft_rank" - fb_order = "e.updated_at DESC, e.id DESC" - - fb_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_fb} - FROM exercises e - WHERE {' AND '.join(fallback_where)} - ORDER BY {fb_order} - LIMIT %s - """ - fallback_params.append(int(raw_pool_limit)) - cur.execute(fb_sql, fallback_params) return [dict(r) for r in cur.fetchall()] -def profile_preselect_rows( - cur, - rows: Sequence[Dict[str, Any]], - *, - target: PlanningTargetProfile, - intent: str, - progression_successor_ids: Set[int], - query: str, - preselect_limit: int = _PROFILE_PRESELECT_LIMIT, -) -> Tuple[List[Dict[str, Any]], bool]: - """S1b-1: Profil-Score auf Pool, Top-K für Hybrid.""" - if len(rows) <= preselect_limit: - return list(rows), False - - cand_ids = [int(r["id"]) for r in rows] - match_profiles = load_exercise_match_profiles_bulk(cur, cand_ids) - - scored: List[Tuple[float, Dict[str, Any]]] = [] - row_by_id = {int(r["id"]): r for r in rows} - must_keep: Set[int] = set(int(x) for x in progression_successor_ids) - - if query: - max_ft = max(float(r.get("ft_rank") or 0.0) for r in rows) or 0.0 - if max_ft > 0: - for r in rows: - if float(r.get("ft_rank") or 0.0) / max_ft >= 0.5: - must_keep.add(int(r["id"])) - - for eid in cand_ids: - emp = match_profiles.get(eid) - profile_score = 0.0 - if emp: - profile_score, _ = score_exercise_against_target(emp, target, intent=intent) - scored.append((profile_score, row_by_id[eid])) - - scored.sort(key=lambda x: (-x[0], str(x[1].get("title") or ""))) - selected: List[Dict[str, Any]] = [] - seen: Set[int] = set() - for _, row in scored: - eid = int(row["id"]) - if eid in seen: - continue - seen.add(eid) - selected.append(row) - if len(selected) >= preselect_limit: - break - - for eid in must_keep: - if eid in seen: - continue - row = row_by_id.get(eid) - if row: - selected.append(row) - seen.add(eid) - - return selected, True +def _load_match_profiles_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH): + ids = sorted({int(x) for x in exercise_ids if int(x) > 0}) + if not ids: + return {} + out: Dict[int, Any] = {} + for i in range(0, len(ids), batch): + chunk = ids[i : i + batch] + out.update(load_exercise_match_profiles_bulk(cur, chunk)) + return out -def hybrid_score_planning_hits( +def _load_skill_sets_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH) -> Dict[int, Set[int]]: + ids = sorted({int(x) for x in exercise_ids if int(x) > 0}) + out: Dict[int, Set[int]] = {eid: set() for eid in ids} + if not ids: + return out + for i in range(0, len(ids), batch): + chunk = ids[i : i + batch] + ph = ",".join(["%s"] * len(chunk)) + cur.execute( + f"SELECT exercise_id, skill_id FROM exercise_skills WHERE exercise_id IN ({ph})", + chunk, + ) + for row in cur.fetchall(): + eid = int(row["exercise_id"]) + sid = row.get("skill_id") + if sid is not None: + out.setdefault(eid, set()).add(int(sid)) + return out + + +def rank_visible_library_hits( cur, rows: Sequence[Dict[str, Any]], *, @@ -268,7 +132,7 @@ def hybrid_score_planning_hits( target: PlanningTargetProfile, pack: Mapping[str, Any], ) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]]]: - """S1b-2: Hybrid-Score auf vorselektiertem Pool.""" + """S1b-1: Hybrid-Score auf der gesamten sichtbaren Bibliothek.""" planned_set = set(pack.get("planned_exercise_ids") or []) group_recent_set = set(pack.get("group_recent_exercise_ids") or []) progression_set = set(pack.get("progression_successor_ids") or []) @@ -285,24 +149,21 @@ def hybrid_score_planning_hits( ) last_planned_skills = {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")} - cand_ids = [int(r["id"]) for r in rows] - skills_by_ex: Dict[int, Set[int]] = {cid: set() for cid in cand_ids} - match_profiles = load_exercise_match_profiles_bulk(cur, 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_items: List[Dict[str, Any]] = [] + cand_rows: List[Dict[str, Any]] = [] for row in rows: eid = int(row["id"]) if anchor_id and eid == int(anchor_id): continue + cand_rows.append(row) + + cand_ids = [int(r["id"]) for r in cand_rows] + match_profiles = _load_match_profiles_chunked(cur, cand_ids) + skills_by_ex = _load_skill_sets_chunked(cur, cand_ids) + + max_ft = 0.0 + scored_items: List[Dict[str, Any]] = [] + for row in cand_rows: + eid = int(row["id"]) ft = float(row.get("ft_rank") or 0.0) if ft > max_ft: max_ft = ft @@ -397,29 +258,15 @@ def run_multistage_planning_retrieval( intent_weights: Mapping[str, float], pack: Mapping[str, Any], ) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]: - """Orchestriert S1b-0 → S1b-1 → S1b-2.""" - progression_set = set(pack.get("progression_successor_ids") or []) - anchor_skills = set(pack.get("anchor_skill_ids") or []) - - rows = fetch_retrieval_candidate_rows( + """Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking).""" + rows = fetch_all_visible_exercise_rows( cur, vis_sql=vis_sql, vis_params=vis_params, query=query, exercise_kind_any=exercise_kind_any, - target=target, - progression_successor_ids=progression_set, - anchor_skill_ids=anchor_skills, ) - rows, preselect_applied = profile_preselect_rows( - cur, - rows, - target=target, - intent=intent, - progression_successor_ids=progression_set, - query=query, - ) - hits, skills_by_ex = hybrid_score_planning_hits( + hits, skills_by_ex = rank_visible_library_hits( cur, rows, query=query, @@ -428,12 +275,35 @@ def run_multistage_planning_retrieval( target=target, pack=pack, ) - return hits, skills_by_ex, preselect_applied + full_library_ranked = len(rows) > 0 + return hits, skills_by_ex, full_library_ranked + + +# Legacy-Alias für Tests / externe Imports +fetch_retrieval_candidate_rows = fetch_all_visible_exercise_rows +hybrid_score_planning_hits = rank_visible_library_hits + + +def profile_preselect_rows( + cur, + rows: Sequence[Dict[str, Any]], + *, + target: PlanningTargetProfile, + intent: str, + progression_successor_ids: Set[int], + query: str, + preselect_limit: int = 160, +) -> Tuple[List[Dict[str, Any]], bool]: + """Deprecated: Phase A rankt die volle Library — keine separate Vorselektion.""" + _ = (cur, target, intent, progression_successor_ids, query, preselect_limit) + return list(rows), False __all__ = [ + "fetch_all_visible_exercise_rows", "fetch_retrieval_candidate_rows", "hybrid_score_planning_hits", "profile_preselect_rows", + "rank_visible_library_hits", "run_multistage_planning_retrieval", ] diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 1b25080..8b6a6cc 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -629,7 +629,7 @@ def suggest_planning_exercises( effective_club_id=tenant.effective_club_id, ) - hits, skills_by_ex, profile_preselect_applied = run_multistage_planning_retrieval( + hits, skills_by_ex, full_library_ranked = run_multistage_planning_retrieval( cur, vis_sql=vis_sql, vis_params=vis_params, @@ -645,7 +645,7 @@ def suggest_planning_exercises( llm_rank_applied = False retrieval_phase = compose_retrieval_phase( - profile_preselect=profile_preselect_applied, + full_library=full_library_ranked, query_intent=query_intent_applied, llm_expectation=llm_expectation_applied, llm_rank=False, @@ -680,7 +680,7 @@ def suggest_planning_exercises( ) if llm_rank_applied: retrieval_phase = compose_retrieval_phase( - profile_preselect=profile_preselect_applied, + full_library=full_library_ranked, query_intent=query_intent_applied, llm_expectation=llm_expectation_applied, llm_rank=True, @@ -718,7 +718,8 @@ def suggest_planning_exercises( "scenario_kind": scenario_kind, "query_intent_summary": query_intent_summary, "retrieval_phase": retrieval_phase, - "profile_preselect_applied": profile_preselect_applied, + "full_library_ranked": full_library_ranked, + "profile_preselect_applied": False, "llm_rank_applied": llm_rank_applied, "llm_intent_applied": query_intent_applied, "llm_expectation_applied": llm_expectation_applied, diff --git a/backend/planning_exercise_target_pipeline.py b/backend/planning_exercise_target_pipeline.py index 0026d16..199a794 100644 --- a/backend/planning_exercise_target_pipeline.py +++ b/backend/planning_exercise_target_pipeline.py @@ -385,14 +385,15 @@ VALID_SCENARIOS_SET = { def compose_retrieval_phase( *, + full_library: bool = False, profile_preselect: bool = False, query_intent: bool = False, llm_expectation: bool = False, llm_rank: bool = False, ) -> str: parts = ["profile_v1"] - if profile_preselect: - parts.append("profile_preselect") + if full_library or profile_preselect: + parts.append("full_library") if llm_expectation: parts.append("llm_expectation") elif query_intent: diff --git a/backend/tests/test_planning_exercise_retrieval.py b/backend/tests/test_planning_exercise_retrieval.py index 8e4b834..e16972a 100644 --- a/backend/tests/test_planning_exercise_retrieval.py +++ b/backend/tests/test_planning_exercise_retrieval.py @@ -1,8 +1,12 @@ -"""Tests Planungs-Retrieval SQL-Parameter.""" -from planning_exercise_retrieval import fetch_retrieval_candidate_rows +"""Tests Planungs-Retrieval Phase A (Voll-Library-Ranking).""" +from planning_exercise_profiles import ExerciseMatchProfile, PlanningTargetProfile +from planning_exercise_retrieval import ( + fetch_all_visible_exercise_rows, + rank_visible_library_hits, +) -def test_fetch_retrieval_binds_query_before_visibility_params(): +def test_fetch_all_visible_has_no_profile_or_pool_filter(): captured = {} class _Cur: @@ -11,33 +15,95 @@ def test_fetch_retrieval_binds_query_before_visibility_params(): captured["params"] = list(params) def fetchall(self): - return [ - { - "id": 1, - "title": "Test", - "summary": "", - "primary_focus_name": None, - "ft_rank": 0.2, - } - ] + return [] - fetch_retrieval_candidate_rows( + fetch_all_visible_exercise_rows( _Cur(), vis_sql="(e.visibility = 'official' OR (e.visibility = 'private' AND e.created_by = %s))", vis_params=[42], - query="nächste Übung planen", - exercise_kind_any=None, - target=__import__( - "planning_exercise_profiles", fromlist=["PlanningTargetProfile"] - ).PlanningTargetProfile(), - progression_successor_ids=set(), - anchor_skill_ids={7}, - raw_pool_limit=10, + query="Kime Partner", + exercise_kind_any=["simple"], ) + sql = captured["sql"].lower() + assert "exercise_skills" not in sql + assert "@@ plainto_tsquery" not in sql + assert " exists " not in sql.replace("primary_focus_name", "") params = captured["params"] - assert params[0] == "nächste Übung planen" + assert params[0] == "Kime Partner" assert params[1] == 42 assert params[2] == "archived" - assert params[-2] == "nächste Übung planen" - assert params[-1] == 10 + assert params[3] == "simple" + assert params[-1] == 8000 + + +def test_rank_visible_library_prefers_profile_over_untagged_pool_miss(): + """Übung ohne Pool-Tags, aber hoher Profil-Match, muss vor schwachem Treffer ranken.""" + + target = PlanningTargetProfile( + focus_area_ids={10: 1.0}, + skill_weights={5: 1.0}, + sources=["framework_catalog"], + ) + rows = [ + {"id": 1, "title": "Schwach", "summary": "", "primary_focus_name": "X", "ft_rank": 0.9}, + {"id": 2, "title": "Stark", "summary": "", "primary_focus_name": "Karate", "ft_rank": 0.0}, + ] + + profiles = { + 1: ExerciseMatchProfile(exercise_id=1, focus_area_ids={99: 1.0}), + 2: ExerciseMatchProfile(exercise_id=2, focus_area_ids={10: 1.0}, skill_weights={5: 1.0}), + } + + class _Cur: + def execute(self, sql, params=None): + return None + + def fetchall(self): + return [] + + def _fake_load(cur, exercise_ids, *, batch=400): + _ = (cur, batch) + return {eid: profiles[eid] for eid in exercise_ids if eid in profiles} + + def _fake_skills(cur, exercise_ids, *, batch=400): + _ = (cur, batch) + return {1: {99}, 2: {5, 10}} + + import planning_exercise_retrieval as mod + + orig_profiles = mod._load_match_profiles_chunked + orig_skills = mod._load_skill_sets_chunked + try: + mod._load_match_profiles_chunked = _fake_load + mod._load_skill_sets_chunked = _fake_skills + hits, _ = rank_visible_library_hits( + _Cur(), + rows, + query="", + intent="suggest_next", + intent_weights={ + "fulltext": 0.08, + "progression": 0.28, + "skill": 0.12, + "plan": 0.10, + "profile": 0.25, + "repeat_unit": -0.30, + "repeat_group": -0.15, + }, + target=target, + pack={ + "planned_exercise_ids": [], + "group_recent_exercise_ids": [], + "progression_successor_ids": [], + "anchor_skill_ids": [], + "anchor_exercise_id": None, + "progression_edge_notes": {}, + }, + ) + finally: + mod._load_match_profiles_chunked = orig_profiles + mod._load_skill_sets_chunked = orig_skills + + assert hits[0]["id"] == 2 + assert hits[0]["score"] > hits[1]["score"] diff --git a/backend/tests/test_planning_exercise_suggest.py b/backend/tests/test_planning_exercise_suggest.py index 5c6e194..4f5a951 100644 --- a/backend/tests/test_planning_exercise_suggest.py +++ b/backend/tests/test_planning_exercise_suggest.py @@ -73,10 +73,9 @@ def test_compose_retrieval_phase(): assert compose_retrieval_phase(query_intent=False, llm_rank=False) == "profile_v1" assert compose_retrieval_phase(query_intent=True, llm_rank=True) == "profile_v1+query_intent+llm_rank" - assert ( - compose_retrieval_phase(profile_preselect=True, query_intent=True, llm_rank=False) - == "profile_v1+profile_preselect+query_intent" + compose_retrieval_phase(full_library=True, query_intent=True, llm_rank=False) + == "profile_v1+full_library+query_intent" ) @@ -119,6 +118,10 @@ def test_compose_retrieval_phase_llm_expectation(): compose_retrieval_phase(llm_expectation=True) == "profile_v1+llm_expectation" ) + assert ( + compose_retrieval_phase(full_library=True, llm_expectation=True) + == "profile_v1+full_library+llm_expectation" + ) def test_query_only_expectation_without_planning_reference(): diff --git a/backend/version.py b/backend/version.py index 3f6c0ae..64c517e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.176" +APP_VERSION = "0.8.177" BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260531074" @@ -28,7 +28,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay - "planning_exercise_suggest": "0.7.0", # LLM-Erwartungsprofil aus Kontext (preset); Migration 074 + "planning_exercise_suggest": "0.8.0", # Phase A: Voll-Library-Ranking gegen Erwartungsprofil "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -43,6 +43,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.177", + "date": "2026-05-22", + "changes": [ + "Planungs-Übungssuche Phase A: gesamte sichtbare Bibliothek deterministisch gegen Erwartungsprofil gerankt.", + "Kein OR-Profil-Pool mehr; Volltext nur noch als Score-Signal; retrieval_phase full_library.", + ], + }, { "version": "0.8.176", "date": "2026-05-22", -- 2.43.0 From f4196c3580d245a80522ab8d1c4e6af2dc9bcf72 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 23 May 2026 07:35:45 +0200 Subject: [PATCH 15/16] Add Exercise Enrichment Admin API and Update Documentation - Introduced the `exercise_enrichment_admin` API for batch exercise enrichment, allowing superadmins to filter candidates, preview, and apply skills. - Updated the access layer documentation to include the new endpoint and its exempt status. - Enhanced the frontend with a new admin page for exercise enrichment and updated navigation to include this feature. - Incremented version to 0.8.179 and updated changelog to reflect these additions and improvements. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 4 +- .../docs/working/EXERCISE_ENRICHMENT_ADMIN.md | 66 ++ backend/exercise_enrichment.py | 534 +++++++++++ backend/main.py | 3 +- backend/routers/exercise_enrichment_admin.py | 415 +++++++++ backend/scripts/check_access_layer_hints.py | 1 + .../tests/test_exercise_enrichment_admin.py | 282 ++++++ backend/version.py | 21 +- frontend/src/App.jsx | 9 + frontend/src/components/AdminPageNav.jsx | 3 +- .../src/pages/AdminExerciseEnrichmentPage.jsx | 881 ++++++++++++++++++ frontend/src/utils/api.js | 49 + 12 files changed, 2263 insertions(+), 5 deletions(-) create mode 100644 .claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md create mode 100644 backend/exercise_enrichment.py create mode 100644 backend/routers/exercise_enrichment_admin.py create mode 100644 backend/tests/test_exercise_enrichment_admin.py create mode 100644 frontend/src/pages/AdminExerciseEnrichmentPage.jsx diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index e16267c..8c42a97 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -37,17 +37,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT | | ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug | | ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext | +| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext | **Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-30 — Superadmin `/api/admin/ai-prompts*` (Prompt-Pflege, Vorschau ohne OpenRouter); weiterhin suggest + Retrieval-Profile. +Letzte Änderung: 2026-05-23 — Superadmin `/api/admin/exercise-enrichment/*` (Batch-KI Skills, Status in_review). --- ### Changelog (Fortführung) +- **2026-05-23:** Superadmin-API `exercise_enrichment_admin` (Batch-Übungs-Anreicherung KI) dokumentiert. - **2026-05-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert. - **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert. - **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert. diff --git a/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md b/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md new file mode 100644 index 0000000..16cf56a --- /dev/null +++ b/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md @@ -0,0 +1,66 @@ +# Superadmin: Übungs-Anreicherung per KI + +Stand: 2026-05-23 · App 0.8.178 + +## Zweck + +Plattform-weites Werkzeug für Superadmins, um Übungen (typisch `draft`, ohne Skills) **batchweise** per KI mit Fähigkeiten anzureichern und kontrolliert auf `in_review` zu setzen. + +Verbessert indirekt die Planungs-KI (`POST /api/planning/exercise-suggest`), die gegen Skill-Profile rankt — unvollständige `exercise_skills` führen dort zu Volltext-dominiertem Ranking. + +## UI + +- Route: `/admin/exercise-enrichment` (nur Superadmin) +- Admin-Menü: „Übungs-Anreicherung“ + +## API + +Prefix: `/api/admin/exercise-enrichment` + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| GET | `/candidates` | Paginierte Kandidaten (Filter: status, visibility, focus_area, without_skills, with_ai_suggested_skills, include_club, search) | +| POST | `/preview` | Dry-Run — `{ exercise_ids[], modes: { skills, summary }, merge_mode }` | +| POST | `/apply` | `{ items: [{ exercise_id, merged_skills }], merge_mode, set_status }` | + +Auth: `require_auth` + `is_superadmin` — **kein** `TenantContext` (EXEMPT, siehe ACCESS_LAYER_ENDPOINT_AUDIT.md). + +## KI + +Wiederverwendet `run_exercise_form_ai_suggestion` → Prompts `exercise_skill_suggestions` (MVP Pflicht), optional `exercise_summary`. Skill-Katalog via `build_contextual_skills_catalog_block` / `ai_skill_retrieval_profiles`. + +## Merge-Modi (Skills) + +- `additive` (Default): manuelle Skills bleiben; KI ergänzt neue; bestehende `ai_suggested`-Links werden aktualisiert +- `replace_ai_only`: nur `ai_suggested=true` entfernen, dann KI-Set anwenden +- `replace_all`: alle Skills ersetzen (explizit) + +## Defaults + +- Kandidaten: **Status** primär (Default `draft`); Sichtbarkeit Default **`private`**, wählbar bis „Alle“ +- Skill-Merge Default: **`replace_all`** (alle Skills KI-neu, `ai_suggested=true` — unterscheidbar von manuell) +- Nach Apply: `set_status=in_review` (nie automatisch `approved`) +- Batch: keine Gesamtgrenze (bis 10.000 IDs); **Analyze** + explizite Nutzerbestätigung; HTTP-Chunks à 25/100 + +## Inhalte (modular) + +| Modus | Prompt | Apply-Felder | +|-------|--------|--------------| +| Skills | `exercise_skill_suggestions` | `exercise_skills` inkl. Intensität, required/target_level, `ai_suggested` | +| Summary | `exercise_summary` | `summary`, `summary_ai_generated=true` | +| Anleitung | `exercise_instruction_rewrite` | `goal`, `execution`, `preparation`, `trainer_notes` | + +## API (ergänzt) + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| GET | `/candidate-ids` | Alle IDs zum Filter (Select-all) | +| POST | `/analyze` | `{ exercise_ids[], modes }` → Kosten-Schätzung vor Start | + +## Keine Migration + +Bestehende Spalte `exercise_skills.ai_suggested` reicht; kein Enrichment-Log in MVP. + +## Tests + +`backend/tests/test_exercise_enrichment_admin.py` — 403, Merge-Logik, Status draft→in_review. diff --git a/backend/exercise_enrichment.py b/backend/exercise_enrichment.py new file mode 100644 index 0000000..f9da6c6 --- /dev/null +++ b/backend/exercise_enrichment.py @@ -0,0 +1,534 @@ +""" +Superadmin-Werkzeug: Übungs-Anreicherung per KI (Skills + optional Metadaten). + +Wiederverwendet run_exercise_form_ai_suggestion / exercise_ai — keine neue OpenRouter-Pipeline. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional + +from ai_prompt_context import ExerciseFormAiPromptContext +from ai_prompt_job import run_exercise_form_ai_suggestion +from exercise_ai import strip_html_to_plain +from exercise_rich_text import normalize_inline_exercise_media_markup + +from routers.exercises import ( + enrich_exercise_detail, + normalize_exercise_skill_intensity, + normalize_exercise_skill_level, +) + +SkillMergeMode = Literal["additive", "replace_ai_only", "replace_all"] + +SKILL_MERGE_MODES = frozenset({"additive", "replace_ai_only", "replace_all"}) +DEFAULT_SET_STATUS = "in_review" +# Max. IDs pro einzelner HTTP-Anfrage (Frontend chunked darüber hinaus). +MAX_BATCH_EXERCISES = 100 + +_INSTRUCTION_FIELDS = ("goal", "execution", "preparation", "trainer_notes") +_SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary") + + +def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]: + rows: list[tuple[int, bool]] = [] + for row in exercise.get("focus_areas") or []: + if not isinstance(row, dict): + continue + try: + fid = int(row.get("focus_area_id")) + except (TypeError, ValueError): + continue + if fid < 1: + continue + rows.append((fid, bool(row.get("is_primary")))) + rows.sort(key=lambda x: (not x[1], x[0])) + return rows + + +def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str: + parts: List[str] = [] + for row in exercise.get("focus_areas") or []: + if isinstance(row, dict): + nm = (row.get("name") or "").strip() + if nm: + parts.append(nm) + txt = ", ".join(parts).strip() + if len(txt) > 900: + return txt[:899] + "…" + return txt + + +def build_form_context_from_exercise(exercise: Dict[str, Any]) -> ExerciseFormAiPromptContext: + focus = _focus_area_hint_from_detail(exercise) + fctx = _focus_areas_ai_ctx_from_detail(exercise) + return ExerciseFormAiPromptContext.from_focus_tuples( + title=str(exercise.get("title") or "").strip(), + goal=exercise.get("goal"), + execution=exercise.get("execution"), + preparation=exercise.get("preparation"), + trainer_notes=exercise.get("trainer_notes"), + focus_hint=focus or None, + focus_tuples=fctx or None, + ) + + +def validate_exercise_for_enrichment( + exercise: Dict[str, Any], + *, + want_skills: bool = False, + want_summary: bool = False, + want_instructions: bool = False, +) -> Optional[str]: + title = str(exercise.get("title") or "").strip() + if not title: + return "Titel fehlt" + + ctx = build_form_context_from_exercise(exercise) + g_plain = strip_html_to_plain(exercise.get("goal")) + e_plain = strip_html_to_plain(exercise.get("execution")) + + if want_skills or want_summary: + if not (g_plain.strip() or e_plain.strip()): + return "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)" + + if want_instructions and not ctx.has_instruction_source_text(): + return "Für Anleitungs-Überarbeitung fehlt Ausgangstext (Titel oder Anleitungsfeld)" + + if not (want_skills or want_summary or want_instructions): + return "Kein Anreicherungsmodus aktiv" + + return None + + +def _normalize_skill_row(raw: Dict[str, Any], *, ai_suggested: bool) -> Dict[str, Any]: + return { + "skill_id": int(raw["skill_id"]), + "skill_name": (raw.get("skill_name") or "").strip() or f"Skill #{raw['skill_id']}", + "skill_category": raw.get("skill_category"), + "is_primary": bool(raw.get("is_primary")), + "intensity": normalize_exercise_skill_intensity(raw.get("intensity")), + "required_level": normalize_exercise_skill_level(raw.get("required_level")), + "target_level": normalize_exercise_skill_level(raw.get("target_level")), + "ai_suggested": ai_suggested, + } + + +def _skill_meta_differs(a: Dict[str, Any], b: Dict[str, Any]) -> bool: + for k in _SKILL_COMPARE_KEYS: + av = a.get(k) + bv = b.get(k) + if k in ("required_level", "target_level"): + av = normalize_exercise_skill_level(av) + bv = normalize_exercise_skill_level(bv) + elif k == "intensity": + av = normalize_exercise_skill_intensity(av) + bv = normalize_exercise_skill_intensity(bv) + elif k == "is_primary": + av = bool(av) + bv = bool(bv) + if av != bv: + return True + return False + + +def merge_skills( + existing: List[Dict[str, Any]], + suggested: List[Dict[str, Any]], + mode: SkillMergeMode, +) -> List[Dict[str, Any]]: + """Merge-Modi: additive | replace_ai_only | replace_all (alle KI-Skills mit ai_suggested=true).""" + existing_norm = [_normalize_skill_row(s, ai_suggested=bool(s.get("ai_suggested"))) for s in existing] + suggested_norm = [_normalize_skill_row(s, ai_suggested=True) for s in suggested] + + suggested_by_id = {int(s["skill_id"]): s for s in suggested_norm} + + if mode == "replace_all": + return list(suggested_norm) + + if mode == "replace_ai_only": + manual = [s for s in existing_norm if not s.get("ai_suggested")] + manual_ids = {int(s["skill_id"]) for s in manual} + result = list(manual) + for s in suggested_norm: + sid = int(s["skill_id"]) + if sid in manual_ids: + continue + result.append(s) + return result + + # additive + result: List[Dict[str, Any]] = [] + seen: set[int] = set() + for s in existing_norm: + sid = int(s["skill_id"]) + seen.add(sid) + if sid in suggested_by_id and s.get("ai_suggested"): + merged = {**s, **suggested_by_id[sid], "ai_suggested": True} + result.append(merged) + else: + result.append(dict(s)) + for s in suggested_norm: + sid = int(s["skill_id"]) + if sid not in seen: + result.append(s) + seen.add(sid) + return result + + +def compute_skill_diff( + before: List[Dict[str, Any]], + after: List[Dict[str, Any]], +) -> Dict[str, Any]: + before_ids = {int(s["skill_id"]): s for s in before} + after_ids = {int(s["skill_id"]): s for s in after} + added = [after_ids[i] for i in sorted(after_ids) if i not in before_ids] + removed = [before_ids[i] for i in sorted(before_ids) if i not in after_ids] + changed: List[Dict[str, Any]] = [] + for sid in before_ids: + if sid in after_ids and _skill_meta_differs(before_ids[sid], after_ids[sid]): + changed.append( + { + "skill_id": sid, + "skill_name": after_ids[sid].get("skill_name") or before_ids[sid].get("skill_name"), + "before": before_ids[sid], + "after": after_ids[sid], + } + ) + kept = [ + before_ids[i] + for i in sorted(before_ids) + if i in after_ids and i not in {c["skill_id"] for c in changed} + ] + return {"added": added, "removed": removed, "changed": changed, "kept": kept} + + +def _skills_from_ai_payload(payload: Dict[str, Any]) -> List[Dict[str, Any]]: + rows = payload.get("skills") + if not isinstance(rows, list): + return [] + return [_normalize_skill_row(r, ai_suggested=True) for r in rows if isinstance(r, dict) and r.get("skill_id")] + + +def _summary_from_ai_payload(payload: Dict[str, Any]) -> Optional[str]: + block = payload.get("summary") + if isinstance(block, dict): + text = (block.get("text") or "").strip() + return text or None + if isinstance(block, str) and block.strip(): + return block.strip() + return None + + +def _instructions_from_ai_payload(payload: Dict[str, Any]) -> Dict[str, str]: + block = payload.get("instructions") + if not isinstance(block, dict): + return {} + fields = block.get("fields") + if not isinstance(fields, dict): + return {} + out: Dict[str, str] = {} + for key in _INSTRUCTION_FIELDS: + val = fields.get(key) + if val is not None and str(val).strip(): + out[key] = str(val).strip() + return out + + +def _instruction_snapshot(exercise: Dict[str, Any]) -> Dict[str, str]: + out: Dict[str, str] = {} + for key in _INSTRUCTION_FIELDS: + raw = exercise.get(key) + plain = strip_html_to_plain(raw, max_len=400) if raw else "" + if plain.strip(): + out[key] = plain.strip() + return out + + +def compute_instruction_diff( + before: Dict[str, str], + after: Dict[str, str], +) -> Dict[str, Any]: + changed: List[Dict[str, Any]] = [] + added: List[str] = [] + for key in _INSTRUCTION_FIELDS: + b = (before.get(key) or "").strip() + a = (after.get(key) or "").strip() + if not a: + continue + if not b: + added.append(key) + elif b != strip_html_to_plain(a, max_len=400).strip() and b != a: + changed.append({"field": key, "before_plain": b, "after_html": a}) + return {"changed_fields": changed, "added_fields": added} + + +def preview_exercise_enrichment( + cur, + exercise_id: int, + *, + want_skills: bool = True, + want_summary: bool = False, + want_instructions: bool = False, + merge_mode: SkillMergeMode = "additive", +) -> Dict[str, Any]: + exercise = enrich_exercise_detail(exercise_id, cur) + if not exercise: + return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"} + + skip_reason = validate_exercise_for_enrichment( + exercise, + want_skills=want_skills, + want_summary=want_summary, + want_instructions=want_instructions, + ) + if skip_reason: + return { + "exercise_id": exercise_id, + "ok": False, + "skipped": True, + "error": skip_reason, + "title": exercise.get("title"), + "status": exercise.get("status"), + } + + existing = exercise.get("skills") or [] + suggested: List[Dict[str, Any]] = [] + ai_meta: Dict[str, Any] = {} + payload: Dict[str, Any] = {} + suggested_summary: Optional[str] = None + suggested_instructions: Dict[str, str] = {} + + if want_skills or want_summary or want_instructions: + ctx = build_form_context_from_exercise(exercise) + payload = run_exercise_form_ai_suggestion( + cur, + ctx, + want_summary=want_summary, + want_skills=want_skills, + want_instructions=want_instructions, + ) + if want_skills: + suggested = _skills_from_ai_payload(payload) + if want_summary: + suggested_summary = _summary_from_ai_payload(payload) + if want_instructions: + suggested_instructions = _instructions_from_ai_payload(payload) + ai_meta = { + "models": payload.get("models_by_slug") or {}, + "llm_calls": sum([want_skills, want_summary, want_instructions]), + } + + merged = merge_skills(existing, suggested, merge_mode) if want_skills else list(existing) + diff = compute_skill_diff(existing, merged) if want_skills else None + + existing_summary = (exercise.get("summary") or "").strip() or None + instr_before = _instruction_snapshot(exercise) + instr_after_plain = { + k: strip_html_to_plain(v, max_len=400) for k, v in suggested_instructions.items() + } + instruction_diff = ( + compute_instruction_diff(instr_before, instr_after_plain) if want_instructions else None + ) + + return { + "exercise_id": exercise_id, + "ok": True, + "title": exercise.get("title"), + "status": exercise.get("status"), + "visibility": exercise.get("visibility"), + "primary_focus_name": _primary_focus_from_exercise(exercise), + "existing_skills": existing, + "suggested_skills": suggested, + "merged_skills": merged, + "diff": diff, + "existing_summary": existing_summary, + "suggested_summary": suggested_summary, + "existing_instructions": instr_before, + "suggested_instructions": suggested_instructions, + "instruction_diff": instruction_diff, + "ai_meta": ai_meta, + } + + +def _primary_focus_from_exercise(exercise: Dict[str, Any]) -> Optional[str]: + for row in exercise.get("focus_areas") or []: + if isinstance(row, dict) and row.get("is_primary"): + return (row.get("name") or "").strip() or None + for row in exercise.get("focus_areas") or []: + if isinstance(row, dict): + nm = (row.get("name") or "").strip() + if nm: + return nm + return None + + +def persist_merged_skills(cur, exercise_id: int, merged: List[Dict[str, Any]], merge_mode: SkillMergeMode) -> None: + if merge_mode == "replace_all": + cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,)) + elif merge_mode == "replace_ai_only": + cur.execute( + "DELETE FROM exercise_skills WHERE exercise_id = %s AND ai_suggested = true", + (exercise_id,), + ) + + for sk in merged: + cur.execute( + """ + INSERT INTO exercise_skills + (exercise_id, skill_id, is_primary, intensity, required_level, target_level, ai_suggested) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (exercise_id, skill_id) DO UPDATE SET + intensity = CASE + WHEN exercise_skills.ai_suggested = false AND %s = 'additive' + THEN exercise_skills.intensity ELSE EXCLUDED.intensity END, + required_level = CASE + WHEN exercise_skills.ai_suggested = false AND %s = 'additive' + THEN exercise_skills.required_level ELSE EXCLUDED.required_level END, + target_level = CASE + WHEN exercise_skills.ai_suggested = false AND %s = 'additive' + THEN exercise_skills.target_level ELSE EXCLUDED.target_level END, + is_primary = CASE + WHEN exercise_skills.ai_suggested = false AND %s = 'additive' + THEN exercise_skills.is_primary ELSE EXCLUDED.is_primary END, + ai_suggested = CASE + WHEN exercise_skills.ai_suggested = false AND %s = 'additive' + THEN exercise_skills.ai_suggested ELSE EXCLUDED.ai_suggested END + """, + ( + exercise_id, + int(sk["skill_id"]), + bool(sk.get("is_primary")), + normalize_exercise_skill_intensity(sk.get("intensity")), + normalize_exercise_skill_level(sk.get("required_level")), + normalize_exercise_skill_level(sk.get("target_level")), + bool(sk.get("ai_suggested")), + merge_mode, + merge_mode, + merge_mode, + merge_mode, + merge_mode, + ), + ) + + +def _normalize_instruction_fields(fields: Optional[Dict[str, Any]]) -> Dict[str, str]: + if not fields: + return {} + out: Dict[str, str] = {} + for key in _INSTRUCTION_FIELDS: + if key not in fields: + continue + raw = fields.get(key) + if raw is None or not str(raw).strip(): + continue + out[key] = normalize_inline_exercise_media_markup(str(raw).strip()) + return out + + +def apply_exercise_enrichment( + cur, + exercise_id: int, + *, + merged_skills: Optional[List[Dict[str, Any]]] = None, + merge_mode: SkillMergeMode = "additive", + set_status: Optional[str] = DEFAULT_SET_STATUS, + apply_skills: bool = False, + summary_text: Optional[str] = None, + apply_summary: bool = False, + instruction_fields: Optional[Dict[str, Any]] = None, + apply_instructions: bool = False, +) -> Dict[str, Any]: + exercise = enrich_exercise_detail(exercise_id, cur) + if not exercise: + return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"} + + skip_reason = validate_exercise_for_enrichment( + exercise, + want_skills=apply_skills, + want_summary=apply_summary, + want_instructions=apply_instructions, + ) + if skip_reason: + return { + "exercise_id": exercise_id, + "ok": False, + "skipped": True, + "error": skip_reason, + } + + skills_list = merged_skills or [] + if apply_skills: + if not skills_list and merge_mode != "replace_all": + return { + "exercise_id": exercise_id, + "ok": False, + "error": "Keine Skills zum Anwenden", + } + persist_merged_skills(cur, exercise_id, skills_list, merge_mode) + + sets: List[str] = [] + vals: List[Any] = [] + + if apply_summary and summary_text is not None: + text = str(summary_text).strip() + if text: + sets.extend(["summary = %s", "summary_ai_generated = true"]) + vals.append(text[:220]) + + if apply_instructions: + norm = _normalize_instruction_fields(instruction_fields) + for key, val in norm.items(): + sets.append(f"{key} = %s") + vals.append(val) + + new_status = (set_status or "").strip().lower() or None + if new_status: + if new_status == "approved": + return { + "exercise_id": exercise_id, + "ok": False, + "error": "Automatisches Freigeben (approved) ist nicht erlaubt", + } + if new_status not in ("draft", "in_review", "archived"): + return {"exercise_id": exercise_id, "ok": False, "error": "Ungültiger Ziel-Status"} + sets.append("status = %s") + vals.append(new_status) + + if sets: + sets.append("updated_at = NOW()") + vals.append(exercise_id) + cur.execute( + f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s", + tuple(vals), + ) + elif not apply_skills: + return {"exercise_id": exercise_id, "ok": False, "error": "Nichts anzuwenden"} + + return { + "exercise_id": exercise_id, + "ok": True, + "status": new_status or exercise.get("status"), + "skills_applied": len(skills_list) if apply_skills else 0, + "summary_applied": apply_summary and bool(summary_text and str(summary_text).strip()), + "instructions_applied": apply_instructions and bool(_normalize_instruction_fields(instruction_fields)), + } + + +def estimate_llm_calls( + *, + exercise_count: int, + want_skills: bool, + want_summary: bool, + want_instructions: bool = False, +) -> Dict[str, Any]: + per_skills = exercise_count if want_skills else 0 + per_summary = exercise_count if want_summary else 0 + per_instructions = exercise_count if want_instructions else 0 + total = per_skills + per_summary + per_instructions + return { + "total": total, + "per_exercise": sum([want_skills, want_summary, want_instructions]), + "skills": per_skills, + "summary": per_summary, + "instructions": per_instructions, + } diff --git a/backend/main.py b/backend/main.py index 5c15d97..3fe7898 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, 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 +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, exercise_enrichment_admin app.include_router(auth.router) app.include_router(profiles.router) @@ -224,6 +224,7 @@ app.include_router(legal_documents.router) app.include_router(content_reports.router) app.include_router(ai_prompts_admin.router) app.include_router(ai_skill_retrieval_admin.router) +app.include_router(exercise_enrichment_admin.router) # Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad # GET /api/exercises/{id}/media/{mid}/file (?ssetoken für /
)} + {exerciseCount >= 10 && ( +

+ Die Vorschau läuft in Paketen à 3 Übungen (Gateway-Timeout vermeiden). Fenster offen lassen — + bei {exerciseCount} Übungen ca. {Math.ceil(exerciseCount / 3)} Pakete. +

+ )} {exerciseCount >= 25 && ( -

- Großer Batch — OpenRouter-Kosten und Laufzeit beachten. Der Lauf kann mehrere Minuten dauern. +

+ Großer Batch — OpenRouter-Kosten und Gesamtlaufzeit (mehrere Minuten) beachten.

)}
@@ -432,6 +440,24 @@ export default function AdminExerciseEnrichmentPage() { setDialogOpen(true) } + async function previewChunkWithRetry(chunk, attempt = 0) { + try { + return await api.previewExerciseEnrichment({ + exercise_ids: chunk, + modes, + merge_mode: mergeMode, + }) + } catch (e) { + const msg = e.message || String(e) + const isTimeout = /504|502|timeout/i.test(msg) + if (isTimeout && attempt < 2) { + await new Promise((r) => setTimeout(r, 2500)) + return previewChunkWithRetry(chunk, attempt + 1) + } + throw e + } + } + async function runPreviewFromDialog() { setDialogOpen(false) setError('') @@ -440,18 +466,21 @@ export default function AdminExerciseEnrichmentPage() { const allResults = [] const allErrors = [] let estTotal = 0 + const totalChunks = Math.ceil(selectedIds.length / PREVIEW_CHUNK_SIZE) setJobProgress({ done: 0, total: selectedIds.length, phase: 'Vorschau' }) try { - for (let i = 0; i < selectedIds.length; i += CHUNK_SIZE) { + for (let i = 0; i < selectedIds.length; i += PREVIEW_CHUNK_SIZE) { if (abortRef.current) break - const chunk = selectedIds.slice(i, i + CHUNK_SIZE) - const resp = await api.previewExerciseEnrichment({ - exercise_ids: chunk, - modes, - merge_mode: mergeMode, + const chunk = selectedIds.slice(i, i + PREVIEW_CHUNK_SIZE) + const chunkNo = Math.floor(i / PREVIEW_CHUNK_SIZE) + 1 + setJobProgress({ + done: i, + total: selectedIds.length, + phase: `Vorschau Paket ${chunkNo}/${totalChunks}`, }) + const resp = await previewChunkWithRetry(chunk) allResults.push(...(resp.results || [])) allErrors.push(...(resp.errors || [])) const est = resp.estimated_llm_calls @@ -459,7 +488,7 @@ export default function AdminExerciseEnrichmentPage() { setJobProgress({ done: Math.min(i + chunk.length, selectedIds.length), total: selectedIds.length, - phase: 'Vorschau', + phase: `Vorschau Paket ${chunkNo}/${totalChunks}`, }) } setPreviewRows(allResults) @@ -517,9 +546,9 @@ export default function AdminExerciseEnrichmentPage() { setJobProgress({ done: 0, total: applyItems.length, phase: 'Anwenden' }) try { - for (let i = 0; i < applyItems.length; i += CHUNK_SIZE) { + for (let i = 0; i < applyItems.length; i += APPLY_CHUNK_SIZE) { if (abortRef.current) break - const chunk = applyItems.slice(i, i + CHUNK_SIZE) + const chunk = applyItems.slice(i, i + APPLY_CHUNK_SIZE) const resp = await api.applyExerciseEnrichment({ items: chunk, modes: previewMeta?.modes || modes, -- 2.43.0