All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m18s
- Added `tzdata` installation in the Dockerfile to support time zone handling in Linux environments. - Increased `PIP_DEFAULT_TIMEOUT` and added retry logic for pip installations to enhance reliability during dependency installation. - Updated `requirements.txt` to conditionally include `tzdata` for Windows platforms, ensuring compatibility across different operating systems.
351 lines
14 KiB
JavaScript
351 lines
14 KiB
JavaScript
/**
|
|
* Planungs-KI Phase D: strukturierter Kontext für suggestExerciseAi.
|
|
*/
|
|
|
|
import { slotsAsPathStepRows } from './progressionGraphDraft.js'
|
|
|
|
export function buildPickerPlanningContextForAi({
|
|
planningContextSummary = null,
|
|
planningContext = null,
|
|
searchQuery = '',
|
|
} = {}) {
|
|
if (!planningContextSummary && !planningContext) return null
|
|
const ctx = {
|
|
source: 'planning_picker',
|
|
search_query: (searchQuery || '').trim() || null,
|
|
unit_title: planningContextSummary?.unit_title || null,
|
|
group_name: planningContextSummary?.group_name || null,
|
|
section_title:
|
|
planningContextSummary?.section_title || planningContext?.sectionTitle || null,
|
|
section_guidance_notes: planningContext?.sectionGuidanceNotes || null,
|
|
section_exercise_count: planningContextSummary?.section_exercise_count ?? null,
|
|
last_section_exercise_title:
|
|
planningContextSummary?.last_section_exercise_title ||
|
|
planningContext?.lastExerciseTitle ||
|
|
null,
|
|
anchor_title: planningContextSummary?.anchor_title || null,
|
|
progression_graph_name: planningContextSummary?.progression_graph_name || null,
|
|
planned_count: planningContextSummary?.planned_count ?? null,
|
|
intent_resolved:
|
|
planningContextSummary?.intent_resolved || planningContext?.intentHint || null,
|
|
}
|
|
return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null && v !== ''))
|
|
}
|
|
|
|
function majorIndexFromStep(step) {
|
|
const raw = step?.roadmap_major_step_index ?? step?.roadmapMajorStepIndex
|
|
if (raw == null || !Number.isFinite(Number(raw))) return null
|
|
return Number(raw)
|
|
}
|
|
|
|
function priorPathStepsBeforeMajor(pathSteps, majorIdx) {
|
|
if (majorIdx == null || !Number.isFinite(Number(majorIdx))) return []
|
|
const mi = Number(majorIdx)
|
|
return (pathSteps || [])
|
|
.filter((s) => {
|
|
const idx = majorIndexFromStep(s)
|
|
return idx != null && idx < mi
|
|
})
|
|
.sort((a, b) => (majorIndexFromStep(a) || 0) - (majorIndexFromStep(b) || 0))
|
|
}
|
|
|
|
function stepDisplayFields(step) {
|
|
if (!step) return null
|
|
const title = String(step.title || step.exerciseTitle || '').trim()
|
|
const learningGoal = String(
|
|
step.roadmap_learning_goal || step.roadmapLearningGoal || step.learning_goal || '',
|
|
).trim()
|
|
const phase = String(step.roadmap_phase || step.roadmapPhase || step.phase || '').trim()
|
|
const startState = String(step.roadmap_start_state || step.start_state || '').trim()
|
|
const targetState = String(step.roadmap_target_state || step.target_state || '').trim()
|
|
const criteria = Array.isArray(step.success_criteria)
|
|
? step.success_criteria.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 4)
|
|
: []
|
|
const majorStepIndex = majorIndexFromStep(step)
|
|
const out = {
|
|
title: title || null,
|
|
learning_goal: learningGoal || null,
|
|
start_state: startState || null,
|
|
target_state: targetState || null,
|
|
phase: phase || null,
|
|
success_criteria: criteria.length ? criteria : null,
|
|
major_step_index: majorStepIndex,
|
|
}
|
|
const hasData = Object.values(out).some((v) => v != null && v !== '')
|
|
return hasData ? out : null
|
|
}
|
|
|
|
export function buildProgressionEntryState({
|
|
majorStepIndex = null,
|
|
priorSteps = [],
|
|
startSituation = '',
|
|
currentStageStart = '',
|
|
} = {}) {
|
|
const priorCompact = (priorSteps || [])
|
|
.map(stepDisplayFields)
|
|
.filter(Boolean)
|
|
|
|
const achievements = []
|
|
const detailLines = []
|
|
for (const p of priorCompact) {
|
|
if (Array.isArray(p.success_criteria) && p.success_criteria.length) {
|
|
achievements.push(...p.success_criteria)
|
|
} else if (p.learning_goal) {
|
|
achievements.push(p.learning_goal)
|
|
}
|
|
|
|
const labelParts = []
|
|
if (p.major_step_index != null) labelParts.push(`Stufe ${p.major_step_index + 1}`)
|
|
if (p.phase) labelParts.push(`(${p.phase})`)
|
|
if (p.title) labelParts.push(`„${p.title}"`)
|
|
const prefix = labelParts.length ? labelParts.join(' ') : 'Vorstufe'
|
|
const achieved =
|
|
p.target_state ||
|
|
(Array.isArray(p.success_criteria) && p.success_criteria.length
|
|
? p.success_criteria.join('; ')
|
|
: '') ||
|
|
p.learning_goal ||
|
|
''
|
|
if (achieved) detailLines.push(`${prefix}: erreicht — ${achieved}`)
|
|
}
|
|
|
|
let entryState = (currentStageStart || '').trim()
|
|
if (!entryState && priorCompact.length) {
|
|
const immediate = priorCompact[priorCompact.length - 1]
|
|
entryState =
|
|
immediate.target_state ||
|
|
(Array.isArray(immediate.success_criteria) && immediate.success_criteria.length
|
|
? immediate.success_criteria.join('; ')
|
|
: '') ||
|
|
immediate.learning_goal ||
|
|
''
|
|
} else if (!entryState && (startSituation || '').trim()) {
|
|
entryState = startSituation.trim()
|
|
}
|
|
|
|
if (priorCompact.length && (startSituation || '').trim() && !entryState) {
|
|
detailLines.unshift(`Ausgangsbasis Pfad: ${startSituation.trim()}`)
|
|
}
|
|
|
|
const out = {}
|
|
if (entryState) out.entry_state = entryState
|
|
if (detailLines.length) out.entry_state_detail = detailLines.join('\n')
|
|
if (priorCompact.length) out.prior_steps = priorCompact.slice(0, 6)
|
|
if (achievements.length) out.prior_achievements = [...new Set(achievements)].slice(0, 8)
|
|
return out
|
|
}
|
|
|
|
function stageSpecForMajorIndex(progressionRoadmap, majorIdx) {
|
|
if (majorIdx == null || !progressionRoadmap) return null
|
|
const specs = progressionRoadmap?.stage_specs
|
|
if (!Array.isArray(specs)) return null
|
|
const hit = specs.find((s) => Number(s.major_step_index) === Number(majorIdx))
|
|
if (!hit) return null
|
|
const majors = progressionRoadmap?.roadmap?.major_steps
|
|
const major =
|
|
Array.isArray(majors) && majors.find((m) => Number(m.index) === Number(majorIdx))
|
|
return major ? { ...hit, phase: hit.phase || major.phase } : hit
|
|
}
|
|
|
|
export function buildPathGapPlanningContextForAi({
|
|
goalQuery = '',
|
|
semanticBrief = null,
|
|
offer = null,
|
|
graphId = null,
|
|
pathSteps = [],
|
|
editableMajorSteps = [],
|
|
progressionRoadmap = null,
|
|
startSituation = '',
|
|
targetState = '',
|
|
roadmapNotes = '',
|
|
stageLearningGoalOverride = '',
|
|
gapTrainerSupplements = '',
|
|
} = {}) {
|
|
const majorIdxRaw =
|
|
offer?.roadmap_major_step_index ?? offer?.gap?.roadmap_major_step_index
|
|
const majorIdx =
|
|
majorIdxRaw != null && Number.isFinite(Number(majorIdxRaw)) ? Number(majorIdxRaw) : null
|
|
const priorSteps = majorIdx != null ? priorPathStepsBeforeMajor(pathSteps, majorIdx) : []
|
|
const afterIdx = Number(offer?.insert_after_index)
|
|
const stepA =
|
|
priorSteps.length > 0
|
|
? priorSteps[priorSteps.length - 1]
|
|
: Number.isFinite(afterIdx) && afterIdx >= 0
|
|
? pathSteps[afterIdx]
|
|
: null
|
|
const stepB =
|
|
majorIdx != null
|
|
? (pathSteps || []).find((s) => majorIndexFromStep(s) === majorIdx + 1) ||
|
|
(Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx + 1] : null)
|
|
: Number.isFinite(afterIdx) && afterIdx >= 0
|
|
? pathSteps[afterIdx + 1]
|
|
: null
|
|
const majorStep =
|
|
majorIdx != null && editableMajorSteps[majorIdx] ? editableMajorSteps[majorIdx] : null
|
|
const stageSpec = stageSpecForMajorIndex(progressionRoadmap, majorIdx)
|
|
const ga = progressionRoadmap?.goal_analysis || null
|
|
const rs = progressionRoadmap?.resolved_structured || null
|
|
|
|
const start =
|
|
(startSituation || '').trim() ||
|
|
rs?.start_situation ||
|
|
ga?.start_assumption ||
|
|
null
|
|
const target =
|
|
(targetState || '').trim() || rs?.target_state || ga?.target_state || null
|
|
const notes = (roadmapNotes || '').trim() || rs?.roadmap_notes || null
|
|
|
|
const skillHints = []
|
|
if (Array.isArray(semanticBrief?.must_phrases)) {
|
|
semanticBrief.must_phrases.slice(0, 4).forEach((p) => {
|
|
const s = String(p || '').trim()
|
|
if (s) skillHints.push(s)
|
|
})
|
|
}
|
|
if (Array.isArray(semanticBrief?.development_arc) && semanticBrief.development_arc.length) {
|
|
skillHints.push(
|
|
`Entwicklungsbogen: ${semanticBrief.development_arc.slice(0, 5).join(' → ')}`,
|
|
)
|
|
}
|
|
|
|
const entryState = buildProgressionEntryState({
|
|
majorStepIndex: majorIdx,
|
|
priorSteps,
|
|
startSituation: start,
|
|
currentStageStart: stageSpec?.start_state || '',
|
|
})
|
|
|
|
const ctx = {
|
|
source: 'progression_path_gap_fill',
|
|
goal_query: (goalQuery || '').trim() || null,
|
|
primary_topic: ga?.primary_topic || semanticBrief?.primary_topic || null,
|
|
progression_graph_id: graphId != null ? Number(graphId) : null,
|
|
gap_source: offer?.source || null,
|
|
gap_phase: offer?.phase || offer?.gap?.expected_phase || null,
|
|
roadmap_major_step_index: majorIdx,
|
|
roadmap_phase: majorStep?.phase || stageSpec?.phase || offer?.phase || null,
|
|
roadmap_learning_goal:
|
|
(majorStep?.learning_goal || offer?.title_hint || offer?.gap?.learning_goal || '').trim() ||
|
|
null,
|
|
start_situation: start,
|
|
target_state: target,
|
|
roadmap_notes: notes,
|
|
stage_learning_goal:
|
|
(stageLearningGoalOverride || '').trim() || stageSpec?.learning_goal || null,
|
|
stage_start_state: stageSpec?.start_state || null,
|
|
stage_target_state: stageSpec?.target_state || 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)
|
|
? stageSpec.load_profile.slice(0, 6)
|
|
: null,
|
|
stage_success_criteria: Array.isArray(stageSpec?.success_criteria)
|
|
? stageSpec.success_criteria.slice(0, 4)
|
|
: null,
|
|
stage_anti_patterns: Array.isArray(stageSpec?.anti_patterns)
|
|
? stageSpec.anti_patterns.slice(0, 3)
|
|
: null,
|
|
path_success_criteria: Array.isArray(ga?.success_criteria)
|
|
? ga.success_criteria.slice(0, 4)
|
|
: null,
|
|
skill_hints: skillHints.length ? skillHints : null,
|
|
neighbor_before_title: stepA?.exerciseTitle || stepA?.title || offer?.from_title || null,
|
|
neighbor_after_title: stepB?.exerciseTitle || stepB?.title || offer?.to_title || null,
|
|
...entryState,
|
|
path_step_count: Array.isArray(pathSteps) ? pathSteps.length : 0,
|
|
major_step_count:
|
|
editableMajorSteps?.length ||
|
|
progressionRoadmap?.major_step_count ||
|
|
progressionRoadmap?.roadmap?.major_steps?.length ||
|
|
null,
|
|
}
|
|
return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null && v !== ''))
|
|
}
|
|
|
|
/** Lesbare Zeilen für UI — aus API context_preview oder lokal berechnet. */
|
|
export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
|
|
const raw =
|
|
offer?.context_preview ||
|
|
(fallbackParams ? buildPathGapPlanningContextForAi({ offer, ...fallbackParams }) : null)
|
|
if (!raw) return []
|
|
const lines = []
|
|
const push = (label, value) => {
|
|
const v = String(value || '').trim()
|
|
if (v) lines.push({ label, value: v })
|
|
}
|
|
push('Eingangszustand (Vorstufen)', raw.entry_state)
|
|
if (raw.entry_state_detail && raw.entry_state_detail !== raw.entry_state) {
|
|
push('Bisheriger Pfad', raw.entry_state_detail)
|
|
}
|
|
if (Array.isArray(raw.prior_achievements) && raw.prior_achievements.length) {
|
|
push('Erreichte Voraussetzungen', raw.prior_achievements.slice(0, 6).join(' · '))
|
|
}
|
|
push('Ausgangslage (gesamter Pfad)', raw.start_situation)
|
|
push('Gesamtziel (Pfad)', raw.target_state)
|
|
push('Ergänzungen', raw.roadmap_notes)
|
|
push('Stufen-Lernziel', raw.stage_learning_goal || raw.roadmap_learning_goal)
|
|
if (raw.stage_phase) push('Phase', raw.stage_phase)
|
|
if (Array.isArray(raw.stage_load_profile) && raw.stage_load_profile.length) {
|
|
push('Belastung', raw.stage_load_profile.join(', '))
|
|
}
|
|
if (Array.isArray(raw.stage_success_criteria) && raw.stage_success_criteria.length) {
|
|
push('Erfolgskriterien', raw.stage_success_criteria.slice(0, 3).join(' · '))
|
|
}
|
|
if (Array.isArray(raw.skill_hints) && raw.skill_hints.length) {
|
|
push('Fähigkeiten-Fokus', raw.skill_hints.slice(0, 3).join(' · '))
|
|
}
|
|
if (Array.isArray(raw.expected_skills) && raw.expected_skills.length) {
|
|
const names = raw.expected_skills
|
|
.map((s) => String(s?.skill_name || '').trim())
|
|
.filter(Boolean)
|
|
.slice(0, 5)
|
|
if (names.length) push('Erwartete Fähigkeiten', names.join(' · '))
|
|
}
|
|
push('Trainer-Ergänzungen', raw.gap_trainer_supplements)
|
|
return lines
|
|
}
|
|
|
|
/** Zieltext für KI aus Slot-Kontext (Graph-Editor ohne API-Offer). */
|
|
export function buildSlotGapGoalForAi(draft, slotIndex, { goalQuery = '' } = {}) {
|
|
const slot = draft?.slots?.[slotIndex]
|
|
if (!slot) return ''
|
|
const pathSteps = slotsAsPathStepRows(draft)
|
|
const majorIdx = slot.majorStepIndex
|
|
const priorSteps = priorPathStepsBeforeMajor(pathSteps, majorIdx)
|
|
const start = (draft.startSituation || '').trim()
|
|
const stageSpec =
|
|
majorIdx != null && draft.progressionRoadmap
|
|
? stageSpecForMajorIndex(draft.progressionRoadmap, majorIdx)
|
|
: null
|
|
const entry = buildProgressionEntryState({
|
|
majorStepIndex: majorIdx,
|
|
priorSteps,
|
|
startSituation: start,
|
|
currentStageStart: stageSpec?.start_state || '',
|
|
})
|
|
const parts = [
|
|
goalQuery ? `Planungsziel (gesamter Pfad): ${goalQuery}` : '',
|
|
entry.entry_state
|
|
? `Eingangszustand (erreichte Voraussetzungen): ${entry.entry_state}`
|
|
: start
|
|
? `Ausgangslage (Pfad): ${start}`
|
|
: '',
|
|
entry.entry_state_detail && entry.entry_state_detail !== entry.entry_state
|
|
? `Bisheriger Pfad:\n${entry.entry_state_detail}`
|
|
: '',
|
|
(slot.learning_goal || '').trim()
|
|
? `Lernziel dieser Roadmap-Stufe: ${(slot.learning_goal || '').trim()}`
|
|
: '',
|
|
(slot.phase || '').trim() ? `Entwicklungsphase: ${slot.phase}` : '',
|
|
'Die Übung baut didaktisch auf den Vorstufen auf — Voraussetzungen explizit benennen, messbares Stufenziel.',
|
|
].filter(Boolean)
|
|
return parts.join('\n\n').trim()
|
|
}
|
|
|
|
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()
|
|
}
|