diff --git a/backend/Dockerfile b/backend/Dockerfile index 41de5da..eb64367 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 . . diff --git a/backend/planning_exercise_form_context.py b/backend/planning_exercise_form_context.py index 6972c93..63c9d04 100644 --- a/backend/planning_exercise_form_context.py +++ b/backend/planning_exercise_form_context.py @@ -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", diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py index c33a5bc..e798161 100644 --- a/backend/planning_exercise_path_ai_fill.py +++ b/backend/planning_exercise_path_ai_fill.py @@ -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"), diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 0855cf2..31d04f9 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index ebc6416..a7f455a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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) diff --git a/backend/tests/test_planning_exercise_form_context.py b/backend/tests/test_planning_exercise_form_context.py index 3079065..5e0e2ba 100644 --- a/backend/tests/test_planning_exercise_form_context.py +++ b/backend/tests/test_planning_exercise_form_context.py @@ -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", diff --git a/backend/tests/test_planning_exercise_path_ai_fill.py b/backend/tests/test_planning_exercise_path_ai_fill.py index 15016a9..8748b04 100644 --- a/backend/tests/test_planning_exercise_path_ai_fill.py +++ b/backend/tests/test_planning_exercise_path_ai_fill.py @@ -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( diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 78bee4c..7d5a20f 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -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, } } diff --git a/frontend/src/utils/planningContextForExerciseAi.js b/frontend/src/utils/planningContextForExerciseAi.js index bc77f9e..1d1da9e 100644 --- a/frontend/src/utils/planningContextForExerciseAi.js +++ b/frontend/src/utils/planningContextForExerciseAi.js @@ -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') diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index dd7bf38..3a2707a 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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', })) }