Progression optimiert Phase A #55

Merged
Lars merged 33 commits from develop into main 2026-06-11 21:26:54 +02:00
6 changed files with 323 additions and 30 deletions
Showing only changes of commit 0adf20c9e1 - Show all commits

View File

@ -168,6 +168,8 @@ def build_progression_path_gap_planning_context(
resolved_structured: Optional[Mapping[str, Any]] = None, resolved_structured: Optional[Mapping[str, Any]] = None,
stage_spec: Optional[Mapping[str, Any]] = None, stage_spec: Optional[Mapping[str, Any]] = None,
semantic_brief: 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]: ) -> Dict[str, Any]:
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke.""" """Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
offer = offer or {} offer = offer or {}
@ -205,6 +207,11 @@ def build_progression_path_gap_planning_context(
semantic_brief=semantic_brief, semantic_brief=semantic_brief,
) )
ctx.update(snap) 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) 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["start_situation"] == "Start"
assert ctx["stage_learning_goal"] == "Stufenziel" 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 # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.213" APP_VERSION = "0.8.214"
BUILD_DATE = "2026-06-07" BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260607087" DB_SCHEMA_VERSION = "20260607087"

View File

@ -4,6 +4,7 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import api from '../utils/api' import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal' import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import { import {
aiPreviewToQuickCreateDraft, aiPreviewToQuickCreateDraft,
@ -13,6 +14,7 @@ import {
import { import {
buildPathGapPlanningContextForAi, buildPathGapPlanningContextForAi,
gapOfferContextDisplayLines, gapOfferContextDisplayLines,
initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi' } from '../utils/planningContextForExerciseAi'
function applyResolvedStructuredFromRoadmap(progressionRoadmap, setters) { function applyResolvedStructuredFromRoadmap(progressionRoadmap, setters) {
@ -252,6 +254,12 @@ export default function ExerciseProgressionPathBuilder({
const [quickSaving, setQuickSaving] = useState(false) const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('') const [quickAiError, setQuickAiError] = useState('')
const [activePlanningContextLines, setActivePlanningContextLines] = 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -391,13 +399,6 @@ export default function ExerciseProgressionPathBuilder({
setQuickAiError('') setQuickAiError('')
} }
const handleGapFillClick = async (offer) => {
if (!confirmPathExpansionIfNeeded(offer, pathSteps.length, maxSteps, setMaxSteps)) {
return
}
await runGapFillAiSuggest(offer)
}
const gapContextFallbackParams = { const gapContextFallbackParams = {
goalQuery, goalQuery,
semanticBrief, semanticBrief,
@ -410,21 +411,70 @@ export default function ExerciseProgressionPathBuilder({
roadmapNotes, roadmapNotes,
} }
const runGapFillAiSuggest = async (offer) => { const closeGapFillPrep = () => {
const title = (offer?.title_hint || '').trim() if (quickSaving) return
if (title.length < 3) { setGapPrepOpen(false)
alert('Titel-Hinweis fehlt — bitte Pfad erneut vorschlagen.') 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 return
} }
const goalText = (offer?.goal_for_ai || offer?.sketch || '').trim() openGapFillPrep(offer)
const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas) }
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) { if (!focusId) {
alert('Kein Fokusbereich verfügbar — bitte Kataloge laden oder manuell wählen.') alert('Kein Fokusbereich verfügbar — bitte im Vorbereitungsdialog wählen.')
setQuickTitle(title)
setQuickSketch(goalText)
setQuickFocusAreaId('')
setActiveOffer(offer)
setQuickCreateOpen(true)
return return
} }
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId) const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
@ -438,11 +488,16 @@ export default function ExerciseProgressionPathBuilder({
setQuickCreateDraft(null) setQuickCreateDraft(null)
setQuickSaving(true) setQuickSaving(true)
setGeneratingOfferId(offer?.offer_id || null) setGeneratingOfferId(offer?.offer_id || null)
const contextLines = gapOfferContextDisplayLines(offer, gapContextFallbackParams) const contextParams = {
...gapContextFallbackParams,
stageLearningGoalOverride: stageGoal,
gapTrainerSupplements: supplements,
}
const contextLines = gapOfferContextDisplayLines(offer, contextParams)
setActivePlanningContextLines(contextLines) setActivePlanningContextLines(contextLines)
const planningContext = buildPathGapPlanningContextForAi({ const planningContext = buildPathGapPlanningContextForAi({
offer, offer,
...gapContextFallbackParams, ...contextParams,
}) })
try { try {
const aiRes = await api.suggestExerciseAi({ const aiRes = await api.suggestExerciseAi({
@ -450,7 +505,7 @@ export default function ExerciseProgressionPathBuilder({
goal: goalText || undefined, goal: goalText || undefined,
execution: '', execution: '',
preparation: '', preparation: '',
trainer_notes: '', trainer_notes: supplements || '',
focus_area_hint: focusHint || undefined, focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }], focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContext || undefined, planning_context: planningContext || undefined,
@ -469,12 +524,13 @@ export default function ExerciseProgressionPathBuilder({
sketchPlain: goalText, sketchPlain: goalText,
}), }),
) )
setGapPrepOpen(false)
setQuickCreateOpen(false) setQuickCreateOpen(false)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
const msg = e?.message || String(e) const msg = e?.message || String(e)
setGapPrepError(msg)
setQuickAiError(msg) setQuickAiError(msg)
setQuickCreateOpen(true)
} finally { } finally {
setQuickSaving(false) setQuickSaving(false)
setGeneratingOfferId(null) setGeneratingOfferId(null)
@ -1205,7 +1261,7 @@ export default function ExerciseProgressionPathBuilder({
Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad). Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad).
{pathSteps.length >= maxSteps {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.' ? ' 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> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{gapFillOffers.map((offer) => ( {gapFillOffers.map((offer) => (
@ -1255,12 +1311,12 @@ export default function ExerciseProgressionPathBuilder({
: 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll' : 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll'
: offer.replace_step_index != null : offer.replace_step_index != null
? 'Ersetzt den themenfremden Schritt im Pfad' ? '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 {generatingOfferId === offer.offer_id
? 'KI erstellt Entwurf …' ? 'KI erstellt Entwurf …'
: 'Mit KI anlegen'} : 'Vorbereiten & KI anlegen'}
</button> </button>
</div> </div>
</div> </div>
@ -1406,6 +1462,25 @@ export default function ExerciseProgressionPathBuilder({
</> </>
) : null} ) : 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 <ExerciseAiQuickCreateModal
open={quickCreateOpen} open={quickCreateOpen}
onClose={closeQuickCreate} onClose={closeQuickCreate}
@ -1428,8 +1503,11 @@ export default function ExerciseProgressionPathBuilder({
onDraftChange={setQuickCreateDraft} onDraftChange={setQuickCreateDraft}
onDiscard={() => { onDiscard={() => {
setQuickCreateDraft(null) setQuickCreateDraft(null)
if (activeOffer) {
setGapPrepOpen(true)
} else {
setActivePlanningContextLines([]) setActivePlanningContextLines([])
if (activeOffer) setQuickCreateOpen(true) }
}} }}
planningContextLines={activePlanningContextLines} planningContextLines={activePlanningContextLines}
onApply={applyQuickCreateDraft} 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 = '', startSituation = '',
targetState = '', targetState = '',
roadmapNotes = '', roadmapNotes = '',
stageLearningGoalOverride = '',
gapTrainerSupplements = '',
} = {}) { } = {}) {
const afterIdx = Number(offer?.insert_after_index) const afterIdx = Number(offer?.insert_after_index)
const stepA = Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx] : null const stepA = Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx] : null
@ -105,7 +107,9 @@ export function buildPathGapPlanningContextForAi({
start_situation: start, start_situation: start,
target_state: target, target_state: target,
roadmap_notes: notes, 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_phase: stageSpec?.phase || majorStep?.phase || null,
stage_exercise_type: stageSpec?.exercise_type || null, stage_exercise_type: stageSpec?.exercise_type || null,
stage_load_profile: Array.isArray(stageSpec?.load_profile) 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) { if (Array.isArray(raw.skill_hints) && raw.skill_hints.length) {
push('Fähigkeiten-Fokus', raw.skill_hints.slice(0, 3).join(' · ')) push('Fähigkeiten-Fokus', raw.skill_hints.slice(0, 3).join(' · '))
} }
push('Trainer-Ergänzungen', raw.gap_trainer_supplements)
return lines 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()
}