Update Dockerfile and requirements for improved dependency management
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.
This commit is contained in:
Lars 2026-06-11 11:48:25 +02:00
parent 8f1dad53ab
commit 480890d0c6
10 changed files with 492 additions and 18 deletions

View File

@ -2,14 +2,16 @@ FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
# Install system dependencies (tzdata für zoneinfo/ZoneInfo unter Linux)
RUN apt-get update && apt-get install -y \
postgresql-client \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ENV PIP_DEFAULT_TIMEOUT=120
RUN pip install --no-cache-dir --retries 5 -r requirements.txt
# Copy application code
COPY . .

View File

@ -6,7 +6,7 @@ Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instruct
from __future__ import annotations
import json
from typing import Any, Dict, List, Mapping, Optional
from typing import Any, Dict, List, Mapping, Optional, Sequence
_MAX_JSON_CHARS = 6000
_MAX_STRING = 800
@ -85,6 +85,163 @@ def planning_context_prompt_variables(
}
def _major_index_from_step(step: Mapping[str, Any]) -> Optional[int]:
for key in ("roadmap_major_step_index", "major_step_index"):
raw = step.get(key)
if raw is None:
continue
try:
return int(raw)
except (TypeError, ValueError):
continue
return None
def prior_path_steps_before_major(
steps: Sequence[Mapping[str, Any]],
major_idx: int,
) -> List[Dict[str, Any]]:
"""Pfadschritte mit kleinerem roadmap_major_step_index, sortiert."""
prior: List[Dict[str, Any]] = []
for step in steps:
mi = _major_index_from_step(step)
if mi is not None and mi < major_idx:
prior.append(dict(step))
prior.sort(key=lambda s: _major_index_from_step(s) or 0)
return prior
def _step_display_fields(step: Mapping[str, Any]) -> Dict[str, Any]:
title = _trim_str(
step.get("title") or step.get("exercise_title"),
limit=200,
)
learning_goal = _trim_str(
step.get("roadmap_learning_goal") or step.get("learning_goal"),
limit=500,
)
summary = _trim_str(step.get("summary"), limit=400)
start_state = _trim_str(step.get("roadmap_start_state") or step.get("start_state"))
target_state = _trim_str(step.get("roadmap_target_state") or step.get("target_state"))
phase = _trim_str(step.get("roadmap_phase") or step.get("phase"))
criteria_raw = step.get("stage_success_criteria") or step.get("success_criteria") or []
criteria = [
t
for x in criteria_raw
if (t := _trim_str(x, limit=200))
][:4]
out: Dict[str, Any] = {
"title": title,
"learning_goal": learning_goal,
"summary": summary,
"start_state": start_state,
"target_state": target_state,
"phase": phase,
"success_criteria": criteria or None,
"major_step_index": _major_index_from_step(step),
}
return {k: v for k, v in out.items() if v is not None and v != "" and v != []}
def build_progression_entry_state(
*,
major_step_index: Optional[int] = None,
prior_steps: Sequence[Mapping[str, Any]] = (),
start_situation: Optional[str] = None,
current_stage_start: Optional[str] = None,
) -> Dict[str, Any]:
"""
Eingangszustand für eine Roadmap-Stufe: erreichte Voraussetzungen aus Vorstufen.
"""
prior_compact = [_step_display_fields(s) for s in prior_steps]
prior_compact = [
p
for p in prior_compact
if any(p.get(k) for k in ("title", "learning_goal", "summary", "success_criteria"))
]
achievements: List[str] = []
detail_lines: List[str] = []
for p in prior_compact:
if p.get("success_criteria"):
achievements.extend(p["success_criteria"])
elif p.get("learning_goal"):
achievements.append(p["learning_goal"])
label_parts: List[str] = []
if p.get("major_step_index") is not None:
label_parts.append(f"Stufe {int(p['major_step_index']) + 1}")
if p.get("phase"):
label_parts.append(f"({p['phase']})")
if p.get("title"):
label_parts.append(f"{p['title']}\"")
prefix = " ".join(label_parts) if label_parts else "Vorstufe"
achieved = ""
if p.get("target_state"):
achieved = p["target_state"]
elif p.get("success_criteria"):
achieved = "; ".join(p["success_criteria"])
elif p.get("learning_goal"):
achieved = p["learning_goal"]
elif p.get("summary"):
achieved = p["summary"]
if achieved:
detail_lines.append(f"{prefix}: erreicht — {achieved}")
immediate_entry: Optional[str] = _trim_str(current_stage_start)
if not immediate_entry and prior_compact:
immediate = prior_compact[-1]
if immediate.get("target_state"):
immediate_entry = immediate["target_state"]
elif immediate.get("success_criteria"):
immediate_entry = "; ".join(immediate["success_criteria"])
elif immediate.get("learning_goal"):
immediate_entry = immediate["learning_goal"]
elif immediate.get("summary"):
immediate_entry = immediate["summary"]
elif not immediate_entry and start_situation:
immediate_entry = start_situation
entry_state = immediate_entry or start_situation
if prior_compact and start_situation and not immediate_entry:
detail_lines.insert(0, f"Ausgangsbasis Pfad: {start_situation}")
out: Dict[str, Any] = {}
if entry_state:
out["entry_state"] = _trim_str(entry_state, limit=1200)
if detail_lines:
out["entry_state_detail"] = _trim_str("\n".join(detail_lines), limit=2000)
if prior_compact:
out["prior_steps"] = prior_compact[:6]
if achievements:
out["prior_achievements"] = list(dict.fromkeys(achievements))[:8]
return out
def enrich_gap_snapshot_with_entry_state(
snapshot: Mapping[str, Any],
*,
steps: Sequence[Mapping[str, Any]],
major_step_index: Optional[int],
) -> Dict[str, Any]:
snap = dict(snapshot)
if major_step_index is None:
return snap
try:
mi = int(major_step_index)
except (TypeError, ValueError):
return snap
prior = prior_path_steps_before_major(steps, mi)
entry = build_progression_entry_state(
major_step_index=mi,
prior_steps=prior,
start_situation=snap.get("start_situation"),
current_stage_start=snap.get("stage_start_state"),
)
snap.update(entry)
return snap
def build_progression_gap_snapshot(
*,
goal_analysis: Optional[Mapping[str, Any]] = None,
@ -141,6 +298,8 @@ def build_progression_gap_snapshot(
"stage_learning_goal": _trim_str(
spec.get("learning_goal"), limit=1200
),
"stage_start_state": _trim_str(spec.get("start_state")),
"stage_target_state": _trim_str(spec.get("target_state")),
"stage_phase": _trim_str(spec.get("phase")),
"stage_exercise_type": _trim_str(spec.get("exercise_type")),
"stage_load_profile": load_profile or None,
@ -160,6 +319,7 @@ def build_progression_path_gap_planning_context(
offer: Optional[Mapping[str, Any]] = None,
neighbor_before: Optional[Mapping[str, Any]] = None,
neighbor_after: Optional[Mapping[str, Any]] = None,
prior_path_steps: Optional[Sequence[Mapping[str, Any]]] = None,
path_step_count: int = 0,
major_step_count: Optional[int] = None,
roadmap_phase: Optional[str] = None,
@ -207,6 +367,14 @@ def build_progression_path_gap_planning_context(
semantic_brief=semantic_brief,
)
ctx.update(snap)
if major_idx is not None and prior_path_steps:
ctx.update(
build_progression_entry_state(
major_step_index=major_idx,
prior_steps=list(prior_path_steps),
start_situation=ctx.get("start_situation"),
)
)
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"]
@ -216,8 +384,11 @@ def build_progression_path_gap_planning_context(
__all__ = [
"build_progression_entry_state",
"build_progression_gap_snapshot",
"build_progression_path_gap_planning_context",
"enrich_gap_snapshot_with_entry_state",
"prior_path_steps_before_major",
"compact_planning_context_json",
"planning_context_prompt_variables",
"sanitize_planning_context_for_ai",

View File

@ -12,7 +12,12 @@ from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_path_qa import find_step_pair_index
from planning_exercise_form_context import build_progression_gap_snapshot
from planning_exercise_form_context import (
build_progression_entry_state,
build_progression_gap_snapshot,
enrich_gap_snapshot_with_entry_state,
prior_path_steps_before_major,
)
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
@ -47,6 +52,8 @@ def _build_stage_ai_context(
spec: Mapping[str, Any],
step_before: Optional[Mapping[str, Any]] = None,
step_after: Optional[Mapping[str, Any]] = None,
prior_steps: Optional[Sequence[Mapping[str, Any]]] = None,
start_situation: Optional[str] = None,
) -> ExerciseFormAiPromptContext:
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
gap = dict(spec.get("gap") or {})
@ -59,11 +66,26 @@ def _build_stage_ai_context(
or ""
).strip()
title = (spec.get("title_hint") or f"{topic}{phase}").strip()[:280]
major_idx = spec.get("roadmap_major_step_index")
entry: Dict[str, Any] = {}
if prior_steps is not None and major_idx is not None:
entry = build_progression_entry_state(
major_step_index=major_idx,
prior_steps=prior_steps,
start_situation=start_situation,
)
goal_parts = [
f"Planungsziel: {goal_query}",
f"Roadmap-Stufe ({phase}): {learning_goal}",
"Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.",
]
if entry.get("entry_state"):
goal_parts.append(
f"Eingangszustand (erreichte Voraussetzungen): {entry['entry_state']}"
)
if entry.get("entry_state_detail") and entry.get("entry_state_detail") != entry.get("entry_state"):
goal_parts.append(f"Bisheriger Pfad:\n{entry['entry_state_detail']}")
if step_before:
goal_parts.append(
f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}"
@ -106,6 +128,7 @@ def try_suggest_ai_stage_step(
except (TypeError, ValueError):
return None
step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi)
prior_steps = prior_path_steps_before_major(steps, mi)
gap = dict(spec.get("gap") or {})
if not gap.get("expected_phase"):
gap["expected_phase"] = spec.get("phase") or "vertiefung"
@ -119,6 +142,7 @@ def try_suggest_ai_stage_step(
spec=spec,
step_before=step_before,
step_after=step_after,
prior_steps=prior_steps,
)
try:
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
@ -440,8 +464,16 @@ def build_gap_fill_goal_text(
f"Planungsziel (gesamter Pfad): {goal_query}",
f"Hauptthema: {snap.get('primary_topic') or topic}",
]
if snap.get("start_situation"):
if snap.get("entry_state"):
parts.append(
f"Eingangszustand (erreichte Voraussetzungen aus Vorstufen): {snap['entry_state']}"
)
if snap.get("entry_state_detail") and snap.get("entry_state_detail") != snap.get("entry_state"):
parts.append(f"Bisheriger Pfad:\n{snap['entry_state_detail']}")
if snap.get("start_situation") and not snap.get("entry_state"):
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
elif snap.get("start_situation") and snap.get("prior_steps"):
parts.append(f"Ausgangsbasis des gesamten Pfads: {snap['start_situation']}")
if snap.get("target_state"):
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
if snap.get("roadmap_notes"):
@ -525,6 +557,14 @@ def build_gap_fill_offer(
step_a = steps[idx] if idx < len(steps) else None
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
enriched_snapshot = dict(roadmap_snapshot) if roadmap_snapshot else {}
major_raw = spec.get("roadmap_major_step_index")
if major_raw is not None:
enriched_snapshot = enrich_gap_snapshot_with_entry_state(
enriched_snapshot,
steps=steps,
major_step_index=major_raw,
)
goal_for_ai = ""
if brief and goal_query:
goal_for_ai = build_gap_fill_goal_text(
@ -533,9 +573,9 @@ def build_gap_fill_offer(
spec=spec,
step_a=step_a,
step_b=step_b,
roadmap_snapshot=roadmap_snapshot,
roadmap_snapshot=enriched_snapshot or None,
)
ctx_preview = dict(roadmap_snapshot) if roadmap_snapshot else None
ctx_preview = enriched_snapshot or None
offer: Dict[str, Any] = {
"offer_id": offer_id,
"source": spec.get("source"),

View File

@ -535,6 +535,9 @@ def _annotate_roadmap_step(
step["roadmap_start_state"] = stage_spec.start_state.strip()
if (stage_spec.target_state or "").strip():
step["roadmap_target_state"] = stage_spec.target_state.strip()
if stage_spec.success_criteria:
step["success_criteria"] = list(stage_spec.success_criteria)
step["stage_success_criteria"] = list(stage_spec.success_criteria)
step["roadmap_match_source"] = "stage_spec"
if skill_expectations:
step["skill_expectations"] = skill_expectations

View File

@ -10,5 +10,5 @@ bcrypt==4.1.3
slowapi==0.1.9
psycopg2-binary==2.9.9
python-dateutil==2.9.0
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
tzdata>=2024.1; sys_platform == "win32" # ZoneInfo lokal; Linux/Docker: apt tzdata
sqlparse>=0.5.0 # Migrationen: Statements splitten (Fallback ohne psql)

View File

@ -1,7 +1,9 @@
"""Tests Planungs-KI Phase D — planning_context für suggestExerciseAi."""
from planning_exercise_form_context import (
build_progression_entry_state,
build_progression_gap_snapshot,
build_progression_path_gap_planning_context,
enrich_gap_snapshot_with_entry_state,
planning_context_prompt_variables,
sanitize_planning_context_for_ai,
)
@ -80,6 +82,47 @@ def test_gap_planning_context_carries_snapshot_fields():
assert ctx["stage_learning_goal"] == "Stufenziel"
def test_build_progression_entry_state_from_prior_steps():
entry = build_progression_entry_state(
major_step_index=2,
prior_steps=[
{
"roadmap_major_step_index": 0,
"title": "Schritt-Stand",
"roadmap_phase": "einstieg",
"success_criteria": ["stabile Grundstellung"],
},
{
"roadmap_major_step_index": 1,
"title": "Mawashi Vorbereitung",
"roadmap_target_state": "Hüfte dreht vor dem Knie",
"roadmap_phase": "grundlage",
},
],
start_situation="Anfänger ohne Kumite-Erfahrung",
current_stage_start="Hüfte dreht vor dem Knie, sicherer Stand",
)
assert entry["entry_state"] == "Hüfte dreht vor dem Knie, sicherer Stand"
assert "Mawashi Vorbereitung" in entry["entry_state_detail"]
assert "stabile Grundstellung" in entry["prior_achievements"][0]
def test_enrich_gap_snapshot_with_entry_state():
snap = enrich_gap_snapshot_with_entry_state(
{"start_situation": "Basis", "stage_learning_goal": "Rhythmen"},
steps=[
{
"roadmap_major_step_index": 0,
"title": "A",
"success_criteria": ["Timing erkannt"],
}
],
major_step_index=1,
)
assert snap["entry_state"] == "Timing erkannt"
assert snap["prior_steps"][0]["title"] == "A"
def test_gap_planning_context_trainer_supplements_and_stage_override():
ctx = build_progression_path_gap_planning_context(
goal_query="Kumite",

View File

@ -169,6 +169,36 @@ def test_build_gap_fill_offer_roadmap_unfilled_uses_major_step_neighbors():
assert "Stufen-Lernziel" in offer["goal_for_ai"] or "Roadmap-Stufe" in offer["goal_for_ai"]
def test_build_gap_fill_offer_includes_entry_state_from_prior_steps():
brief = build_semantic_brief("Kumite Beinarbeit")
steps = [
{
"roadmap_major_step_index": 0,
"title": "Schritt A",
"roadmap_target_state": "gleichmäßige Distanz",
"success_criteria": ["Partnerabstand stabil"],
},
{"roadmap_major_step_index": 2, "title": "Schritt C"},
]
offer = build_gap_fill_offer(
spec={
"source": "roadmap_unfilled",
"phase": "vertiefung",
"title_hint": "Rhythmen",
"roadmap_major_step_index": 1,
},
steps=steps,
goal_query="Kumite Beinarbeit",
brief=brief,
roadmap_snapshot={
"start_situation": "Steppbewegung",
"stage_learning_goal": "variable Rhythmen",
},
)
assert offer["context_preview"]["entry_state"] == "gleichmäßige Distanz"
assert "Eingangszustand" in offer["goal_for_ai"]
def test_build_gap_fill_offer_exposes_context_preview():
brief = build_semantic_brief("Kumite Beinarbeit")
offer = build_gap_fill_offer(

View File

@ -16,6 +16,7 @@ import {
} from '../utils/exerciseAiQuickCreate'
import {
buildPathGapPlanningContextForAi,
buildSlotGapGoalForAi,
gapOfferContextDisplayLines,
initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi'
@ -527,15 +528,23 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const slotOfferContext = (slotIndex) => {
const slot = draft?.slots?.[slotIndex]
if (!slot) return null
if (!draft || !slot) return null
const goalForAi =
buildSlotGapGoalForAi(draft, slotIndex, { goalQuery: draft.goalQuery }) ||
slot.learning_goal
const priorSlot =
slotIndex > 0 && draft.slots[slotIndex - 1]
? draft.slots[slotIndex - 1]
: null
return {
offer_id: `slot-${slotIndex}`,
title_hint: slot.primary?.exerciseTitle || slot.learning_goal,
roadmap_major_step_index: slot.majorStepIndex,
phase: slot.phase,
source: 'roadmap_unfilled',
goal_for_ai: slot.learning_goal,
sketch: slot.learning_goal,
goal_for_ai: goalForAi,
sketch: goalForAi,
from_title: priorSlot?.primary?.exerciseTitle || null,
}
}

View File

@ -2,6 +2,8 @@
* Planungs-KI Phase D: strukturierter Kontext für suggestExerciseAi.
*/
import { slotsAsPathStepRows } from './progressionGraphDraft.js'
export function buildPickerPlanningContextForAi({
planningContextSummary = null,
planningContext = null,
@ -30,6 +32,109 @@ export function buildPickerPlanningContextForAi({
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
@ -56,14 +161,25 @@ export function buildPathGapPlanningContextForAi({
stageLearningGoalOverride = '',
gapTrainerSupplements = '',
} = {}) {
const afterIdx = Number(offer?.insert_after_index)
const stepA = Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx] : null
const stepB =
Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx + 1] : null
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)
@ -92,6 +208,13 @@ export function buildPathGapPlanningContextForAi({
)
}
const entryState = buildProgressionEntryState({
majorStepIndex: majorIdx,
priorSteps,
startSituation: start,
currentStageStart: stageSpec?.start_state || '',
})
const ctx = {
source: 'progression_path_gap_fill',
goal_query: (goalQuery || '').trim() || null,
@ -109,6 +232,8 @@ export function buildPathGapPlanningContextForAi({
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,
@ -125,8 +250,9 @@ export function buildPathGapPlanningContextForAi({
? ga.success_criteria.slice(0, 4)
: null,
skill_hints: skillHints.length ? skillHints : null,
neighbor_before_title: stepA?.exerciseTitle || offer?.from_title || null,
neighbor_after_title: stepB?.exerciseTitle || offer?.to_title || 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 ||
@ -148,7 +274,14 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
const v = String(value || '').trim()
if (v) lines.push({ label, value: v })
}
push('Ausgangslage (Pfad)', raw.start_situation)
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)
@ -173,6 +306,43 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
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')

View File

@ -280,9 +280,15 @@ export function slotsAsPathStepRows(draft) {
return (draft.slots || []).map((slot) => ({
exerciseId: slot.primary?.exerciseId ?? null,
exerciseTitle: slot.primary?.exerciseTitle || '',
title: slot.primary?.exerciseTitle || '',
roadmap_major_step_index: slot.majorStepIndex,
roadmapMajorStepIndex: slot.majorStepIndex,
roadmap_phase: slot.phase,
roadmapPhase: slot.phase,
roadmap_learning_goal: slot.learning_goal,
roadmapLearningGoal: slot.learning_goal,
learning_goal: slot.learning_goal,
success_criteria: Array.isArray(slot.success_criteria) ? slot.success_criteria : [],
isAiProposal: slot.primary?.kind === 'proposal',
}))
}