diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index 9b0f238..c4b10db 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -189,7 +189,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: | **A** | Voll-Library Hybrid-Ranking | ✅ **0.8.177** | | **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** | | **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** | -| **C2** | Varianten in Trefferliste / Picker | 🔲 | +| **C2** | Varianten in Trefferliste / Picker | ✅ **0.8.184** | | **C3** | Graph-Builder (Ziel → Pfad → speichern) | 🔲 | | **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 | @@ -210,7 +210,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: - **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage). - **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen. -- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer noch nicht (**C2**). +- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer (**C2** — Dropdown + Graph-Vorschlag). - **Graph-Builder:** Ziel eingeben → aufbauende Übungen → in Graph speichern (**C3**) — Compound-Nutzen über viele Pläne. - **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**). - **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score. @@ -419,8 +419,14 @@ Treffer: optional `hits[].suggested_variant_id`. --- -## 20. Phase C2 / C3 — Roadmap (offen) +## 20. Phase C2 — Varianten in Treffern (0.8.184) ✅ -**C2:** Varianten in Trefferliste / Picker-Auswahl bei Graph-Treffern. +- API: `variants[]`, `suggested_variant_name` pro Treffer (Batch aus `exercise_variants`). +- **`ExercisePickerModal`:** Dropdown pro Treffer; Graph-`suggested_variant_id` vorausgewählt; Übernahme setzt `exercise_variant_id`. +- **`hydrateExercisePlanningRow`:** übernimmt `exercise_variant_id` / `suggested_variant_id` in die Planungszeile. -**C3:** Graph-Builder — Ziel eingeben, aufbauende Übungen vorschlagen, nach Review in Graph speichern (`POST …/edges/sequence`). Nutzen über viele Pläne hinweg. +--- + +## 21. Phase C3 — Graph-Builder (Roadmap, offen) + +Ziel eingeben → aufbauende Übungen vorschlagen → nach Review in Graph speichern (`POST …/edges/sequence`). Nutzen über viele Pläne hinweg. diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 4b0b9ed..436c2c8 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -211,6 +211,79 @@ def _resolve_anchor_variant_id( return None +def _enrich_planning_hits_with_variant_meta( + cur, + hits: Sequence[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Variantennamen und -listen für Treffer mit suggested_variant_id (Phase C2).""" + if not hits: + return [] + variant_ids: Set[int] = set() + exercise_ids: Set[int] = set() + for h in hits: + exercise_ids.add(int(h["id"])) + raw = h.get("suggested_variant_id") + if raw is not None: + try: + vid = int(raw) + except (TypeError, ValueError): + vid = 0 + if vid > 0: + variant_ids.add(vid) + + names_by_variant: Dict[int, str] = {} + if variant_ids: + ph = ",".join(["%s"] * len(variant_ids)) + cur.execute( + f"SELECT id, variant_name FROM exercise_variants WHERE id IN ({ph})", + list(variant_ids), + ) + for row in cur.fetchall(): + n = (row.get("variant_name") or "").strip() + if n: + names_by_variant[int(row["id"])] = n + + variants_by_exercise: Dict[int, List[Dict[str, Any]]] = {} + if exercise_ids: + ph = ",".join(["%s"] * len(exercise_ids)) + cur.execute( + f""" + SELECT exercise_id, id, variant_name, sequence_order + FROM exercise_variants + WHERE exercise_id IN ({ph}) + ORDER BY exercise_id, sequence_order NULLS LAST, id + """, + list(exercise_ids), + ) + for row in cur.fetchall(): + eid = int(row["exercise_id"]) + variants_by_exercise.setdefault(eid, []).append( + { + "id": int(row["id"]), + "variant_name": (row.get("variant_name") or "").strip() or None, + "sequence_order": row.get("sequence_order"), + } + ) + + out: List[Dict[str, Any]] = [] + for h in hits: + item = dict(h) + eid = int(item["id"]) + vars_for_ex = variants_by_exercise.get(eid) or [] + if vars_for_ex: + item["variants"] = vars_for_ex + raw_vid = item.get("suggested_variant_id") + if raw_vid is not None: + try: + vid = int(raw_vid) + except (TypeError, ValueError): + vid = 0 + if vid > 0: + item["suggested_variant_name"] = names_by_variant.get(vid) + out.append(item) + return out + + def _finalize_progression_context( cur, tenant: TenantContext, @@ -728,6 +801,7 @@ def suggest_planning_exercises( hits = hits[: int(body.limit)] hits = hits[: int(body.limit)] + hits = _enrich_planning_hits_with_variant_meta(cur, hits) context_summary = { "unit_title": pack.get("unit_title"), diff --git a/backend/tests/test_planning_exercise_suggest_variants.py b/backend/tests/test_planning_exercise_suggest_variants.py new file mode 100644 index 0000000..f20915c --- /dev/null +++ b/backend/tests/test_planning_exercise_suggest_variants.py @@ -0,0 +1,43 @@ +"""Tests Phase C2: Varianten-Metadaten in Planungs-Treffern.""" +from planning_exercise_suggest import _enrich_planning_hits_with_variant_meta + + +class _Cur: + def __init__(self, rows): + self._rows = rows + self.last_query = '' + + def execute(self, query, params=None): + self.last_query = query + self._params = params + + def fetchall(self): + q = self.last_query + if 'FROM exercise_variants WHERE id IN' in q: + return [ + {'id': 7, 'variant_name': 'Leicht'}, + ] + if 'FROM exercise_variants' in q and 'exercise_id IN' in q: + return [ + { + 'exercise_id': 10, + 'id': 7, + 'variant_name': 'Leicht', + 'sequence_order': 1, + }, + { + 'exercise_id': 10, + 'id': 8, + 'variant_name': 'Schwer', + 'sequence_order': 2, + }, + ] + return [] + + +def test_enrich_planning_hits_with_variant_meta(): + hits = [{'id': 10, 'title': 'Test', 'suggested_variant_id': 7}] + out = _enrich_planning_hits_with_variant_meta(_Cur([]), hits) + assert out[0]['suggested_variant_name'] == 'Leicht' + assert len(out[0]['variants']) == 2 + assert out[0]['variants'][0]['id'] == 7 diff --git a/backend/version.py b/backend/version.py index dfa3832..6736e14 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.183" +APP_VERSION = "0.8.184" BUILD_DATE = "2026-05-23" DB_SCHEMA_VERSION = "20260531074" @@ -29,7 +29,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.11.0", # Phase C1: Graph auto-match + variantenbewusste Nachfolger + "planning_exercise_suggest": "0.12.0", # Phase C2: Varianten in Treffern + Übernahme "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 @@ -44,6 +44,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.184", + "date": "2026-05-23", + "changes": [ + "Planungs-KI Phase C2: Treffer mit Variantenliste + suggested_variant_name; Picker wählt Graph-Variante vor Übernahme.", + "hydrateExercisePlanningRow übernimmt exercise_variant_id / suggested_variant_id in die Planungszeile.", + ], + }, { "version": "0.8.183", "date": "2026-05-23", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 931eb2b..ac77fa4 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover **Stand:** 2026-05-23 -**App-Version / DB-Schema:** App **`0.8.183`** (Planungs-KI Phase C1); DB **`20260531074`** — maßgeblich **`backend/version.py`**. +**App-Version / DB-Schema:** App **`0.8.184`** (Planungs-KI Phase C2); DB **`20260531074`** — 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**. @@ -102,7 +102,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl | **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** | | **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** | | **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** | -| **C2** | Varianten in Trefferliste / Picker-Auswahl | 🔲 | +| **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** | | **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | 🔲 | | **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 | @@ -247,11 +247,10 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl ### Planungs-KI (priorisiert) -1. **C2 — Varianten in Treffern:** Planungs-Picker: bei `suggested_variant_id` Variante vorauswählen; optional Varianten-Ranking bei `deepen`/`progression`. -2. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review. -3. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot / Framework mit Default-Graph verknüpfen. -4. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking). -5. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`. +1. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review. +2. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen. +3. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking). +4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`. ### Allgemein diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 40c0b85..2a244d9 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -25,6 +25,7 @@ import { buildQuickCreateExercisePayloadFromDraft, aiPreviewToQuickCreateDraft, } from '../utils/exerciseAiQuickCreate' +import { resolveExercisePickVariantId } from '../utils/exercisePlanningPick' const PAGE_SIZE = 100 /** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */ @@ -89,6 +90,7 @@ export default function ExercisePickerModal({ const [planningHasSearched, setPlanningHasSearched] = useState(false) const [planningSubmittedQuery, setPlanningSubmittedQuery] = useState('') const [planningSearchTick, setPlanningSearchTick] = useState(0) + const [variantPickByExerciseId, setVariantPickByExerciseId] = useState({}) const pickerScrollRef = useRef(null) const resolvedPlanningUnitId = useMemo(() => { @@ -171,8 +173,38 @@ export default function ExercisePickerModal({ } = useExerciseAiQuickCreateFields(effectivePickerQuery, { enabled: open && enableQuickCreateDraft }) const toggleMultiPick = (ex) => { + setMultiPicked((prev) => { + if (prev.some((p) => p.id === ex.id)) return prev.filter((p) => p.id !== ex.id) + const vid = resolveExercisePickVariantId(ex, variantPickByExerciseId[ex.id]) + return [...prev, { ...ex, exercise_variant_id: vid }] + }) + } + + const buildExercisePickPayload = (ex) => { + const vid = resolveExercisePickVariantId(ex, variantPickByExerciseId[ex.id]) + return { + ...ex, + exercise_variant_id: vid, + suggested_variant_id: vid ?? ex.suggested_variant_id ?? null, + } + } + + const setVariantPickForExercise = (exerciseId, variantId) => { + const eid = Number(exerciseId) + if (!Number.isFinite(eid) || eid < 1) return + setVariantPickByExerciseId((prev) => ({ + ...prev, + [eid]: variantId === '' || variantId == null ? null : Number(variantId), + })) setMultiPicked((prev) => - prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex] + prev.map((p) => + Number(p.id) === eid + ? { + ...p, + exercise_variant_id: resolveExercisePickVariantId(p, variantId === '' ? null : Number(variantId)), + } + : p, + ), ) } @@ -230,6 +262,54 @@ export default function ExercisePickerModal({ /> ) + const renderPlanningVariantPick = (ex) => { + if (!usePlanningSearch || !ex?.id) return null + const variants = Array.isArray(ex.variants) ? ex.variants : [] + const resolved = resolveExercisePickVariantId(ex, variantPickByExerciseId[ex.id]) + if (ex.suggested_variant_name && !variants.length) { + return ( + + Variante: {ex.suggested_variant_name} + + ) + } + if (variants.length === 0) return null + return ( +