Implement Phase C2 Enhancements for Exercise Suggestions
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 18s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
Test Suite / pytest-backend (pull_request) Successful in 38s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m13s

- Incremented version to 0.8.184, reflecting the implementation of Phase C2 features.
- Added support for displaying variant lists and suggested variant names in exercise suggestions.
- Enhanced the ExercisePickerModal to allow selection of exercise variants and improved handling of variant IDs.
- Updated backend logic to enrich planning hits with variant metadata, ensuring accurate exercise variant selection.
- Documented changes in the changelog to highlight the new capabilities in planning AI functionality.
This commit is contained in:
Lars 2026-05-23 11:39:18 +02:00
parent b2157d8a40
commit a34e748be5
10 changed files with 280 additions and 27 deletions

View File

@ -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.

View File

@ -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"),

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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 (
<span className="exercise-tag" style={{ marginTop: 6, display: 'inline-block' }}>
Variante: {ex.suggested_variant_name}
</span>
)
}
if (variants.length === 0) return null
return (
<div
style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<label className="form-label" style={{ margin: 0, fontSize: '11px' }}>
Variante
</label>
<select
className="form-input"
style={{ fontSize: '12px', padding: '4px 8px', maxWidth: '100%', flex: '1 1 160px' }}
value={resolved ?? ''}
onChange={(e) => {
const v = e.target.value
setVariantPickForExercise(ex.id, v === '' ? null : Number(v))
}}
>
<option value=""> Standard </option>
{variants.map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
{ex.suggested_variant_id && Number(ex.suggested_variant_id) === Number(resolved) ? (
<span style={{ fontSize: '11px', color: 'var(--accent-dark)' }}>Progressionsgraph</span>
) : null}
</div>
)
}
useEffect(() => {
if (!open) setVariantPickByExerciseId({})
}, [open])
useEffect(() => {
if (!open) return
let cancelled = false
@ -505,11 +585,18 @@ export default function ExercisePickerModal({
title: h.title,
summary: h.summary,
focus_area: h.focus_area,
variants: Array.isArray(h.variants) ? h.variants : [],
suggested_variant_id: h.suggested_variant_id ?? null,
suggested_variant_name: h.suggested_variant_name ?? null,
_planningScore: h.score,
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
updated_at: new Date().toISOString(),
}))
const initialVariants = {}
for (const h of hits) {
if (h.suggested_variant_id) initialVariants[h.id] = Number(h.suggested_variant_id)
}
setVariantPickByExerciseId(initialVariants)
setList(hits)
setHasMore(false)
} catch (e) {
@ -581,7 +668,8 @@ export default function ExercisePickerModal({
estimateSize: (index) => {
const ex = list[index]
const rc = ex?._planningReasons?.length || 0
return rc > 0 ? 96 + Math.min(rc, 3) * 14 : 88
const hasVariant = usePlanningSearch && Array.isArray(ex?.variants) && ex.variants.length > 0
return rc > 0 || hasVariant ? 96 + Math.min(rc, 3) * 14 + (hasVariant ? 36 : 0) : 88
},
overscan: 8,
getItemKey: (index) => String(list[index]?.id ?? index),
@ -591,10 +679,11 @@ export default function ExercisePickerModal({
const adoptExistingExercise = async (ex) => {
if (!ex?.id) return
const payload = buildExercisePickPayload(ex)
if (multiSelect && typeof onSelectExercises === 'function') {
await Promise.resolve(onSelectExercises([ex]))
await Promise.resolve(onSelectExercises([payload]))
} else if (typeof onSelectExercise === 'function') {
await Promise.resolve(onSelectExercise(ex))
await Promise.resolve(onSelectExercise(payload))
}
onClose()
}
@ -1186,6 +1275,7 @@ export default function ExercisePickerModal({
))}
</ul>
) : null}
{renderPlanningVariantPick(ex)}
</>
)
return (
@ -1232,10 +1322,7 @@ export default function ExercisePickerModal({
) : (
<button
type="button"
onClick={() => {
onSelectExercise(ex)
onClose()
}}
onClick={() => adoptExistingExercise(ex)}
style={{
width: '100%',
textAlign: 'left',
@ -1303,7 +1390,7 @@ export default function ExercisePickerModal({
className="btn btn-primary"
disabled={!multiPicked.length}
onClick={() => {
onSelectExercises([...multiPicked])
onSelectExercises(multiPicked.map((p) => buildExercisePickPayload(p)))
onClose()
}}
>

View File

@ -500,13 +500,17 @@ export default function ExerciseProgressionGraphPanel({
const applyPickedExercise = async (ex) => {
const title = ex.title || `Übung #${ex.id}`
const variants = await loadVariantsForExercise(ex.id)
const variants = Array.isArray(ex.variants) && ex.variants.length
? ex.variants
: await loadVariantsForExercise(ex.id)
const variantId =
ex.exercise_variant_id ?? ex.suggested_variant_id ?? null
if (pickContext?.kind === 'sequence') {
patchSeqStep(pickContext.index, {
exerciseId: ex.id,
exerciseTitle: title,
variantId: null,
variantId: variantId != null ? Number(variantId) : null,
variants,
})
setPickContext(null)
@ -516,7 +520,7 @@ export default function ExerciseProgressionGraphPanel({
const patch = {
exerciseId: ex.id,
exerciseTitle: title,
variantId: null,
variantId: variantId != null ? Number(variantId) : null,
variants,
}
if (pickContext.slot === 'first') setFirstEp(patch)

View File

@ -9,10 +9,12 @@ export function mapPlanningHitsToListRows(hits) {
summary: h.summary,
focus_area: h.focus_area,
focus_area_names: h.focus_area ? [h.focus_area] : [],
variants: Array.isArray(h.variants) ? h.variants : [],
suggested_variant_id: h.suggested_variant_id ?? null,
suggested_variant_name: h.suggested_variant_name ?? null,
updated_at: new Date().toISOString(),
_planningScore: h.score,
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
suggested_variant_id: h.suggested_variant_id ?? null,
}))
}

View File

@ -0,0 +1,18 @@
/** Varianten-Auswahl beim Übernehmen aus Planungs-KI-Treffern. */
export function resolveExercisePickVariantId(exercise, overrideVariantId) {
if (exercise == null) return null
const raw =
overrideVariantId !== undefined && overrideVariantId !== null && overrideVariantId !== ''
? overrideVariantId
: exercise.exercise_variant_id ?? exercise.suggested_variant_id
if (raw == null || raw === '') return null
const vid = Number(raw)
return Number.isFinite(vid) && vid > 0 ? vid : null
}
export function variantLabelForPick(variants, variantId) {
if (variantId == null) return null
const list = Array.isArray(variants) ? variants : []
const row = list.find((v) => Number(v.id) === Number(variantId))
return (row?.variant_name || '').trim() || null
}

View File

@ -325,6 +325,18 @@ export async function hydrateExercisePlanningRow(exercise) {
row.exercise_variant_id = ''
} else {
row.variants = variants
const pickVidRaw =
exercise?.exercise_variant_id ?? exercise?.suggested_variant_id ?? null
const pickVid =
pickVidRaw != null && pickVidRaw !== '' ? Number(pickVidRaw) : NaN
if (Number.isFinite(pickVid) && pickVid > 0) {
const known = variants.some((v) => Number(v.id) === pickVid)
if (known || !variants.length) row.exercise_variant_id = pickVid
}
if (row.exercise_variant_id !== '' && variants.length > 0) {
const ok = variants.some((v) => Number(v.id) === Number(row.exercise_variant_id))
if (!ok) row.exercise_variant_id = ''
}
}
Object.assign(row, meta)
if (row.exercise_kind === 'combination') {