Enhance Gap Planning Context with Stage Overrides and Trainer Supplements
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m25s

- Added `stage_learning_goal_override` and `gap_trainer_supplements` parameters to `build_progression_path_gap_planning_context`, allowing for customized learning goals and additional trainer notes.
- Updated `gapOfferContextDisplayLines` to include trainer supplements in the context display.
- Enhanced `ExerciseProgressionPathBuilder` to utilize new parameters for improved gap fill offer handling.
- Incremented application version to 0.8.214 to reflect these changes.
This commit is contained in:
Lars 2026-06-10 06:54:49 +02:00
parent d4b1780193
commit 0adf20c9e1
6 changed files with 323 additions and 30 deletions

View File

@ -168,6 +168,8 @@ def build_progression_path_gap_planning_context(
resolved_structured: Optional[Mapping[str, Any]] = None,
stage_spec: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[Mapping[str, Any]] = None,
stage_learning_goal_override: Optional[str] = None,
gap_trainer_supplements: Optional[str] = None,
) -> Dict[str, Any]:
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
offer = offer or {}
@ -205,6 +207,11 @@ def build_progression_path_gap_planning_context(
semantic_brief=semantic_brief,
)
ctx.update(snap)
if stage_learning_goal_override and stage_learning_goal_override.strip():
ctx["stage_learning_goal"] = _trim_str(stage_learning_goal_override, limit=1200)
ctx["roadmap_learning_goal"] = ctx["stage_learning_goal"]
if gap_trainer_supplements and gap_trainer_supplements.strip():
ctx["gap_trainer_supplements"] = _trim_str(gap_trainer_supplements, limit=2000)
return sanitize_planning_context_for_ai(ctx)

View File

@ -78,3 +78,14 @@ def test_gap_planning_context_carries_snapshot_fields():
)
assert ctx["start_situation"] == "Start"
assert ctx["stage_learning_goal"] == "Stufenziel"
def test_gap_planning_context_trainer_supplements_and_stage_override():
ctx = build_progression_path_gap_planning_context(
goal_query="Kumite",
stage_spec={"learning_goal": "Original"},
stage_learning_goal_override="Angepasstes Stufenziel",
gap_trainer_supplements="Nur Partnerübung, Kindergruppe",
)
assert ctx["stage_learning_goal"] == "Angepasstes Stufenziel"
assert ctx["gap_trainer_supplements"] == "Nur Partnerübung, Kindergruppe"

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.213"
APP_VERSION = "0.8.214"
BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260607087"

View File

@ -4,6 +4,7 @@
import React, { useCallback, useEffect, useState } from 'react'
import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
aiPreviewToQuickCreateDraft,
@ -13,6 +14,7 @@ import {
import {
buildPathGapPlanningContextForAi,
gapOfferContextDisplayLines,
initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi'
function applyResolvedStructuredFromRoadmap(progressionRoadmap, setters) {
@ -252,6 +254,12 @@ export default function ExerciseProgressionPathBuilder({
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
const [gapPrepOpen, setGapPrepOpen] = useState(false)
const [gapPrepTitle, setGapPrepTitle] = useState('')
const [gapPrepStageGoal, setGapPrepStageGoal] = useState('')
const [gapPrepSupplements, setGapPrepSupplements] = useState('')
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = useState('')
useEffect(() => {
let cancelled = false
@ -391,13 +399,6 @@ export default function ExerciseProgressionPathBuilder({
setQuickAiError('')
}
const handleGapFillClick = async (offer) => {
if (!confirmPathExpansionIfNeeded(offer, pathSteps.length, maxSteps, setMaxSteps)) {
return
}
await runGapFillAiSuggest(offer)
}
const gapContextFallbackParams = {
goalQuery,
semanticBrief,
@ -410,21 +411,70 @@ export default function ExerciseProgressionPathBuilder({
roadmapNotes,
}
const runGapFillAiSuggest = async (offer) => {
const title = (offer?.title_hint || '').trim()
if (title.length < 3) {
alert('Titel-Hinweis fehlt — bitte Pfad erneut vorschlagen.')
const closeGapFillPrep = () => {
if (quickSaving) return
setGapPrepOpen(false)
setGapPrepError('')
}
const openGapFillPrep = (offer) => {
const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas)
setActiveOffer(offer)
setGapPrepTitle((offer?.title_hint || '').trim())
setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextFallbackParams))
setGapPrepSupplements('')
setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '')
setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextFallbackParams))
setGapPrepError('')
setGapPrepOpen(true)
}
const handleGapFillClick = (offer) => {
if (!confirmPathExpansionIfNeeded(offer, pathSteps.length, maxSteps, setMaxSteps)) {
return
}
const goalText = (offer?.goal_for_ai || offer?.sketch || '').trim()
const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas)
openGapFillPrep(offer)
}
const submitGapFillPrep = async () => {
const title = (gapPrepTitle || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const focusId = parseInt(String(gapPrepFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.')
return
}
if (!activeOffer) return
setGapPrepError('')
await runGapFillAiSuggest(activeOffer, {
title,
stageLearningGoal: (gapPrepStageGoal || '').trim(),
supplements: (gapPrepSupplements || '').trim(),
focusAreaId: focusId,
})
}
const runGapFillAiSuggest = async (offer, prep = null) => {
const title = (prep?.title || offer?.title_hint || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const supplements = (prep?.supplements || '').trim()
const stageGoal = (prep?.stageLearningGoal || '').trim()
let goalText = (offer?.goal_for_ai || offer?.sketch || '').trim()
if (supplements) {
goalText = `${goalText}\n\nTrainer-Ergänzungen für diese Übung:\n${supplements}`.trim()
}
const focusId =
prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId))
? Number(prep.focusAreaId)
: resolveDefaultFocusAreaId(targetSummary, focusAreas)
if (!focusId) {
alert('Kein Fokusbereich verfügbar — bitte Kataloge laden oder manuell wählen.')
setQuickTitle(title)
setQuickSketch(goalText)
setQuickFocusAreaId('')
setActiveOffer(offer)
setQuickCreateOpen(true)
alert('Kein Fokusbereich verfügbar — bitte im Vorbereitungsdialog wählen.')
return
}
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
@ -438,11 +488,16 @@ export default function ExerciseProgressionPathBuilder({
setQuickCreateDraft(null)
setQuickSaving(true)
setGeneratingOfferId(offer?.offer_id || null)
const contextLines = gapOfferContextDisplayLines(offer, gapContextFallbackParams)
const contextParams = {
...gapContextFallbackParams,
stageLearningGoalOverride: stageGoal,
gapTrainerSupplements: supplements,
}
const contextLines = gapOfferContextDisplayLines(offer, contextParams)
setActivePlanningContextLines(contextLines)
const planningContext = buildPathGapPlanningContextForAi({
offer,
...gapContextFallbackParams,
...contextParams,
})
try {
const aiRes = await api.suggestExerciseAi({
@ -450,7 +505,7 @@ export default function ExerciseProgressionPathBuilder({
goal: goalText || undefined,
execution: '',
preparation: '',
trainer_notes: '',
trainer_notes: supplements || '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContext || undefined,
@ -469,12 +524,13 @@ export default function ExerciseProgressionPathBuilder({
sketchPlain: goalText,
}),
)
setGapPrepOpen(false)
setQuickCreateOpen(false)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setGapPrepError(msg)
setQuickAiError(msg)
setQuickCreateOpen(true)
} finally {
setQuickSaving(false)
setGeneratingOfferId(null)
@ -1205,7 +1261,7 @@ export default function ExerciseProgressionPathBuilder({
Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad).
{pathSteps.length >= maxSteps
? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.'
: ' „Mit KI anlegen“ erzeugt einen vollständigen Entwurf und fügt die Übung ein.'}
: ' Zuerst Kontext prüfen und ergänzen, dann KI-Entwurf erstellen und einfügen.'}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{gapFillOffers.map((offer) => (
@ -1255,12 +1311,12 @@ export default function ExerciseProgressionPathBuilder({
: 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll'
: offer.replace_step_index != null
? 'Ersetzt den themenfremden Schritt im Pfad'
: 'KI-Entwurf mit Pfad-Kontext generieren'
: 'Kontext prüfen, Ergänzungen mitgeben, dann KI-Entwurf'
}
>
{generatingOfferId === offer.offer_id
? 'KI erstellt Entwurf …'
: 'Mit KI anlegen'}
: 'Vorbereiten & KI anlegen'}
</button>
</div>
</div>
@ -1406,6 +1462,25 @@ export default function ExerciseProgressionPathBuilder({
</>
) : null}
<ExerciseGapFillPrepModal
open={gapPrepOpen}
onClose={closeGapFillPrep}
offer={activeOffer}
contextLines={activePlanningContextLines}
title={gapPrepTitle}
onTitleChange={setGapPrepTitle}
stageLearningGoal={gapPrepStageGoal}
onStageLearningGoalChange={setGapPrepStageGoal}
supplements={gapPrepSupplements}
onSupplementsChange={setGapPrepSupplements}
focusAreaId={gapPrepFocusAreaId}
onFocusAreaChange={setGapPrepFocusAreaId}
focusAreas={focusAreas}
busy={quickSaving}
error={gapPrepError}
onSubmit={submitGapFillPrep}
/>
<ExerciseAiQuickCreateModal
open={quickCreateOpen}
onClose={closeQuickCreate}
@ -1428,8 +1503,11 @@ export default function ExerciseProgressionPathBuilder({
onDraftChange={setQuickCreateDraft}
onDiscard={() => {
setQuickCreateDraft(null)
if (activeOffer) {
setGapPrepOpen(true)
} else {
setActivePlanningContextLines([])
if (activeOffer) setQuickCreateOpen(true)
}
}}
planningContextLines={activePlanningContextLines}
onApply={applyQuickCreateDraft}

View File

@ -0,0 +1,186 @@
import React, { useEffect } from 'react'
/**
* Vorbereitung vor KI-Übungsanlage aus Pfad-Lücke: Kontext prüfen, Ergänzungen mitgeben.
*/
export default function ExerciseGapFillPrepModal({
open,
onClose,
offer = null,
contextLines = [],
title = '',
onTitleChange,
stageLearningGoal = '',
onStageLearningGoalChange,
supplements = '',
onSupplementsChange,
focusAreaId = '',
onFocusAreaChange,
focusAreas = [],
busy = false,
error = '',
onSubmit,
}) {
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key === 'Escape' && !busy) {
e.preventDefault()
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, busy, onClose])
if (!open || !offer) return null
const readOnlyLines = (contextLines || []).filter(
(line) => line.label !== 'Stufen-Lernziel',
)
return (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget && !busy) onClose()
}}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="gap-fill-prep-title"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '680px' }}
>
<div className="admin-modal-sheet__header">
<h3 id="gap-fill-prep-title" className="admin-modal-sheet__title">
Übung mit KI vorbereiten
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
disabled={busy}
onClick={onClose}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body">
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', lineHeight: 1.45 }}>
Prüfen und anpassen, was an die KI geht. Ergänzungen fließen in Ziel, Trainerhinweise und
Planungskontext ein erst danach wird der Entwurf erzeugt.
</p>
{readOnlyLines.length > 0 ? (
<div
style={{
marginBottom: '14px',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong style={{ display: 'block', marginBottom: '6px' }}>Pfad-Kontext (aus Roadmap)</strong>
<dl style={{ margin: 0, display: 'grid', gap: '6px' }}>
{readOnlyLines.map(({ label, value }) => (
<div key={label}>
<dt style={{ margin: 0, fontSize: '11px', color: 'var(--text3)' }}>{label}</dt>
<dd style={{ margin: '2px 0 0', lineHeight: 1.45, color: 'var(--text2)' }}>{value}</dd>
</div>
))}
</dl>
</div>
) : null}
<div style={{ display: 'grid', gap: '12px' }}>
<div>
<label className="form-label" htmlFor="gap-prep-title">
Titel-Vorschlag *
</label>
<input
id="gap-prep-title"
className="form-input"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
disabled={busy}
maxLength={280}
/>
</div>
<div>
<label className="form-label" htmlFor="gap-prep-stage-goal">
Stufen-Lernziel (anpassbar)
</label>
<textarea
id="gap-prep-stage-goal"
className="form-input"
rows={2}
value={stageLearningGoal}
onChange={(e) => onStageLearningGoalChange(e.target.value)}
disabled={busy}
placeholder="Was soll diese Übung in der Roadmap-Stufe leisten?"
/>
</div>
<div>
<label className="form-label" htmlFor="gap-prep-supplements">
Ergänzungen / Anpassungen für die KI
</label>
<textarea
id="gap-prep-supplements"
className="form-input"
rows={3}
value={supplements}
onChange={(e) => onSupplementsChange(e.target.value)}
disabled={busy}
placeholder="z. B. nur Partnerübung, 1012 Jahre, Fokus auf Reaktion unter Druck, keine Sprünge …"
/>
</div>
<div>
<label className="form-label" htmlFor="gap-prep-focus">
Fokusbereich *
</label>
<select
id="gap-prep-focus"
className="form-input"
value={focusAreaId}
onChange={(e) => onFocusAreaChange(e.target.value)}
disabled={busy}
>
<option value="">Bitte wählen </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={fa.id}>
{fa.name}
</option>
))}
</select>
</div>
</div>
{offer.from_title && offer.to_title ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '12px 0 0' }}>
Einordnung: zwischen {offer.from_title} und {offer.to_title}
</p>
) : null}
{error ? (
<p className="form-error" style={{ marginTop: '12px' }}>
{error}
</p>
) : null}
</div>
<div className="admin-modal-sheet__footer" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={onClose}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={onSubmit}>
{busy ? 'KI erstellt Entwurf …' : 'KI-Entwurf erstellen'}
</button>
</div>
</div>
</div>
)
}

View File

@ -53,6 +53,8 @@ export function buildPathGapPlanningContextForAi({
startSituation = '',
targetState = '',
roadmapNotes = '',
stageLearningGoalOverride = '',
gapTrainerSupplements = '',
} = {}) {
const afterIdx = Number(offer?.insert_after_index)
const stepA = Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx] : null
@ -105,7 +107,9 @@ export function buildPathGapPlanningContextForAi({
start_situation: start,
target_state: target,
roadmap_notes: notes,
stage_learning_goal: stageSpec?.learning_goal || null,
stage_learning_goal:
(stageLearningGoalOverride || '').trim() || stageSpec?.learning_goal || null,
gap_trainer_supplements: (gapTrainerSupplements || '').trim() || null,
stage_phase: stageSpec?.phase || majorStep?.phase || null,
stage_exercise_type: stageSpec?.exercise_type || null,
stage_load_profile: Array.isArray(stageSpec?.load_profile)
@ -158,5 +162,12 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
if (Array.isArray(raw.skill_hints) && raw.skill_hints.length) {
push('Fähigkeiten-Fokus', raw.skill_hints.slice(0, 3).join(' · '))
}
push('Trainer-Ergänzungen', raw.gap_trainer_supplements)
return lines
}
export function initialStageLearningGoalFromOffer(offer, fallbackParams = null) {
const lines = gapOfferContextDisplayLines(offer, fallbackParams)
const hit = lines.find((l) => l.label === 'Stufen-Lernziel')
return hit?.value || (offer?.title_hint || '').trim()
}