Merge pull request 'progression V2' (#57) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s

Reviewed-on: #57
This commit is contained in:
Lars 2026-06-13 16:34:09 +02:00
commit ea7de64061
24 changed files with 4538 additions and 314 deletions

View File

@ -15,7 +15,7 @@
**Plattform-Rechtstexte (P-01, 0.8.950.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent). **Plattform-Rechtstexte (P-01, 0.8.950.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.1370.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.1370.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8.
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md) **Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)

View File

@ -465,6 +465,8 @@ skill_level_definitions (
**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4. **Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4.
**KI-Planung (Workbench, Stand 0.8.233):** Am Graph können Trainer neben Kanten ein **`planning_roadmap`**-Artefakt (Curriculum-Stufen) und **`planning_catalog_context`** (Primärfokus, Stilrichtung, Trainingsstil, Zielgruppe aus den Katalog-Dimensionen §1) pflegen. Die Roadmap-first-Pipeline matcht Übungen pro Stufe; Didaktik und Reihenfolge kommen aus Roadmap + QS, nicht aus Technik-Hardcoding. **Geplant (H1):** Katalog-Dimensionen zusätzlich als **Prompt-Snippets** in LLM-Aufrufen (Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`**. Technische Details: **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**. Für **Trainingsplanung** (Einheit, Abschnitt, Rahmen-Slot) gelten dieselben Katalog- und Retrieval-Bausteine mit anderen Scopes — Phase G, siehe Roadmap **`PLANNING_KI_ROADMAP.md`**.
### TrainingsrahmenVorlage (Rahmenprogramm, CURR002 Stufe2 / CURR009) ### TrainingsrahmenVorlage (Rahmenprogramm, CURR002 Stufe2 / CURR009)
**Abgrenzung:** Eine **einzeilige** TrainingsplanMikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten SessionSlots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR013**). **Abgrenzung:** Eine **einzeilige** TrainingsplanMikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten SessionSlots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR013**).

File diff suppressed because it is too large Load Diff

View File

@ -745,12 +745,44 @@ def build_path_qa_summary(
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema" f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
for o in off_topic for o in off_topic
] ]
summary["quality_score"] = compute_deterministic_path_quality_score(
gaps=gaps,
off_topic_steps=off_topic,
steps=steps,
multistage_qa=multistage_qa,
)
return summary return summary
def compute_deterministic_path_quality_score(
*,
gaps: Sequence[Mapping[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]],
steps: Optional[Sequence[Mapping[str, Any]]] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
) -> float:
"""Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche."""
score = 0.92
score -= 0.08 * len(off_topic_steps or [])
score -= 0.05 * len(gaps or [])
if steps:
empty = sum(
1
for s in steps
if isinstance(s, dict)
and s.get("exercise_id") is None
and not s.get("is_ai_proposal")
)
score -= 0.06 * empty
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
score -= min(0.14, 0.02 * hint_count)
return max(0.35, min(0.98, round(score, 4)))
__all__ = [ __all__ = [
"apply_llm_path_reorder", "apply_llm_path_reorder",
"build_path_qa_summary", "build_path_qa_summary",
"compute_deterministic_path_quality_score",
"detect_off_topic_steps", "detect_off_topic_steps",
"detect_path_gaps", "detect_path_gaps",
"is_roadmap_planned_neighbor_pair", "is_roadmap_planned_neighbor_pair",

View File

@ -8,6 +8,40 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
def _slot_priority_for_rematch(
body,
*,
major_idx: int,
old: Optional[Mapping[str, Any]],
rejected_by_major: Optional[Mapping[int, Set[int]]],
) -> Optional[int]:
"""Bestehende Slot-Zuordnung beim Rematch bevorzugen — außer explizit abgelehnt."""
priority_id: Optional[int] = None
if body is not None:
for raw in getattr(body, "slot_assignments", None) or []:
midx = getattr(raw, "roadmap_major_step_index", None)
if midx is None or int(midx) != int(major_idx):
continue
eid = getattr(raw, "exercise_id", None)
if eid is not None:
try:
priority_id = int(eid)
except (TypeError, ValueError):
priority_id = None
break
if priority_id is None and old and old.get("exercise_id") is not None:
try:
priority_id = int(old["exercise_id"])
except (TypeError, ValueError):
priority_id = None
if priority_id is None or priority_id < 1:
return None
rejected = rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
if priority_id in rejected:
return None
return priority_id
def collect_rematch_slot_indices( def collect_rematch_slot_indices(
*, *,
stripped_off_topic: Sequence[Mapping[str, Any]], stripped_off_topic: Sequence[Mapping[str, Any]],
@ -80,6 +114,43 @@ def collect_rematch_slot_indices(
return indices, reasons return indices, reasons
def filter_rematch_slot_indices(
steps: Sequence[Mapping[str, Any]],
slot_indices: Set[int],
*,
stripped_off_topic: Sequence[Mapping[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]],
) -> Set[int]:
"""Trainer-Zuordnungen (slot_best_match) nicht rematchen, außer Slot ist explizit beanstandet."""
flagged: Set[int] = set()
for item in list(stripped_off_topic or []) + list(off_topic_steps or []):
if not isinstance(item, dict):
continue
midx = item.get("roadmap_major_step_index")
if midx is not None:
try:
flagged.add(int(midx))
except (TypeError, ValueError):
pass
preserved: Set[int] = set()
for raw in steps or []:
if not isinstance(raw, dict):
continue
midx = raw.get("roadmap_major_step_index")
if midx is None:
continue
try:
major_idx = int(midx)
except (TypeError, ValueError):
continue
if raw.get("roadmap_match_source") == "slot_best_match" or raw.get("slot_status") == "preserved":
if major_idx not in flagged:
preserved.add(major_idx)
return {idx for idx in slot_indices if idx not in preserved}
def _context_before_major( def _context_before_major(
steps_by_major: Mapping[int, Mapping[str, Any]], steps_by_major: Mapping[int, Mapping[str, Any]],
target_major: int, target_major: int,
@ -178,6 +249,12 @@ def rematch_roadmap_slots(
anchor_id=anchor_id, anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id, anchor_variant_id=anchor_variant_id,
used=used, used=used,
slot_priority_exercise_id=_slot_priority_for_rematch(
body,
major_idx=major_idx,
old=old,
rejected_by_major=rejected_by_major,
),
) )
reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot") reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot")
@ -186,12 +263,10 @@ def rematch_roadmap_slots(
new_eid = int(new_step.get("exercise_id") or 0) new_eid = int(new_step.get("exercise_id") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
new_eid = 0 new_eid = 0
hist = ( rejected = (
slot_assignment_history.get(int(major_idx), set()) rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
if slot_assignment_history
else set()
) )
if new_eid > 0 and new_eid in hist: if new_eid > 0 and new_eid in rejected:
new_step = None new_step = None
if new_step: if new_step:
steps_by_major[int(major_idx)] = new_step steps_by_major[int(major_idx)] = new_step
@ -207,6 +282,26 @@ def rematch_roadmap_slots(
} }
) )
else: else:
if old and old.get("exercise_id") is not None:
try:
old_eid = int(old["exercise_id"])
except (TypeError, ValueError):
old_eid = 0
rejected = (
rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
)
if old_eid > 0 and old_eid not in rejected:
steps_by_major[int(major_idx)] = dict(old)
rematch_log.append(
{
"roadmap_major_step_index": int(major_idx),
"action": "restored",
"reason": reason,
"restored_exercise_id": old_eid,
"restored_title": old.get("title"),
}
)
continue
goal = (stage_spec.learning_goal or "").strip() goal = (stage_spec.learning_goal or "").strip()
major = None major = None
if roadmap_ctx.roadmap: if roadmap_ctx.roadmap:
@ -278,6 +373,7 @@ def prune_stripped_after_rematch(
__all__ = [ __all__ = [
"collect_rematch_slot_indices", "collect_rematch_slot_indices",
"filter_rematch_slot_indices",
"prune_stripped_after_rematch", "prune_stripped_after_rematch",
"rematch_roadmap_slots", "rematch_roadmap_slots",
] ]

View File

@ -37,6 +37,7 @@ class GraphPlanningRoadmapArtifact(BaseModel):
path_skill_expectations: Optional[Dict[str, Any]] = None path_skill_expectations: Optional[Dict[str, Any]] = None
slot_contents: Optional[List[SlotContentEntry]] = None slot_contents: Optional[List[SlotContentEntry]] = None
last_findings: Optional[Dict[str, Any]] = None last_findings: Optional[Dict[str, Any]] = None
findings_stale: bool = Field(default=False)
planning_catalog_context: Optional[Dict[str, Any]] = None planning_catalog_context: Optional[Dict[str, Any]] = None
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", "planning_catalog_context", mode="before") @field_validator("progression_roadmap", "path_skill_expectations", "last_findings", "planning_catalog_context", mode="before")

View File

@ -0,0 +1,31 @@
"""Tests Trainer-Pfad-Schutz (preserve_slot_assignments)."""
from planning_exercise_path_builder import (
EvaluateStepPayload,
ProgressionPathSuggestRequest,
_assignment_preservation_active,
)
def test_assignment_preservation_explicit_flag():
body = ProgressionPathSuggestRequest(
query="Kumite Beinarbeit Progression",
preserve_slot_assignments=True,
)
assert _assignment_preservation_active(body)
def test_assignment_preservation_not_auto_from_slot_assignments():
"""Nur explizites preserve_slot_assignments — sonst wäre compare/match blockiert."""
body = ProgressionPathSuggestRequest(
query="Kumite Beinarbeit Progression",
slot_assignments=[
EvaluateStepPayload(exercise_id=10, roadmap_major_step_index=0),
EvaluateStepPayload(exercise_id=11, roadmap_major_step_index=1),
],
)
assert not _assignment_preservation_active(body)
def test_assignment_preservation_inactive_without_assignments():
body = ProgressionPathSuggestRequest(query="Kumite Beinarbeit Progression")
assert not _assignment_preservation_active(body)

View File

@ -45,3 +45,34 @@ def test_normalize_planning_roadmap_with_catalog_context():
} }
) )
assert out["planning_catalog_context"]["focus_areas"][0]["id"] == 4 assert out["planning_catalog_context"]["focus_areas"][0]["id"] == 4
def test_multistage_qa_splits_llm_highlights_from_fix_hints():
from planning_path_qa_pipeline import run_multistage_path_qa
result = run_multistage_path_qa(
off_topic_steps=[],
stripped_off_topic=[
{
"issue": "roadmap_unfilled",
"step_index": 1,
"reasons": ["Keine passende Übung"],
}
],
gaps=[],
llm_qa={
"overall_ok": True,
"quality_score": 0.88,
"recommendations": [
"Gute didaktische Progression",
"Optional: Vertiefung Koordination",
],
},
llm_applied=True,
)
hints = result["optimization_hints"]
llm_hints = [h for h in hints if h.get("issue") == "llm_recommendation"]
fix_hints = [h for h in hints if h.get("issue") != "llm_recommendation"]
assert len(llm_hints) >= 2
assert any(h.get("issue") == "roadmap_unfilled" for h in fix_hints)
assert result["qa_tiers"][2]["recommendations"][0].startswith("Gute didaktische")

View File

@ -0,0 +1,127 @@
"""Tests Vergleichs-Diffs (triviale ID-Tausche markieren, Rematch-Filter)."""
from planning_exercise_path_builder import (
_actionable_slot_diffs,
_annotate_slot_diffs,
_build_progression_compare_response,
_build_progression_slot_diffs,
_build_rematch_suggestion_diffs,
)
def test_annotate_trivial_id_swap():
diffs = [
{
"roadmap_major_step_index": 1,
"baseline_exercise_id": 10,
"baseline_title": "Rhythmuswechsel in der Kumite-Beinarbeit",
"proposed_exercise_id": 99,
"proposed_title": "Rhythmuswechsel in der Kumite-Beinarbeit",
}
]
annotated = _annotate_slot_diffs(diffs)
assert len(annotated) == 1
assert annotated[0]["trivial_id_swap"] is True
assert _actionable_slot_diffs(annotated) == []
def test_annotate_keeps_real_title_change():
diffs = [
{
"roadmap_major_step_index": 1,
"baseline_exercise_id": 10,
"baseline_title": "Alt",
"proposed_exercise_id": 99,
"proposed_title": "Neu",
}
]
annotated = _annotate_slot_diffs(diffs)
assert annotated[0]["trivial_id_swap"] is False
assert len(_actionable_slot_diffs(annotated)) == 1
def test_build_slot_diffs_then_annotate():
baseline = [
{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
{"roadmap_major_step_index": 1, "exercise_id": 10, "title": "Gleich"},
]
proposed = [
{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
{"roadmap_major_step_index": 1, "exercise_id": 77, "title": "Gleich"},
]
raw = _build_progression_slot_diffs(baseline, proposed)
annotated = _annotate_slot_diffs(raw)
assert len(annotated) == 1
assert annotated[0]["trivial_id_swap"] is True
assert _actionable_slot_diffs(annotated) == []
def test_rematch_suggestion_skips_filled_baseline_slot():
baseline = [
{
"roadmap_major_step_index": 1,
"exercise_id": 5727,
"title": "Einführung von Richtungswechseln",
"slot_status": "preserved",
},
]
rematch_log = [
{
"roadmap_major_step_index": 1,
"action": "replaced",
"round": 3,
"new_exercise_id": 5594,
"new_title": "Kumite Beinarbeit — vertiefung",
"replaced_exercise_id": 5727,
},
]
assert _build_rematch_suggestion_diffs(baseline, rematch_log) == []
def test_rematch_suggestion_keeps_empty_baseline_slot():
baseline = [
{"roadmap_major_step_index": 1, "exercise_id": None, "title": "Lernziel Slot 2"},
]
rematch_log = [
{
"roadmap_major_step_index": 1,
"action": "replaced",
"round": 1,
"new_exercise_id": 101,
"new_title": "Rhythmuswechsel in der Kumite-Beinarbeit",
},
]
diffs = _build_rematch_suggestion_diffs(baseline, rematch_log)
assert len(diffs) == 1
assert diffs[0]["proposed_exercise_id"] == 101
def test_compare_response_no_step_diffs_uses_baseline_qa_not_pipeline():
baseline = {
"steps": [{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}],
"path_qa": {"overall_ok": True, "quality_score": 0.88},
}
proposed = {
"steps": [{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}],
"path_qa": {"overall_ok": False, "quality_score": 0.65, "rematch_log": [{"action": "replaced"}]},
}
compare = _build_progression_compare_response(baseline, proposed, proposed_eval=None)
assert compare["slot_diff_count"] == 0
assert compare["slot_diffs_source"] == "steps"
assert compare["proposed_path_qa"]["quality_score"] == 0.65
def test_compare_wrapper_snaps_proposed_qa_to_baseline_without_diffs():
baseline = {
"steps": [{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}],
"path_qa": {"overall_ok": True, "quality_score": 0.88},
}
proposed = {
"steps": [{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}],
"path_qa": {"overall_ok": False, "quality_score": 0.65},
}
raw = _build_progression_compare_response(baseline, proposed, proposed_eval=None)
assert raw["proposed_path_qa"]["quality_score"] == 0.65
if raw.get("slot_diff_count", 0) == 0:
fair = baseline["path_qa"]
raw["proposed_path_qa"] = fair
assert raw["proposed_path_qa"]["quality_score"] == 0.88

View File

@ -0,0 +1,21 @@
"""Deterministische Pfad-QS ohne LLM."""
from planning_exercise_path_qa import compute_deterministic_path_quality_score
def test_deterministic_quality_score_penalizes_off_topic():
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[])
with_off = compute_deterministic_path_quality_score(
gaps=[],
off_topic_steps=[{"roadmap_major_step_index": 1}],
)
assert with_off < base
def test_deterministic_quality_score_penalizes_empty_slots():
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=[])
with_empty = compute_deterministic_path_quality_score(
gaps=[],
off_topic_steps=[],
steps=[{"exercise_id": None}, {"exercise_id": 1}],
)
assert with_empty < base

View File

@ -0,0 +1,39 @@
"""Tests inkrementelles Slot-Diff-Scoring (nur messbare Verbesserungen)."""
from planning_exercise_path_builder import (
_apply_slot_diff_to_steps,
_slot_diff_improves_path,
)
def test_slot_diff_improves_path_fill_neutral_or_positive():
fill = {"baseline_exercise_id": None, "proposed_exercise_id": 101}
assert _slot_diff_improves_path(fill, 0.0) is True
assert _slot_diff_improves_path(fill, 0.04) is True
assert _slot_diff_improves_path(fill, -0.01) is False
def test_slot_diff_improves_path_off_topic_allows_neutral_replace():
repl = {"baseline_exercise_id": 10, "proposed_exercise_id": 99}
assert _slot_diff_improves_path(repl, 0.0, off_topic=True) is True
assert _slot_diff_improves_path(repl, -0.02, off_topic=True) is False
def test_apply_slot_diff_merges_proposed_step():
baseline = [
{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
{"roadmap_major_step_index": 1, "exercise_id": None, "title": "Leer"},
]
proposed = [
{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
{"roadmap_major_step_index": 1, "exercise_id": 55, "title": "Neu", "slot_status": "matched"},
]
diff = {
"roadmap_major_step_index": 1,
"baseline_exercise_id": None,
"proposed_exercise_id": 55,
"proposed_title": "Neu",
}
merged = _apply_slot_diff_to_steps(baseline, diff, proposed)
assert merged[0]["exercise_id"] == 1
assert merged[1]["exercise_id"] == 55
assert merged[1]["title"] == "Neu"

View File

@ -214,6 +214,7 @@ def test_rematch_unfilled_leaves_placeholder_step():
slot_indices={1}, slot_indices={1},
rematch_reasons={1: "stage_mismatch"}, rematch_reasons={1: "stage_mismatch"},
match_slot_fn=_no_match, match_slot_fn=_no_match,
rejected_by_major={1: {99}},
) )
assert len(ordered) == 2 assert len(ordered) == 2
@ -235,3 +236,103 @@ def test_prune_filled_from_roadmap_unfilled():
unfilled_steps = [{"exercise_id": None, "roadmap_major_step_index": 5}] unfilled_steps = [{"exercise_id": None, "roadmap_major_step_index": 5}]
kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)]) kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)])
assert len(kept2) == 1 assert len(kept2) == 1
def test_rematch_keeps_same_exercise_when_not_rejected():
"""Regression: slot_assignment_history blockierte gültige Wiederzuordnung → leere Slots."""
specs = _stage_specs()
ctx = ProgressionRoadmapContext(
goal_query="Mae Geri",
max_steps=3,
stage_specs=specs,
)
steps = [
{"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
{"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": 1},
]
def _same_match(cur, *, stage_spec, slot_priority_exercise_id=None, **kwargs):
assert slot_priority_exercise_id == 42
return (
{"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": stage_spec.major_step_index},
None,
)
ordered, log, unfilled = rematch_roadmap_slots(
None,
tenant=None,
body=None,
goal_query="Mae Geri",
max_steps=3,
semantic_brief=None,
path_target_profile=None,
path_intent="",
roadmap_ctx=ctx,
steps=steps,
slot_indices={1},
rematch_reasons={1: "refine_stage_spec"},
match_slot_fn=_same_match,
rejected_by_major={},
)
assert ordered[1]["exercise_id"] == 42
assert log[0]["action"] == "replaced"
assert not unfilled
def test_rematch_restores_when_match_fails_and_not_rejected():
specs = _stage_specs()
ctx = ProgressionRoadmapContext(
goal_query="Mae Geri",
max_steps=3,
stage_specs=specs,
)
steps = [
{"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
{"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": 1},
]
def _no_match(cur, *, stage_spec, **kwargs):
return None, stage_spec
ordered, log, unfilled = rematch_roadmap_slots(
None,
tenant=None,
body=None,
goal_query="Mae Geri",
max_steps=3,
semantic_brief=None,
path_target_profile=None,
path_intent="",
roadmap_ctx=ctx,
steps=steps,
slot_indices={1},
rematch_reasons={1: "refine_stage_spec"},
match_slot_fn=_no_match,
rejected_by_major={},
)
assert ordered[1]["exercise_id"] == 42
assert log[0]["action"] == "restored"
assert not unfilled
def test_filter_rematch_skips_preserved_slots():
from planning_path_rematch import filter_rematch_slot_indices
steps = [
{
"exercise_id": 10,
"roadmap_major_step_index": 0,
"roadmap_match_source": "slot_best_match",
"slot_status": "preserved",
},
{"exercise_id": 20, "roadmap_major_step_index": 1},
]
filtered = filter_rematch_slot_indices(
steps,
{0, 1},
stripped_off_topic=[],
off_topic_steps=[],
)
assert filtered == {1}

View File

@ -0,0 +1,130 @@
"""Schachstellen-Erkennung für unified Slot-Review."""
from planning_exercise_path_builder import (
_parse_slot_refs_from_text,
_problematic_slots_from_path_qa,
_slot_auto_select_library,
_slot_suggestion_accepted,
)
from planning_progression_roadmap import StageSpecArtifact
def _spec(midx: int) -> StageSpecArtifact:
return StageSpecArtifact(
major_step_index=midx,
learning_goal=f"Lernziel Slot {midx + 1}",
load_profile=[],
exercise_type="",
success_criteria=[],
anti_patterns=[],
)
def test_problematic_slots_from_optimization_hints():
qa = {
"optimization_hints": [
{
"action": "rematch_slot",
"step_index": 1,
"issue": "stage_mismatch",
"reason": "Übung passt nicht zur Stufe",
}
],
"off_topic_steps": [],
}
steps = [
{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
{"roadmap_major_step_index": 1, "exercise_id": 2, "title": "B"},
]
specs = [_spec(0), _spec(1)]
problems = _problematic_slots_from_path_qa(qa, steps, specs)
assert 1 in problems
assert any("Stufe" in r or "passt" in r for r in problems[1])
def test_slot_suggestion_accepted_for_problem_slot():
diff = {"baseline_exercise_id": 10, "proposed_exercise_id": 99}
assert _slot_suggestion_accepted(
baseline_qa={"optimization_hints": [{"action": "rematch_slot", "roadmap_major_step_index": 1}]},
projected_qa={"optimization_hints": []},
baseline_score=0.7,
projected_score=0.7,
diff=diff,
off_topic=False,
major_idx=1,
slot_problem=True,
)
def test_parse_slot_refs_schritt_is_one_based():
assert _parse_slot_refs_from_text("Schritt 8 (Ukemi Vorwärts) entfernen") == {7}
assert _parse_slot_refs_from_text("slot 3 und Stufe 5") == {2, 4}
def test_problematic_slots_from_refine_stage_spec_hint():
qa = {
"optimization_hints": [
{
"action": "refine_stage_spec",
"step_index": 7,
"issue": "stage_mismatch",
"reason": "Stufen-Fit zu schwach (0.00) für „Integration von Täuschung“",
}
],
"off_topic_steps": [],
}
steps = [
{"roadmap_major_step_index": i, "exercise_id": i + 1, "title": f"Übung {i + 1}"}
for i in range(8)
]
steps[7]["title"] = "Ukemi Vorwärts"
specs = [_spec(i) for i in range(8)]
problems = _problematic_slots_from_path_qa(qa, steps, specs)
assert 7 in problems
def test_problematic_slots_from_llm_schritt_text():
qa = {
"optimization_hints": [],
"off_topic_steps": [],
"issues": [
"Schritt 8 (Ukemi Vorwärts) hat keinen Bezug zur Kumite-Beinarbeit",
],
}
steps = [
{"roadmap_major_step_index": 7, "exercise_id": 99, "title": "Ukemi Vorwärts"},
]
specs = [_spec(7)]
problems = _problematic_slots_from_path_qa(qa, steps, specs)
assert 7 in problems
def test_slot_auto_select_requires_higher_score():
assert _slot_auto_select_library(
baseline_slot_score=0.5,
proposed_slot_score=0.51,
baseline_exercise_id=1,
proposed_exercise_id=2,
)
assert not _slot_auto_select_library(
baseline_slot_score=0.5,
proposed_slot_score=0.5,
baseline_exercise_id=1,
proposed_exercise_id=2,
)
def test_off_topic_slot_gap_spec_for_filled_slot():
from planning_exercise_path_builder import _build_off_topic_slot_gap_spec
spec = _build_off_topic_slot_gap_spec(
{
"roadmap_major_step_index": 7,
"exercise_id": 99,
"title": "Ukemi Vorwärts",
"roadmap_learning_goal": "Integration Täuschung",
}
)
assert spec is not None
assert spec["source"] == "off_topic"
assert spec["roadmap_major_step_index"] == 7
assert "Ukemi" in spec["rationale"]

View File

@ -57,3 +57,15 @@ def test_normalize_slot_contents():
) )
assert len(out["slot_contents"]) == 2 assert len(out["slot_contents"]) == 2
assert out["slot_contents"][1]["primary"]["kind"] == "proposal" assert out["slot_contents"][1]["primary"]["kind"] == "proposal"
def test_normalize_planning_roadmap_with_findings_stale():
out = normalize_planning_roadmap_payload(
{
"goal_query": "Mae Geri",
"last_findings": {"overall_ok": False},
"findings_stale": True,
}
)
assert out["findings_stale"] is True
assert out["last_findings"]["overall_ok"] is False

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-06-07 **Stand:** 2026-05-22
**App-Version / DB-Schema:** App **`0.8.208`** (Planungs-KI Phase D); DB **`20260606086`** — maßgeblich **`backend/version.py`**. **App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11F14, Katalog-Kontext); DB siehe **`backend/version.py`** (`DB_SCHEMA_VERSION`, Migration **088**).
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -37,6 +37,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` | | Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` | | Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
| **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.md` | | **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
| **Planungs-KI — Katalog-Prompt-Snippets (H1)** | **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
| **Lieferliste inkl. Medien & Formular-UX** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §6, §16 | | **Lieferliste inkl. Medien & Formular-UX** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §6, §16 |
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** | | **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
@ -89,7 +90,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.217**) ### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.233**)
**Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen. **Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen.
@ -108,20 +109,32 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **F7** | `planning_skill_expectations` — Retrieval + UI + Gap | ✅ **0.8.215216** | | **F7** | `planning_skill_expectations` — Retrieval + UI + Gap | ✅ **0.8.215216** |
| **F8** | Editierbare `stage_specs` (Belastung, Erfolgskriterien) | ✅ **0.8.216** | | **F8** | Editierbare `stage_specs` (Belastung, Erfolgskriterien) | ✅ **0.8.216** |
| **F9** | `planning_roadmap` JSONB am Graph (Migration **088**) | ✅ **0.8.217** | | **F9** | `planning_roadmap` JSONB am Graph (Migration **088**) | ✅ **0.8.217** |
| **F10** | Stufen-Lernziel-Gate, kein blindes Rank-Fallback | ✅ **0.8.218** |
| **F11** | Auto-Rematch + Stufen-Spec-Refine + mehrstufige QS | ✅ **0.8.2250.8.230** |
| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ **0.8.2310.8.232** |
| **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** |
| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** |
| **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
**Architektur (verbindlich):** Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2, **kein** automatisches Erweitern ab letztem Knoten (siehe Ist-Doku §5). Trainingsplanung = **eigene Pipeline** (Phase G), wiederverwendet `planning_skill_expectations`. **Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16.
**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py` **Validierung (Mae Geri, Härtetest):** Pfad-QS vor Optimierung ~65 % → nach Trainer-Roadmap + KI-Gap-Fill **~88 % OK**. Workbench ist **universell** gedacht; Mae Geri war Referenzfall, kein Sonder-Patch.
**API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`) · `POST …/edges/sequence` **Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_catalog_context.py`, `planning_path_rematch.py`, `planning_path_refine_stage.py`, `planning_path_qa_pipeline.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py`
**Frontend:** `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `planningContextForExerciseAi.js` **API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`, `planning_catalog_context`) · `POST …/edges/sequence`
**Frontend:** **`ProgressionGraphEditor`** (primäre Workbench), `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `progressionGraphDraft.js`, `planningContextForExerciseAi.js`
**Offen (priorisiert):** **Offen (priorisiert):**
1. UI-Wizard (Scroll-Monolith → 4 Schritte) — **separater UI-Chat** 1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri)
2. Graph-Erweiterungsmodus (Start ab Knoten) 2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor
3. Trainingsplanung Phase G (Gruppenkontext) 3. QS-UI — positive LLM-Hinweise als Highlights
4. Kontext-Anzeige auf allen Pfad-Schritten 4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
5. Graph-Erweiterungsmodus (Start ab Knoten)
6. Phase D — Auto KI-Gap-Fill bei persistent leeren Slots
7. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
8. Technik-Katalog konfigurierbar (Backlog)
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**) #### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
@ -256,11 +269,15 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
### Planungs-KI (priorisiert) ### Planungs-KI (priorisiert)
1. **Phase F2:** LLM für Roadmap (Prompts **078**) + `roadmap_first` Retrieval aus `stage_specs`. 1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung).
2. **Phase F4:** Roadmap-Review UI (Major Steps editierbar vor Übungs-Match). 2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest.
3. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza). 2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`. 3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale.
5. **Trainingsplanung G:** Kontext-Pack Gruppe/Historie — eigene Pipeline (`AI_PLANNING_KI_MULTISTAGE_FORECAST`); Mitai Workflow-Engine erst danach. 4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert.
5. **Phase D:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`.
6. **Trainingsplanung G0G4:** Katalog in Einheits-Editor, Scopes `training_section`/`framework_slot`, Abschnitts-QS, Gruppenkontext-Pack — Details **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16, **`PLANNING_KI_ROADMAP.md`**.
7. **Technik-Katalog externalisieren** (Backlog): `concept_groups` konfigurierbar statt Code-Tuples.
8. **Mitai Workflow-Engine** erst nach stabiler Phase G.
### Allgemein ### Allgemein

View File

@ -0,0 +1,229 @@
# Planungs-KI — Katalog-Snippets für modulare Prompts
**Stand:** 2026-05-22
**Status:** Spezifikation (Phase **H1** — Umsetzung offen)
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py`
---
## 1. Problem
Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring** (`PlanningTargetProfile`).
Die **LLM-Prompts** (Roadmap, Stufen-Spec, Pfad-QS, Gap-Fill) erhalten diese Dimensionen **nicht** als differenzierte Bewertungs- und Planungslogik — höchstens indirekt über Freitext oder JSON-Kataloglisten in Intent-Prompts.
**Folge:** Ein Breitensport-Pfad kann mit Leistungsgruppen-Kriterien bewertet werden; Gewaltschutz wird wie Technik-Curriculum behandelt; QS-Hinweise und Rematch-Vorschläge passen fachlich nicht zum gewählten Kontext.
**Ziel:** Gleiche **Prompt-Basis**, aber **kaskadierte Snippet-Blöcke** pro Katalog-Ausprägung — keine Matrix aus Voll-Prompt-Kopien.
---
## 2. Priorität der Dimensionen (absteigend)
Verbindliche Reihenfolge bei Konflikten und beim Rollout:
| Rang | Dimension | DB-Tabelle | Snippet-Rolle |
|------|-----------|------------|----------------|
| **1** | **Primärfokus** | `focus_areas` | Definiert *worüber* geplant und bewertet wird (Technik-Curriculum vs. Gewaltschutz vs. Fitness …). **Dominant.** |
| **2** | **Trainingsstil** | `training_types` | Definiert *wie* trainiert wird (Methodik, Belastungsaufbau, Wettkampf vs. Breitenansatz im Training). |
| **3** | **Zielgruppe** | `target_groups` | Definiert *für wen* (Kinder, Breitensport, Leistungsgruppe) — Tempo, Komplexität, Sicherheit. |
| **4** | **Stilrichtung** | `style_directions` | Vereins-/Stil-Linie (Shotokan, WKF …) — **Nuancen**, kein neuer Planungstyp. |
**Kaskaden-Regel:** Bei widersprüchlichen Snippet-Aussagen gilt: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung.
---
## 3. Architektur — drei Schichten (Erinnerung)
| Schicht | Heute | Mit H1 |
|---------|-------|--------|
| **Retrieval** | Katalog-Gewichte in `merge_catalog_context_into_target` | unverändert |
| **Technik-Gates** | `planning_exercise_semantics.py`, nur `topic_type=technique` | unverändert |
| **LLM-Prompts** | kaum Katalog-spezifisch | **`catalog_guidance_block`** pro Aufruf |
Snippets **ersetzen** keine Technik-Disambiguierung und **duplizieren** keine Retrieval-Gewichte — sie steuern **Didaktik, Bewertungsmaßstäbe und Formulierung** für das Modell.
---
## 4. Snippet-Modell
### 4.1 Lookup-Schlüssel
Pro Katalog-Eintrag ein stabiler **`snippet_key`** (nicht nur numerische ID — IDs können sich in Dev/Import unterscheiden):
```
focus:{slug} z. B. focus:gewaltschutz
training_type:{slug} z. B. training_type:kumite
target_group:{slug} z. B. target_group:breitensport
style:{slug} z. B. style:shotokan
```
**Primär** aus `slug` der DB-Zeile; Fallback normalisierter `name` (ASCII, lower, `_`).
Mehrfachauswahl im UI: pro Dimension **höchstens ein Snippet** — die Zeile mit `is_primary: true`, sonst erste Zeile, sonst höchste `weight`.
### 4.2 Snippet-Inhalt (Struktur)
Jedes Snippet liefert strukturierte Textbausteine (Deutsch, für LLM):
| Feld | Pflicht | Inhalt |
|------|---------|--------|
| `planning_lens` | ja | 24 Sätze: Was ist das Planungsziel in dieser Dimension? |
| `qa_criteria` | ja | Bullet-artige Kriterien für Pfad-QS (was ist „gut“, was ist kein Mangel) |
| `roadmap_hints` | empfohlen | Stufenlogik, typische Phasen, was vermeiden |
| `anti_patterns` | optional | Explizite Fehlbewertungen vermeiden (z. B. „keine Wettkampf-Tiefe verlangen“) |
| `rematch_guard` | optional | Wann **kein** Auto-Rematch sinnvoll (Breitensport: keine Perfektions-Slots erzwingen) |
Phase **H1:** flache Markdown-Strings im Code-Modul.
Phase **H2 (optional):** Tabelle `planning_catalog_prompt_snippets` oder JSONB an Katalog-Zeilen, Admin-editierbar.
### 4.3 Platzhalter in `ai_prompts`
Neue **gemeinsame** Platzhalter (Mustache), in alle betroffenen Prompts einfügen:
| Platzhalter | Bedeutung |
|-------------|-----------|
| `{{catalog_guidance_block}}` | Gerenderter Gesamttext (alle aktiven Snippets, kaskadiert) |
| `{{catalog_context_json}}` | Kompakte JSON-Zusammenfassung der gewählten IDs/Namen (Audit) |
| `{{#has_catalog_guidance}}``{{/has_catalog_guidance}}` | Block nur wenn mindestens ein Snippet aktiv |
**Optional fein (später):** `{{catalog_focus_snippet}}`, `{{catalog_training_type_snippet}}`, … — Phase H1 nur `_block` + JSON.
### 4.4 Betroffene Prompt-Slugs (Reihenfolge Einbindung)
| Priorität | Slug | Migration | Wirkung |
|-----------|------|-----------|---------|
| 1 | `planning_exercise_path_qa` | bestehend | Pfad-QS, `quality_score`, Empfehlungen |
| 2 | `planning_progression_roadmap` | 078 | Major Steps, Didaktik |
| 3 | `planning_progression_stage_spec` | 079 | Stufen-Gates, Erfolgskriterien |
| 4 | `planning_progression_start_target` | 087 | Start/Ziel-Extraktion |
| 5 | `planning_progression_goal_analysis` | 078 | Zielanalyse |
| 6 | Gap-Fill / Übungs-KI | 085+ | `planning_context` ergänzen |
Intent-Prompts (`planning_exercise_search_intent`, …) **optional** Phase H1.5 — dort bereits Katalog-JSON.
---
## 5. Builder (Backend)
**Neues Modul:** `backend/planning_catalog_prompt_snippets.py`
```python
def build_catalog_guidance_for_prompt(
cur,
catalog: Optional[ProgressionPlanningCatalogContext],
) -> Dict[str, str]:
"""
Returns:
catalog_guidance_block: str
catalog_context_json: str
has_catalog_guidance: bool
snippet_keys: list[str] # Metadaten für Logs/Tests
"""
```
**Ablauf:**
1. `catalog` aus Request oder `planning_roadmap.planning_catalog_context` (wie F13).
2. Pro Dimension aktives Item auflösen → `snippet_key` → Text aus Registry.
3. Snippets in **Prioritätsreihenfolge** §2 zu `_block` zusammenfügen (Überschriften: „Primärfokus“, „Trainingsstil“, …).
4. Fehlende Snippets: Dimension **weglassen** (kein Default-Text) — besser kein Snippet als falscher.
**Einbindung:** Orchestratoren rufen Builder auf und mergen in `variables` vor `load_and_render_ai_prompt`:
- `planning_exercise_path_qa.py``try_llm_qa_progression_path`
- `planning_progression_roadmap.py` (Roadmap-/Stage-Pipeline)
- `planning_exercise_path_builder.py` (catalog an QA/Match durchreichen)
`ProgressionPathSuggestRequest` trägt `planning_catalog_context` bereits — kein neues API-Feld nötig.
---
## 6. Beispiel-Snippets (Review-Entwurf)
### 6.1 Primärfokus — Gewaltschutz (`focus:gewaltschutz`)
**planning_lens:** Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.
**qa_criteria:** Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.
**anti_patterns:** Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.
### 6.2 Primärfokus — Technik / Kumite-Beinarbeit (`focus:kumite` o. ä.)
**planning_lens:** Curriculum für eine Technik oder Kumite-Teilaspekt; aufeinander aufbauende Belastung und Anwendungsnähe sind erwünscht.
**qa_criteria:** Kohärente Progression Grundlagen → Anwendung → Vertiefung; Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten.
### 6.3 Trainingsstil — Breitensport (`training_type:breitensport` o. Name-Match)
**planning_lens:** Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung.
**qa_criteria:** Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; „Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke.
**rematch_guard:** Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.
### 6.4 Zielgruppe — Leistungsgruppe (`target_group:leistungsgruppe`)
**qa_criteria:** Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; Lücken in Spezialisierung können echte Hinweise sein.
*(Weitere Snippets iterativ ergänzen — nicht alle Katalog-Zeilen sofort.)*
---
## 7. Rollout-Phasen
### H1 — Minimal viable (Progressionsgraph)
- [ ] Modul `planning_catalog_prompt_snippets.py` + Registry (58 Snippets: 23 Foki, 2 Trainingsstile, 2 Zielgruppen)
- [ ] Einbindung in **`planning_exercise_path_qa`** + **`planning_progression_roadmap`** + **`planning_progression_stage_spec`**
- [ ] Migration Prompt-Templates: Abschnitt `{{#has_catalog_guidance}}…{{/has_catalog_guidance}}`
- [ ] Tests: gleicher Pfad + unterschiedlicher Katalog → unterschiedlicher `catalog_guidance_block`; Snapshot QA-Variablen
- [ ] Dev-Regression: Gewaltschutz, Breitensport, Kinder — **Hinweistexte** müssen zum Kontext passen (nicht Mae-Geri-Kriterien)
### H1.5
- [ ] Rematch/Refine: `rematch_guard` aus Snippets respektieren (weniger Ping-Pong bei Breitensport)
- [ ] Intent-Prompts + Gap-Fill-Kontext
### H2 — Betrieb
- [ ] Snippets in DB, Admin-UI oder Markdown-Import
- [ ] Versionierung / Audit wie `ai_prompts`
### H3 — Phase G (Trainingsplanung)
- [ ] Gleicher Builder, anderer Orchestrator (Abschnitts-QS, Slot-Suggest)
---
## 8. Tests & Akzeptanz
| Test | Erwartung |
|------|-----------|
| `test_catalog_prompt_snippets_priority` | Bei Konflikt gewinnt Fokus-Snippet-Text in `_block`-Reihenfolge |
| `test_path_qa_variables_include_guidance` | Mit Gewaltschutz-Kontext enthält gerendeter Prompt „Deeskalation“ o. ä., nicht „Kumite-Perfektion“ |
| `test_path_qa_no_snippet_without_catalog` | Ohne Katalog: `has_catalog_guidance=false`, Prompt unverändert wie heute |
| Manuell | Mae-Geri-Pfad + Breitensport-Kontext: QS-Highlights ohne Leistungs-Belastungs-Forderungen |
**Nicht Ziel von H1:** Retrieval-Gewichte neu kalibrieren; Technik-Tuples externalisieren (separates Backlog **H** in Roadmap).
---
## 9. Abgrenzung zu anderen Fixes
| Thema | Dokument / Fix |
|-------|----------------|
| 88% vs. 65% falsche Pipeline | Evaluate-only für Pfad-QS; fairer Compare — Code-Stand 2026-05-22 |
| Triviale ID-Tausche im Dialog | `_filter_trivial_slot_diffs` |
| Katalog nur im Retrieval | F13 — bleibt, Snippets ergänzen LLM-Schicht |
Snippets lösen **fachliche Fehlbewertung** — nicht Pipeline-Inkonsistenz allein.
---
## 10. Changelog
| Datum | Änderung |
|-------|----------|
| 2026-05-22 | Erstfassung — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung; H1H3 Rollout |

View File

@ -1,7 +1,7 @@
# Planungs-KI — Produkt-Roadmap # Planungs-KI — Produkt-Roadmap
**Stand:** 2026-05-22 **Stand:** 2026-05-22
**App-Version:** **0.8.217** — maßgeblich `backend/version.py` **App-Version:** **0.8.233** — maßgeblich `backend/version.py`
Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**. Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**.
@ -13,9 +13,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
## Strategische Entscheidung (verbindlich) ## Strategische Entscheidung (verbindlich)
1. **Progressionsgraph:** Planung **vom Ziel rückwärts** (Roadmap-first), nicht Bibliothek-first. 1. **Progressionsgraph:** Planung **vom Ziel rückwärts** (Roadmap-first), nicht Bibliothek-first.
2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Thema, Schrittanzahl, optional Graph-Kanten. 2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Katalog-Dimensionen, Start/Ziel, Roadmap, optional Graph-Kanten.
3. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline später, **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0S4. 3. **Drei Schichten statt monolithischem Vokabular:** Katalog (DB) · Technik-Disambiguierung (Code, nur bei Technik-Themen) · Didaktik (Roadmap + LLM-QS).
4. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`); Mitai Workflow-Engine **später**, wenn 23 Pipelines stabil sind. 4. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline (Phase G), **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0S4 und Ist-Doku §16.
5. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`, `planning_exercise_path_builder.py`); Mitai Workflow-Engine **später**, wenn Phase G stabil ist.
--- ---
@ -27,84 +28,152 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
| AC2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ | | AC2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ | | C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
| EE3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ | | EE3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
| **F0F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap-first, UI Review | ✅ **0.8.204209** | | **F0F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap_first, UI Review | ✅ **0.8.204209** |
| **F5F9** | Progressionsgraph | Start/Ziel, Gap-Prep, Skill-Expectations, Persistenz | ✅ **0.8.210217** | | **F5F9** | Progressionsgraph | Start/Ziel, Gap-Prep, Skill-Expectations, Persistenz | ✅ **0.8.210217** |
| **F10** | Progressionsgraph | Stufen-Lernziel-Gate, kein blindes Rank-Fallback | ✅ **0.8.218** |
| **F11F12** | Progressionsgraph | Auto-Rematch, Spec-Refine, QS-Pipeline-Timing | ✅ **0.8.2250.8.232** |
| **F13F14** | Progressionsgraph | Katalog-Kontext + GraphEditor-Workbench | ✅ **0.8.233** |
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** | | D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
| **UX** | Progressionsgraph | Wizard/Stepper statt Scroll-UI | 🔲 | | **UX** | Progressionsgraph | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 |
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0S4 | 🔲 | | **D** | Progressionsgraph | Auto KI-Gap-Fill bei persistent leeren Slots | 🔲 Backlog |
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog | | **G** | Trainingsplanung | Kontext-Pack Gruppe/Historie, G0G4 | 🔲 |
| **H** | Plattform | Technik-Katalog konfigurierbar; Mitai-Workflow | 🔲 Backlog |
--- ---
## Phase F — Progressions-Roadmap (aktiver Fokus) ## Phase F — Progressions-Roadmap (abgeschlossen bis F14)
### F0 — Foundation (0.8.204) Details und Module: **`PLANNING_PROGRESSION_GRAPH_KI.md`**.
- [x] Spec `PLANNING_PROGRESSION_ROADMAP_SPEC.md` ### F0F9 — (Kurz, siehe Ist-Doku)
- [x] Modul `planning_progression_roadmap.py` (Pydantic, Pipeline-Skeleton)
- [x] Migration **078** Prompt-Slugs (Zielanalyse, Roadmap)
- [x] API: `include_roadmap_preview` auf `progression-path-suggest`
- [x] Doku: HANDOVER, PLANNING_EXERCISE_SUGGEST_CONTEXT, MULTISTAGE_FORECAST
### F1 — Deterministische Roadmap - [x] F0 Foundation (0.8.204) — Spec, Pipeline-Skeleton, Prompts 078
- [x] F1 Deterministische Roadmap — Phase A/B/C heuristisch
- [x] F2 LLM Roadmap (0.8.205) — Prompts 078/079
- [x] F3 roadmap-first (0.8.206) — Match pro `stage_spec`, `roadmap_unfilled`
- [x] F4 UI Review (0.8.207) — `roadmap_override`, Major Steps editierbar
- [x] F5 Start/Ziel (0.8.210214) — Prompt **087**, Zwei-Schritt-UI
- [x] F6 Gap-KI-Kontext (0.8.212214) — `ExerciseGapFillPrepModal`
- [x] F7 Fähigkeiten-Scoring (0.8.215216) — `planning_skill_expectations`
- [x] F8 Stufen-Details UI (0.8.216) — editierbare `stage_specs`
- [x] F9 Persistenz (0.8.217) — Migration **088** `planning_roadmap` JSONB
- [x] Phase A aus Semantic Brief ### F10 — Stufen-Qualität (0.8.218)
- [x] Phase B: `micro_objectives` aus `development_arc` + Konsolidierung auf N
- [x] Phase C: heuristische `stage_specs`
- [ ] pytest für Konsolidierung
### F2 — LLM Roadmap (0.8.205) - [x] Stufen-Lernziel-Gate — kein Rank-Fallback ohne Pass
- [x] Anti-Pattern-Sanitizer, `stage_mismatch` → leerer Slot + Gap
- [x] Prompts **078/079** in `ai_prompts` — Code nur Slugs (`PROMPT_SLUG_*`) ### F11 — Auto-Optimierung (0.8.2250.8.230)
- [x] `include_llm_roadmap` + `load_and_render_ai_prompt` + JSON-Validierung
- [x] Deterministischer Fallback wenn Prompt/OpenRouter fehlt
- [ ] Response/UI: genutzte `prompt_slugs` sichtbar machen (Admin-Hinweis)
### F3 — roadmap-first (0.8.206) - [x] `planning_path_rematch.py` — Rematch-Schleife für `rematch_slot` / `roadmap_unfilled`
- [x] `planning_path_refine_stage.py` — Spec-Schärfung aus QS
- [x] `planning_path_qa_pipeline.py` — mehrstufige QS
- [x] Retrieval pro `major_step` + `stage_spec` statt iterativem Pfad-Bau ### F12 — Pipeline-Timing & Sync (0.8.2310.8.232)
- [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`)
- [x] QA/Lücken an Roadmap gekoppelt (`roadmap_first_lite`: keine Brücken/Reorder zwischen Major Steps)
### F4 — UI (0.8.207) - [x] Post-Match-Gate vor Rematch-Akzeptanz
- [x] LLM Pfad-QS **nach** Rematch
- [x] Gap-Offers vor `path_qa`-Summary
- [x] Frontend: `applyMatchStepsToSlots` sync per `majorStepIndex`
- [x] Roadmap-Review im `ExerciseProgressionPathBuilder` ### F13 — Katalog-Kontext (0.8.233)
- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
- [x] API `roadmap_only` + `roadmap_override`
### F5 — Start/Ziel (0.8.210214) - [x] `planning_catalog_context.py` — Fokus, Stil, Trainingsstil, Zielgruppe
- [x] Merge in `PlanningTargetProfile` + Text-Signale
- [x] Persistenz im Graph-Artefakt
- [x] Technik-Gates nur bei `topic_type == "technique"`
- [x] Strukturierte Felder `start_situation`, `target_state`, `roadmap_notes` ### F14 — GraphEditor Workbench (0.8.233)
- [x] Prompt **087** `planning_progression_start_target`
- [x] Priorität: Trainer > KI > Regex (`resolve_roadmap_structured_input`)
- [x] Zwei-Schritt-UI: „Start/Ziel analysieren“ / „Roadmap vorschlagen“
### F6 — Gap-KI-Kontext (0.8.212214) - [x] `ProgressionGraphEditor` — primäre UI für Roadmap + Match + Lücken
- [x] Vier Planungskontext-Dropdowns im Editor
- [x] `progressionGraphDraft.js` — Artefakt + API-Payload
- [x] `ExerciseGapFillPrepModal` vor KI-Call ### Validierung (Referenz Mae Geri, 2026-05)
- [x] `planning_exercise_form_context.py` — Gap-Snapshot, `context_preview`
- [x] Migration **085**`planning_context` in Übungs-Prompts
### F7 — Fähigkeiten-Scoring (0.8.215216) | Phase | Pfad-QS | Ergebnis |
|-------|---------|----------|
| Vor Roadmap/KI | ~65 % | Lücken, falsche Reihenfolge, Off-Topic |
| Nach Trainer-Roadmap + KI-Gap-Fill | **~88 % OK** | Vollständige Abdeckung; positive LLM-Hinweise |
- [x] `planning_skill_expectations.py` (Scopes: `progression_stage`, `progression_path`) **Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht.
- [x] Pro-Stufe-Retrieval + `path_skill_expectations` + UI-Tags
- [x] `expected_skills` in Gap-Fill
### F8 — Stufen-Details UI (0.8.216) ---
- [x] Editierbare `stage_specs` in `roadmap_override` (Belastung, Erfolgskriterien, Vermeiden) ## UX — UI-Überarbeitung (offen)
### F9 — Persistenz (0.8.217) - [ ] Wizard mit 4 Schritten (Ziel & Katalog → Roadmap → Match → Lücken)
- [ ] Progressive disclosure — Details in Panels
- [ ] PathBuilder-Parität: gleiche Katalog-Dropdowns wie GraphEditor
- [ ] QS-UI: positive LLM-Hinweise als Highlights
- Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §12
- [x] Migration **088**`planning_roadmap` JSONB am Graph ---
- [x] Laden/Speichern über `GET/PUT` Graph + Sequenz-Endpoint
### UX — UI-Überarbeitung (offen) ## Phase D — Auto Gap-Fill (Backlog)
- [ ] Wizard mit 4 Schritten (Ziel → Roadmap → Match → Lücken) - [ ] Bei persistent `roadmap_unfilled` automatisch KI-Vorschlag vorbereiten (ohne manuelles Modal)
- [ ] Progressive disclosure — Details in Panels, nicht alles gleichzeitig - [ ] Governance: Trainer bestätigt vor Persistenz
- [ ] Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §10
---
## Phase G — Trainingsplanung (komplexere Domäne)
**Ziel:** Einheiten, Rahmen-Slots, Abschnitte und parallele Streams KI-gestützt planen — **ohne** zweite Retrieval-Welt.
### Wiederverwendung aus Progressionsgraph
| Baustein | Progressionsgraph | Trainingsplanung |
|----------|-------------------|------------------|
| `PlanningTargetProfile` | Curriculum-Query + Katalog | Einheit + Abschnitt + Slot + Katalog + Historie |
| `planning_catalog_context` | Am Graph gespeichert | Pro Einheit / Slot / Voreinstellung |
| `planning_skill_expectations` | `progression_stage`, `progression_path` | **`training_section`**, **`framework_slot`** |
| `planning_exercise_retrieval` | Roadmap-Stufen-Match | `suggest_planning_exercises`**bereits produktiv** |
| `planning_path_qa_pipeline` | Curriculum-QS | Abschnitts-QS (Kohärenz, Streams) |
| `planning_exercise_form_context` | Pfad-Lücken | Abschnitts-/Slot-Lücken |
| Roadmap-Pipeline | Major Steps über Wochen | **Nicht 1:1** — Phasen/Streams + Vorlagen |
### Was Phase G neu braucht
- Gruppen-/Historie-Kontext-Pack (`AI_PLANNING_KI_MULTISTAGE_FORECAST` S0S4)
- Abschnitts-Didaktik — Dauer, Parallel-Streams, Coaching
- Rahmen-Blueprint-Anbindung (`training_framework_programs`, Slot-Blueprints)
- Eigene Orchestrierung pro Einheit (kein Curriculum über N Wochen)
### Integrations-Reihenfolge G0G4
| Schritt | Inhalt | Abhängigkeit |
|---------|--------|--------------|
| **G0** | Katalog in Einheits-Editor → bestehende Suggest-Pipeline | F13 ✅ |
| **G1** | Scope `training_section` + Skill-Erwartungen aktiv | F7 ✅ |
| **G2** | Abschnitts-QS (Hint-Struktur wie Graph) | F11F12 ✅ |
| **G3** | Framework-Slot + Gap-Fill | G0, G1 |
| **G4** | Gruppenkontext-Pack | G0G3 |
**Nicht:** Roadmap-first-Loop 1:1 auf Trainingseinheit mappen.
Details: **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16 · Domäne **`DOMAIN_MODEL.md`** §12.
---
## Phase H1 — Katalog-Prompt-Snippets (Spez geplant)
**Spec:** **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
Modulare Textbausteine pro Katalog-Ausprägung in LLM-Prompts (Roadmap, Pfad-QS, Stufen-Spec) — **nicht** neue Retrieval-Welt.
**Priorität (absteigend):** Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung.
- [ ] `planning_catalog_prompt_snippets.py` + Registry (58 Snippets)
- [ ] Platzhalter `{{catalog_guidance_block}}` in Pfad-QS + Roadmap-Prompts
- [ ] Dev-Regression: Gewaltschutz / Breitensport / Kinder — QS-Hinweise passend zum Kontext
---
## Phase H — Plattform (Backlog)
- [ ] Technik-Disambiguierung konfigurierbar (DB statt `_GERI_TECHNIQUES` in Code)
- [ ] Mitai Workflow-Engine — erst wenn G0G4 stabil
--- ---
@ -112,10 +181,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
| Von | Nach | Hinweis | | Von | Nach | Hinweis |
|-----|------|---------| |-----|------|---------|
| F2 | Enrichment / Skills | Bessere Roadmap bei technikspezifischen Skills | | F13 | G0, **H1** | Katalog-Kontext in Einheitsplanung; Snippets in LLM-Prompts |
| F3 | F2 | LLM-Roadmap oder stabile heuristische B | | F7, F11 | G1, G2 | Skill-Expectations + QS-Muster |
| G | F4 | Trainingsplanung kann Roadmap aus Graph referenzieren | | F4, F9 | G3 | Graph-Roadmap kann Rahmen referenzieren |
| H | G + F4 | Workflow-Engine lohnt bei verzweigten Planungsflows | | G | H | Workflow-Engine lohnt bei verzweigten Planungsflows |
--- ---

View File

@ -1,7 +1,7 @@
# Progressionsgraph — KI-Planung (Ist-Stand) # Progressionsgraph — KI-Planung (Ist-Stand)
**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.218** · **DB:** Migration **088** **Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.233** · **DB:** Migration **088**
**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`) **Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS.planning_exercise_suggest`)
> **Diese Datei ist die zentrale Referenz** für die KI-gestützte Planung im Progressionsgraph. > **Diese Datei ist die zentrale Referenz** für die KI-gestützte Planung im Progressionsgraph.
> Ältere Abschnitte in `HANDOVER.md` §2.8 und `PLANNING_KI_ROADMAP.md` verweisen hierher. > Ältere Abschnitte in `HANDOVER.md` §2.8 und `PLANNING_KI_ROADMAP.md` verweisen hierher.
@ -10,6 +10,7 @@
`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` (Zielarchitektur) · `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` (Zielarchitektur) ·
`.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` (Retrieval/Scoring) · `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` (Retrieval/Scoring) ·
`.claude/docs/technical/SKILL_SCORING_SPEC.md` (Fähigkeiten-Scoring) · `.claude/docs/technical/SKILL_SCORING_SPEC.md` (Fähigkeiten-Scoring) ·
**`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (H1 — modulare Katalog-Prompts) ·
`docs/architecture/PLANNING_KI_ROADMAP.md` (Produkt-Roadmap Phase G+) `docs/architecture/PLANNING_KI_ROADMAP.md` (Produkt-Roadmap Phase G+)
--- ---
@ -30,19 +31,20 @@
## 2. Trainer-Workflow (UI) ## 2. Trainer-Workflow (UI)
Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgressionGraphPanel.jsx`): **Primär:** `ProgressionGraphEditor.jsx` (integrierter Slot-Editor, Phase B).
**Legacy/Parallel:** `ExerciseProgressionPathBuilder.jsx` (Scroll-Monolith — gleiche API, Katalog-Kontext-Dropdowns dort noch nachziehen).
``` ```
① Ziel eingeben (+ optional Start/Ziel-Felder manuell) ① Ziel eingeben (+ Planungskontext: Primärfokus, Stil, Trainingsstil, Zielgruppe)
„Start/Ziel analysieren“ (optional, start_target_only) Optional: Start/Ziel-Felder manuell oder „Start/Ziel analysieren“
③ „Roadmap vorschlagen“ (roadmap_only, LLM-Roadmap) ③ „Roadmap generieren“ (roadmap_only, LLM-Roadmap)
④ Roadmap bearbeiten (Major Steps + Stufen-Details) ④ Roadmap bearbeiten (Major Steps + Stufen-Details)
⑤ „Übungen matchen“ (roadmap_first + roadmap_override) ⑤ „Übungen matchen“ (roadmap_first + roadmap_override + Auto-QS/Rematch)
⑥ Lücken mit KI schließen (gap_fill_offers + Vorbereitungs-Dialog) ⑥ Lücken: KI-Angebote → „KI anlegen“ (Gap-Prep-Modal) → in Slot
⑦ „Pfad in Graph speichern“ (Sequenz-Kanten) ⑦ „Graph speichern“ (planning_roadmap + optional Kanten-Sequenz)
``` ```
**Bekannte UX-Schuld:** Alle Schritte liegen auf **einer langen Scroll-Seite** — Überarbeitung als Wizard/Stepper ist geplant (separater UI-Chat). Briefing-Vorlage siehe unten §10. **Bekannte UX-Schuld:** PathBuilder = lange Scroll-Seite; GraphEditor = kompakter, aber noch kein Wizard. Stepper geplant (separater UI-Chat). Briefing §12.
--- ---
@ -51,6 +53,7 @@ Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgres
```mermaid ```mermaid
flowchart TB flowchart TB
subgraph ui [Frontend] subgraph ui [Frontend]
PGE[ProgressionGraphEditor]
EPB[ExerciseProgressionPathBuilder] EPB[ExerciseProgressionPathBuilder]
GFM[ExerciseGapFillPrepModal] GFM[ExerciseGapFillPrepModal]
PCtx[planningContextForExerciseAi.js] PCtx[planningContextForExerciseAi.js]
@ -71,6 +74,10 @@ flowchart TB
subgraph match [Match + QA] subgraph match [Match + QA]
PB[planning_exercise_path_builder.py] PB[planning_exercise_path_builder.py]
PCC[planning_catalog_context.py]
REM[planning_path_rematch.py]
REF[planning_path_refine_stage.py]
QAP[planning_path_qa_pipeline.py]
RET[planning_exercise_retrieval.py] RET[planning_exercise_retrieval.py]
PG[planning_exercise_progression.py] PG[planning_exercise_progression.py]
SEM[planning_exercise_semantics.py] SEM[planning_exercise_semantics.py]
@ -88,11 +95,16 @@ flowchart TB
end end
EPB --> PPS EPB --> PPS
EPB --> SEQ PGE --> PPS
EPB --> PUT PGE --> SEQ
PGE --> PUT
GFM --> EAI GFM --> EAI
PPS --> PR PPS --> PR
PPS --> PB PPS --> PB
PB --> PCC
PB --> REM
PB --> REF
PB --> QAP
PB --> RET PB --> RET
PB --> PG PB --> PG
PB --> PSE PB --> PSE
@ -108,12 +120,18 @@ flowchart TB
| Modul | Aufgabe | | Modul | Aufgabe |
|--------|---------| |--------|---------|
| `planning_progression_roadmap.py` | Phasen AC: Zielanalyse, Roadmap, `stage_specs`; Start/Ziel-Auflösung (Trainer > KI > Regex) | | `planning_progression_roadmap.py` | Phasen AC: Zielanalyse, Roadmap, `stage_specs`; Start/Ziel-Auflösung (Trainer > KI > Regex) |
| `planning_exercise_path_builder.py` | `suggest_progression_path`: roadmap_first Match, QA, Gap-Offers | | `planning_exercise_path_builder.py` | `suggest_progression_path`: roadmap_first Match, Auto-QS, Rematch, Gap-Offers |
| `planning_catalog_context.py` | **Expliziter Katalog-Kontext** (Fokus, Stil, Trainingsstil, Zielgruppe) → `PlanningTargetProfile` |
| `planning_path_rematch.py` | Auto-Rematch betroffener Slots (`max_rematch_rounds`) |
| `planning_path_refine_stage.py` | Stufen-Spec-Verfeinerung bei `stage_mismatch` (Phase C) |
| `planning_path_qa_pipeline.py` | Mehrstufige QS → `optimization_hints` |
| `planning_exercise_progression.py` | Graph auflösen, Nachfolger-Kanten für Retrieval-Bias | | `planning_exercise_progression.py` | Graph auflösen, Nachfolger-Kanten für Retrieval-Bias |
| `planning_skill_expectations.py` | Skill-Erwartungen pro Scope (`progression_stage`, `progression_path`, später `training_section`) | | `planning_skill_expectations.py` | Skill-Erwartungen pro Scope (`progression_stage`, `progression_path`, später `training_section`) |
| `planning_exercise_form_context.py` | `planning_context` / Gap-Snapshot für Übungs-KI | | `planning_exercise_form_context.py` | `planning_context` / Gap-Snapshot für Übungs-KI |
| `planning_exercise_path_ai_fill.py` | Gap-Fill-Angebote, `goal_for_ai`, `context_preview` | | `planning_exercise_path_ai_fill.py` | Gap-Fill-Angebote, `goal_for_ai`, `context_preview` |
| `progression_graph_planning_artifact.py` | Validierung `planning_roadmap` JSON (Schema v1, max. 64 KB) | | `progression_graph_planning_artifact.py` | Validierung `planning_roadmap` JSON (Schema v1, max. 64 KB) |
| `planning_exercise_profiles.py` | **Katalog-Scoring** (Fokus/Stil/TT/ZG/Skills) — gemeinsam mit Einheitsplanung |
| `planning_exercise_target_pipeline.py` | Query-Intent-Pipeline — Progressionsgraph nutzt `query_only`-Modus + Katalog-Overlay |
--- ---
@ -131,10 +149,14 @@ flowchart TB
| `start_target_only` | bool | Nur Start/Ziel-Analyse | | `start_target_only` | bool | Nur Start/Ziel-Analyse |
| `roadmap_override` | object | Trainer-bearbeitete `major_steps` + `stage_specs` | | `roadmap_override` | object | Trainer-bearbeitete `major_steps` + `stage_specs` |
| `start_situation`, `target_state`, `roadmap_notes` | string? | Strukturierte Eingabe (Priorität vor KI) | | `start_situation`, `target_state`, `roadmap_notes` | string? | Strukturierte Eingabe (Priorität vor KI) |
| `planning_catalog_context` | object? | Primärfokus, Stilrichtung, Trainingsstil, Zielgruppe (IDs + `is_primary`) |
| `include_llm_start_target` | bool | LLM-Extraktion Start/Ziel (Prompt **087**) | | `include_llm_start_target` | bool | LLM-Extraktion Start/Ziel (Prompt **087**) |
| `include_llm_roadmap` | bool | LLM Roadmap (Prompts **078/079**) | | `include_llm_roadmap` | bool | LLM Roadmap (Prompts **078/079**) |
| `include_llm_intent` | bool | LLM Intent für Semantic Brief (Roadmap-Vorschlag: **true** seit 0.8.217) | | `include_llm_intent` | bool | LLM Intent für Semantic Brief |
| `include_path_qa`, `include_ai_gap_fill` | bool | QS, Lücken-Angebote | | `auto_rematch_after_qa` | bool | Auto-Rematch nach QS (Default **true**) |
| `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) |
| `max_rematch_rounds` | int | Rematch-Runden 04 (Default **3**) |
| `include_path_qa`, `include_llm_path_qa`, `include_ai_gap_fill` | bool | QS, LLM-Ganzpfad, Lücken-Angebote |
### 4.2 Wichtige Response-Felder ### 4.2 Wichtige Response-Felder
@ -144,7 +166,29 @@ flowchart TB
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` | | `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
| `path_skill_expectations` | Pfadweite Skill-Erwartungen | | `path_skill_expectations` | Pfadweite Skill-Erwartungen |
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` | | `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
| `path_qa` | QS inkl. `roadmap_qa_mode: roadmap_first_lite` | | `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log` |
| `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) |
| `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` |
---
## 4.4 Planungskontext — Katalog vs. Technik-Vokabular
Shinkan unterscheidet **drei Schichten** (kein monolithisches „Vokabular“):
| Schicht | Was | Wo | Beispiel |
|---------|-----|-----|----------|
| **Katalog-Dimensionen** | Was für Training? | DB: `focus_areas`, `style_directions`, `training_types`, `target_groups`, `skills` | Gewaltschutz, Breitensport, Shotokan |
| **Disambiguierung (Technik)** | Verwechslungs-Nachbarn | Code: `planning_exercise_semantics.py` (`_GERI_TECHNIQUES`, …) | Mae Geri ≠ Mawashi Geri |
| **Didaktik / Kausalität** | Reihenfolge, Lernphasen | Roadmap + LLM Pfad-QS | Grundlagen vor Geschwindigkeit |
**Seit 0.8.233:** `planning_catalog_context` im Request und im Graph-Artefakt (`planning_catalog_context` JSON). Fließt in `PlanningTargetProfile` → Hybrid-Retrieval (`score_exercise_against_target`: „Fokusbereich passend“, …). Zusätzlich additive Text-Signale aus Anfrage + Start/Ziel + Notizen (`planning_exercise_text_signals`).
**Geplant (H1):** dieselben Dimensionen als **kaskadierte Prompt-Snippets** in Roadmap-, Stufen-Spec- und Pfad-QS-Prompts — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung — siehe **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**.
**Technik-Gates** (`technique_scope`, Geschwister-Ausschluss) nur bei `topic_type == "technique"` — Fokus-Pfade (Gewaltschutz, Fitness, …) werden nicht wie Mae-Geri-Pfade behandelt.
Fallback: fehlt `planning_catalog_context` im Request, wird aus gespeichertem `planning_roadmap` am Graph geladen.
### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding) ### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding)
@ -157,17 +201,32 @@ flowchart TB
--- ---
## 5. Roadmap-Match — Stufen-Qualität (0.8.218) ## 5. Roadmap-Match — Stufen-Qualität (0.8.2180.8.233)
Pro Major Step gilt: Pro Major Step gilt:
1. **Stufen-Brief**`semantic_brief_for_stage()` ergänzt `must_phrases` um das Stufen-Lernziel. 1. **Stufen-Brief**`build_stage_match_brief()` aus Lernziel, `anti_patterns`, Erfolgskriterien, Pfad-Kontext.
2. **Stufen-Gate**`exercise_passes_stage_learning_goal_gate()` prüft Lernziel-Text in Titel/Summary/Ziel oder Mindest-`semantic_score`. 2. **Stufen-Gate**`exercise_passes_stage_fit()` / `exercise_passes_stage_learning_goal_gate()` auf vollem Übungstext.
3. **Kein Fallback** — Bei `roadmap_first` wird **nicht** auf die globale `goal_query` zurückgefallen; passt keine Übung → **Lücke** (`roadmap_unfilled`) statt themenfremder Übung. 3. **Kein blindes Rank-Fallback** — ohne Gate-Passung → `roadmap_unfilled`, nicht themenfremde Übung.
4. **Retrieval** — Bonus/Strafe im Hybrid-Score je nach Stufen-Passung. 4. **Post-Match-Gate**`_roadmap_step_passes_post_match_gate()` = gleiche QS wie `detect_off_topic_steps` (kein Rematch-Treffer, der sofort wieder `stage_mismatch` wäre).
5. **QS**`detect_off_topic_steps` erkennt `stage_mismatch` anhand `roadmap_learning_goal`. 5. **Retrieval** — Hybrid-Score: Volltext + Semantik + **Profil/Katalog** + Skill-Erwartungen + optional Graph-Bias.
6. **Auto-Optimierung (ein Match-Lauf):**
- **Phase B:** Rematch-Schleife (`planning_path_rematch.py`) für `rematch_slot` / `roadmap_unfilled`
- **Phase C:** `planning_path_refine_stage.py``anti_patterns` / Erfolgskriterien aus QS
- Purge persistent `stage_mismatch` → Slot leeren + KI-Gap
- LLM Pfad-QS **nach** Rematch auf finalem Pfad
- Gap-Offers für alle leeren Slots **vor** `path_qa`-Summary
Tests: `test_planning_roadmap_stage_match.py` Tests: `test_planning_roadmap_stage_match.py`, `test_planning_path_rematch.py`, `test_planning_path_refine_stage.py`, `test_planning_catalog_context.py`
### Referenz-Validierung (Mae Geri, 2026-05)
| Phase | Pfad-QS | Ergebnis |
|-------|---------|----------|
| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) |
| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen |
**Lesson:** Workbench + Katalog-Kontext + Roadmap sind der Hebel; Technik-Hardcoding allein reicht nicht für Didaktik.
--- ---
@ -209,7 +268,15 @@ Gespeichert am **Graph-Container** (`exercise_progression_graphs`), Schema v1:
"roadmap_notes": "…", "roadmap_notes": "…",
"max_steps": 5, "max_steps": 5,
"progression_roadmap": { }, "progression_roadmap": { },
"path_skill_expectations": { } "path_skill_expectations": { },
"planning_catalog_context": {
"focus_areas": [{ "id": 1, "is_primary": true }],
"style_directions": [],
"training_types": [{ "id": 2, "is_primary": true }],
"target_groups": []
},
"slot_contents": [ ],
"last_findings": { }
} }
``` ```
@ -307,26 +374,75 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
| F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215216 | | F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215216 |
| F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 | | F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 |
| F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 | | F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 |
| F10 | Stufen-Lernziel-Gate beim Match (kein goal_query-Fallback) | ✅ | 0.8.218 | | F10 | Stufen-Lernziel-Gate + kein goal_query-Fallback | ✅ | 0.8.218 |
| **G** | Trainingsplanung eigene Pipeline + Graph-Referenz | 🔲 | — | | **F11** | Auto-Rematch + Stufen-Spec-Refine + mehrstufige QS | ✅ | 0.8.2250.8.230 |
| **UX** | Wizard/Stepper statt Scroll-Monolith | 🔲 | separater Chat | | **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.2310.8.232 |
| **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** |
| **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 0.8.233 |
| **H1** | **Katalog-Prompt-Snippets** — modulare LLM-Anweisungen pro Dimension | 🔲 | Spec **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
| **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — |
| **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — |
| **H** | Technik-Disambiguierung konfigurierbar (DB statt Code-Tuples) | 🔲 | Backlog |
| **D** | Auto Gap-Fill (KI generiert bei persistent `roadmap_unfilled`) | 🔲 | Backlog |
--- ---
## 12. Offenes Backlog (priorisiert) ## 12. Offenes Backlog (priorisiert)
1. **UI-Überarbeitung** — Wizard mit 4 Schritten, progressive disclosure (Briefing unten) 1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
2. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz 2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren
3. **Kontext auf allen Pfad-Schritten** in UI (nicht nur Lücken) 2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
4. **Trainingsplanung Phase G** — Pipeline mit Gruppenkontext, Wiederverwendung `planning_skill_expectations` 3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“
5. Enrichment / Prompt-Feintuning 4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert
6. Mitai Workflow-Engine (langfristig) 5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
6. **Phase D** — automatisches KI-Gap-Fill bei persistent leeren Slots
7. **Trainingsplanung Phase G** — siehe §16
8. **Technik-Katalog externalisieren** — konfigurierbare `concept_groups` (Backlog)
9. Graph-Metadaten: Primärfokus/Stil als Spalten (Reporting)
10. Mitai Workflow-Engine (langfristig)
### Briefing-Vorlage UI-Chat (Copy-Paste) ### Briefing-Vorlage UI-Chat
Siehe Nutzer-Chat 2026-05-22 oder `HANDOVER.md` §2.8 — Abschnitt „UI-Überarbeitung“. Kern: Wizard ① Ziel & Planungskontext → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen.
Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen. ---
## 16. Wiederverwendung in der Trainingsplanung (Phase G)
Die **komplexere Trainingsplanung** (Einheit, Rahmen-Slot, Abschnitt, parallele Streams) soll **keine zweite Retrieval-Welt** bauen, sondern bestehende Module mit **anderem Kontext-Pack** nutzen.
### 16.1 Was Progressionsgraph liefert (Workbench-Muster)
| Baustein | Progressionsgraph | Trainingsplanung (Ziel) |
|----------|-------------------|-------------------------|
| `PlanningTargetProfile` | Query + Katalog + Skills | Einheit + Abschnitt + Slot + Katalog + Historie |
| `planning_catalog_context` | Am Graph gespeichert | Pro Einheit / Slot / Trainer-Voreinstellung |
| `planning_skill_expectations` | `progression_stage` / `progression_path` | **`training_section`**, **`framework_slot`** |
| `planning_exercise_retrieval` | Roadmap-Stufen-Match | Abschnitts-Suche (`suggest_planning_exercises`) — **produktiv** |
| `planning_path_qa_pipeline` | Curriculum-QS | Abschnitts-QS (Kohärenz, Streams) |
| `planning_intent_context` | Pfad-Ausschlüsse → Stufen | Abschnitts-Guidance → Brief |
| `planning_exercise_form_context` | Pfad-Lücken | Abschnitts-/Slot-Lücken |
| Roadmap-Pipeline | Curriculum Major Steps | **Nicht 1:1** — Phasen/Streams + Vorlagen |
| Technik-Disambiguierung | bei `topic_type=technique` | nur bei explizitem Technik-Abschnitt |
### 16.2 Was Phase G neu braucht
- **Gruppen-/Historie-Kontext-Pack** (`AI_PLANNING_KI_MULTISTAGE_FORECAST` S0S4)
- **Abschnitts-Didaktik** — Dauer, Parallel-Streams, Coaching (`training_unit_phases`)
- **Rahmen-Blueprint** — bereits `training_framework_programs` / Slot-Blueprints
- **Eigene Orchestrierung** pro Einheit — kein Curriculum über N Wochen
### 16.3 Integrations-Reihenfolge (Phase G)
1. **G0** — Katalog in Einheits-Editor → bestehende Suggest-Pipeline
2. **G1** — Scope `training_section` + Skill-Erwartungen aktiv
3. **G2** — Abschnitts-QS (Hint-Struktur wie Graph)
4. **G3** — Framework-Slot + Gap-Fill
5. **G4** — Gruppenkontext-Pack
**Nicht:** Roadmap-first-Loop 1:1 auf Trainingseinheit mappen.
Domänenbezug: **`DOMAIN_MODEL.md`** §12 (Katalog-Dimensionen).
--- ---
@ -342,6 +458,10 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken
| `test_planning_exercise_form_context.py` | `planning_context`, Gap-Snapshot | | `test_planning_exercise_form_context.py` | `planning_context`, Gap-Snapshot |
| `test_progression_graph_planning_artifact.py` | JSONB-Artefakt-Validierung | | `test_progression_graph_planning_artifact.py` | JSONB-Artefakt-Validierung |
| `test_planning_exercise_progression.py` | Graph-Auflösung, Nachfolger | | `test_planning_exercise_progression.py` | Graph-Auflösung, Nachfolger |
| `test_planning_path_rematch.py` | Auto-Rematch, unfilled-Platzhalter |
| `test_planning_path_refine_stage.py` | Stufen-Spec-Refine |
| `test_planning_stage_anti_patterns.py` | Anti-Pattern-Sanitizer, Stufen-Gate |
| `test_planning_catalog_context.py` | Katalog-Kontext → Target-Profil |
--- ---
@ -366,3 +486,4 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken
| Datum | Änderung | | Datum | Änderung |
|-------|----------| |-------|----------|
| 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5F9 | | 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5F9 |
| 2026-05-22 | F11F14: Auto-Optimierung, Katalog-Kontext, GraphEditor, Mae-Geri-Validierung, Phase-G-Wiederverwendung §16 |

View File

@ -7,6 +7,19 @@ import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal' import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import PlanningCatalogContextFields from './PlanningCatalogContextFields'
import {
EMPTY_PLANNING_CATALOG_CONTEXT,
parsePlanningCatalogContextFromArtifact,
planningCatalogContextToApi,
pathQaQualityPercent,
pathQaShowsStrongResult,
setCatalogSelectItems,
splitPathQaHints,
draftHasLibrarySlotAssignments,
slotsToSlotAssignments,
draftRetrievalBoostExerciseIds,
} from '../utils/progressionGraphDraft'
import { import {
aiPreviewToQuickCreateDraft, aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview, buildQuickCreateAiPreview,
@ -449,9 +462,13 @@ function buildPlanningRoadmapArtifactSnapshot({
maxSteps, maxSteps,
progressionRoadmap, progressionRoadmap,
pathSkillExpectations, pathSkillExpectations,
planningCatalogContext,
}) { }) {
const q = (goalQuery || '').trim() const q = (goalQuery || '').trim()
if (!q && !progressionRoadmap) return null if (!q && !progressionRoadmap) return null
const catalogPayload = planningCatalogContextToApi(
planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT,
)
return { return {
schema_version: PLANNING_ARTIFACT_SCHEMA, schema_version: PLANNING_ARTIFACT_SCHEMA,
goal_query: q, goal_query: q,
@ -461,6 +478,9 @@ function buildPlanningRoadmapArtifactSnapshot({
max_steps: Number(maxSteps) || 5, max_steps: Number(maxSteps) || 5,
progression_roadmap: progressionRoadmap || null, progression_roadmap: progressionRoadmap || null,
path_skill_expectations: pathSkillExpectations || null, path_skill_expectations: pathSkillExpectations || null,
...(catalogPayload.planning_catalog_context
? { planning_catalog_context: catalogPayload.planning_catalog_context }
: {}),
} }
} }
@ -544,6 +564,12 @@ export default function ExerciseProgressionPathBuilder({
const [startTargetAnalyzed, setStartTargetAnalyzed] = useState(false) const [startTargetAnalyzed, setStartTargetAnalyzed] = useState(false)
const loading = loadingRoadmap || loadingStartTarget || loadingMatch const loading = loadingRoadmap || loadingStartTarget || loadingMatch
const [focusAreas, setFocusAreas] = useState([]) const [focusAreas, setFocusAreas] = useState([])
const [styleDirections, setStyleDirections] = useState([])
const [trainingTypes, setTrainingTypes] = useState([])
const [targetGroups, setTargetGroups] = useState([])
const [planningCatalogContext, setPlanningCatalogContext] = useState(() => ({
...EMPTY_PLANNING_CATALOG_CONTEXT,
}))
const [skillsCatalog, setSkillsCatalog] = useState([]) const [skillsCatalog, setSkillsCatalog] = useState([])
const [generatingOfferId, setGeneratingOfferId] = useState(null) const [generatingOfferId, setGeneratingOfferId] = useState(null)
@ -571,6 +597,22 @@ export default function ExerciseProgressionPathBuilder({
[editableMajorSteps, pathSteps], [editableMajorSteps, pathSteps],
) )
const catalogApiPayload = useMemo(
() => planningCatalogContextToApi(planningCatalogContext),
[planningCatalogContext],
)
const pathQaSplit = useMemo(() => splitPathQaHints(pathQa), [pathQa])
const pathQaHighlights = pathQaSplit.highlightTexts
const pathQaFixHints = pathQaSplit.fixHints
const patchCatalogDimension = useCallback((key, value) => {
setPlanningCatalogContext((prev) => ({
...prev,
[key]: setCatalogSelectItems(prev?.[key], value),
}))
}, [])
const buildPlanningArtifact = useCallback( const buildPlanningArtifact = useCallback(
() => () =>
buildPlanningRoadmapArtifactSnapshot({ buildPlanningRoadmapArtifactSnapshot({
@ -581,6 +623,7 @@ export default function ExerciseProgressionPathBuilder({
maxSteps, maxSteps,
progressionRoadmap, progressionRoadmap,
pathSkillExpectations, pathSkillExpectations,
planningCatalogContext,
}), }),
[ [
goalQuery, goalQuery,
@ -590,6 +633,7 @@ export default function ExerciseProgressionPathBuilder({
maxSteps, maxSteps,
progressionRoadmap, progressionRoadmap,
pathSkillExpectations, pathSkillExpectations,
planningCatalogContext,
], ],
) )
@ -634,6 +678,9 @@ export default function ExerciseProgressionPathBuilder({
if (art.roadmap_notes) setRoadmapNotes(String(art.roadmap_notes)) if (art.roadmap_notes) setRoadmapNotes(String(art.roadmap_notes))
if (art.max_steps) setMaxSteps(Number(art.max_steps)) if (art.max_steps) setMaxSteps(Number(art.max_steps))
if (art.path_skill_expectations) setPathSkillExpectations(art.path_skill_expectations) if (art.path_skill_expectations) setPathSkillExpectations(art.path_skill_expectations)
if (art.planning_catalog_context) {
setPlanningCatalogContext(parsePlanningCatalogContextFromArtifact(art))
}
if (art.progression_roadmap) { if (art.progression_roadmap) {
setProgressionRoadmap(art.progression_roadmap) setProgressionRoadmap(art.progression_roadmap)
const majors = mapMajorStepsFromApi(art.progression_roadmap) const majors = mapMajorStepsFromApi(art.progression_roadmap)
@ -670,16 +717,25 @@ export default function ExerciseProgressionPathBuilder({
let cancelled = false let cancelled = false
Promise.all([ Promise.all([
api.listFocusAreas({ status: 'active' }), api.listFocusAreas({ status: 'active' }),
api.listStyleDirections({ status: 'active' }),
api.listTrainingTypes({ status: 'active' }),
api.listTargetGroups({ status: 'active' }),
api.listSkillsCatalog({ status: 'active' }), api.listSkillsCatalog({ status: 'active' }),
]) ])
.then(([fa, sk]) => { .then(([fa, sd, tt, tg, sk]) => {
if (cancelled) return if (cancelled) return
setFocusAreas(Array.isArray(fa) ? fa : []) setFocusAreas(Array.isArray(fa) ? fa : [])
setStyleDirections(Array.isArray(sd) ? sd : [])
setTrainingTypes(Array.isArray(tt) ? tt : [])
setTargetGroups(Array.isArray(tg) ? tg : [])
setSkillsCatalog(Array.isArray(sk) ? sk : []) setSkillsCatalog(Array.isArray(sk) ? sk : [])
}) })
.catch(() => { .catch(() => {
if (!cancelled) { if (!cancelled) {
setFocusAreas([]) setFocusAreas([])
setStyleDirections([])
setTrainingTypes([])
setTargetGroups([])
setSkillsCatalog([]) setSkillsCatalog([])
} }
}) })
@ -1095,6 +1151,7 @@ export default function ExerciseProgressionPathBuilder({
start_target_only: true, start_target_only: true,
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
...catalogApiPayload,
}) })
applyStartTargetResponse(res) applyStartTargetResponse(res)
} catch (e) { } catch (e) {
@ -1133,6 +1190,7 @@ export default function ExerciseProgressionPathBuilder({
roadmap_only: true, roadmap_only: true,
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
...catalogApiPayload,
}) })
const majors = mapMajorStepsFromApi(res?.progression_roadmap) const majors = mapMajorStepsFromApi(res?.progression_roadmap)
if (majors.length < 2) { if (majors.length < 2) {
@ -1190,6 +1248,22 @@ export default function ExerciseProgressionPathBuilder({
setError('') setError('')
try { try {
const override = majorStepsToOverridePayload(validSteps) const override = majorStepsToOverridePayload(validSteps)
const preserveAssignments = draftHasLibrarySlotAssignments({
slots: validSteps.map((s, i) => ({
majorStepIndex: i,
phase: s.phase,
learning_goal: s.learning_goal,
primary:
pathSteps[i]?.exerciseId != null
? {
kind: 'library',
exerciseId: pathSteps[i].exerciseId,
exerciseTitle: pathSteps[i].exerciseTitle,
variantId: pathSteps[i].variantId,
}
: { kind: 'empty' },
})),
})
const res = await api.suggestProgressionPath({ const res = await api.suggestProgressionPath({
query: q, query: q,
max_steps: validSteps.length, max_steps: validSteps.length,
@ -1202,8 +1276,24 @@ export default function ExerciseProgressionPathBuilder({
include_llm_roadmap: false, include_llm_roadmap: false,
roadmap_first: true, roadmap_first: true,
roadmap_override: override, roadmap_override: override,
preserve_slot_assignments: preserveAssignments,
slot_assignments: pathSteps
.map((row, i) => {
if (row.exerciseId == null) return null
return {
exercise_id: row.exerciseId,
variant_id: row.variantId || null,
title: row.exerciseTitle || null,
is_ai_proposal: false,
roadmap_major_step_index: i,
roadmap_phase: validSteps[i]?.phase || null,
roadmap_learning_goal: validSteps[i]?.learning_goal || null,
}
})
.filter(Boolean),
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
...catalogApiPayload,
}) })
applyPathMatchResponse(res, q) applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length) setMaxSteps(validSteps.length)
@ -1406,6 +1496,16 @@ export default function ExerciseProgressionPathBuilder({
/> />
</div> </div>
</div> </div>
<PlanningCatalogContextFields
catalogCtx={planningCatalogContext}
onPatchDimension={patchCatalogDimension}
focusAreas={focusAreas}
styleDirections={styleDirections}
trainingTypes={trainingTypes}
targetGroups={targetGroups}
disabled={disabled || loading || saving}
helperText="Planungskontext steuert Bibliotheks-Matching — wird mit dem Graph gespeichert."
/>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.4 }}> <p style={{ fontSize: '11px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.4 }}>
Optional zuerst Start/Ziel analysieren, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer, Optional zuerst Start/Ziel analysieren, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang. geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang.
@ -1826,11 +1926,40 @@ export default function ExerciseProgressionPathBuilder({
> >
<strong> <strong>
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{pathQa.quality_score != null ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)` : ''} {pathQaQualityPercent(pathQa) != null ? ` (${pathQaQualityPercent(pathQa)} %)` : ''}
</strong> </strong>
{pathQaShowsStrongResult(pathQa) ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)', fontWeight: 600 }}>
Starker Pfad KI-Highlights können Feinschliff oder optionale Vertiefung sein.
</p>
) : null}
{pathQa.topic_coverage ? ( {pathQa.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p> <p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
) : null} ) : null}
{pathQaHighlights.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--accent-dark)' }}>
KI-Highlights ({pathQaHighlights.length})
</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '6px' }}>
{pathQaHighlights.map((item, i) => (
<li
key={`hl-${i}-${item.text.slice(0, 24)}`}
style={{
padding: '8px 10px',
borderRadius: '6px',
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 10%, var(--surface2))',
fontSize: '11px',
color: 'var(--text1)',
}}
>
{item.text}
</li>
))}
</ul>
</>
) : null}
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? ( {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}> <ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.issues.slice(0, 4).map((issue) => ( {pathQa.issues.slice(0, 4).map((issue) => (
@ -1838,6 +1967,21 @@ export default function ExerciseProgressionPathBuilder({
))} ))}
</ul> </ul>
) : null} ) : null}
{pathQaFixHints.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
Handlungsbedarf ({pathQaFixHints.length})
</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQaFixHints.slice(0, 6).map((hint, i) => (
<li key={`fix-${i}-${hint.issue}-${hint.step_index ?? 'x'}`}>
{hint.title ? `${hint.title}: ` : ''}
{hint.reason || hint.issue}
</li>
))}
</ul>
</>
) : null}
{Number(pathQa.bridge_insert_count) > 0 ? ( {Number(pathQa.bridge_insert_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}> <p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt. {pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.

View File

@ -0,0 +1,99 @@
/**
* Planungskontext Katalog-Dimensionen für Progressionsgraph-Matching.
*/
import React from 'react'
import { getCatalogSelectId } from '../utils/progressionGraphDraft'
export default function PlanningCatalogContextFields({
catalogCtx,
onPatchDimension,
focusAreas = [],
styleDirections = [],
trainingTypes = [],
targetGroups = [],
disabled = false,
helperText = 'Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig von Technik-Pfaden.',
}) {
return (
<>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '10px',
marginTop: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Primärfokus</label>
<select
className="form-input"
disabled={disabled}
value={getCatalogSelectId(catalogCtx?.focusAreas)}
onChange={(e) => onPatchDimension('focusAreas', e.target.value)}
>
<option value=""> optional </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Stilrichtung</label>
<select
className="form-input"
disabled={disabled}
value={getCatalogSelectId(catalogCtx?.styleDirections)}
onChange={(e) => onPatchDimension('styleDirections', e.target.value)}
>
<option value=""> optional </option>
{(styleDirections || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Trainingsstil</label>
<select
className="form-input"
disabled={disabled}
value={getCatalogSelectId(catalogCtx?.trainingTypes)}
onChange={(e) => onPatchDimension('trainingTypes', e.target.value)}
>
<option value=""> optional </option>
{(trainingTypes || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Zielgruppe</label>
<select
className="form-input"
disabled={disabled}
value={getCatalogSelectId(catalogCtx?.targetGroups)}
onChange={(e) => onPatchDimension('targetGroups', e.target.value)}
>
<option value=""> optional </option>
{(targetGroups || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
</div>
{helperText ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
{helperText}
</p>
) : null}
</>
)
}

View File

@ -10,8 +10,11 @@ import {
formatRematchLogEntry, formatRematchLogEntry,
formatRefineLogEntry, formatRefineLogEntry,
hasRematchSlotHints, hasRematchSlotHints,
pathQaQualityPercent,
pathQaShowsStrongResult,
resolveHintSlotIndex, resolveHintSlotIndex,
resolveOfferSlotIndex, resolveOfferSlotIndex,
splitPathQaHints,
} from '../utils/progressionGraphDraft' } from '../utils/progressionGraphDraft'
function severityStyle(pathQa) { function severityStyle(pathQa) {
@ -23,6 +26,131 @@ function severityStyle(pathQa) {
} }
} }
function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) {
const { fixHints: optimizationHints } = useMemo(
() => splitPathQaHints(pathQa),
[pathQa],
)
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : []
const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : []
const qaTiers = Array.isArray(pathQa?.qa_tiers) ? pathQa.qa_tiers : []
const qualityPct = pathQaQualityPercent(fairQa || pathQa)
const hasContent =
qaTiers.length > 0
|| (pathQa?.rematch_applied && rematchLog.length > 0)
|| (pathQa?.refine_applied && refineLog.length > 0)
|| optimizationHints.length > 0
if (!pathQa || !hasContent) return null
return (
<div
style={{
marginTop: compact ? '10px' : '12px',
padding: compact ? '8px 10px' : '10px 12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface2))',
}}
>
<strong style={{ fontSize: compact ? '11px' : '12px' }}>
{title}
{qualityPct != null
? ` · ${(fairQa || pathQa)?.overall_ok ? 'OK' : 'Hinweise'} (${qualityPct} % fair bewertet)`
: ''}
</strong>
{fairQa && pathQa && pathQaQualityPercent(pathQa) !== qualityPct ? (
<p style={{ margin: '4px 0 0', fontSize: '10px', color: 'var(--text3)' }}>
Rematch-Protokoll (Pipeline-Score {pathQaQualityPercent(pathQa) ?? '—'} %) nur Prozessinfo, nicht Pfad-QS.
</p>
) : null}
{qaTiers.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)', fontSize: '11px' }}>
{qaTiers.map((tier) => (
<li key={tier.id || tier.label}>
{tier.label || tier.id}
{tier.finding_count != null ? ` (${tier.finding_count})` : ''}
{tier.applied === false ? ' · LLM aus' : ''}
</li>
))}
</ul>
) : null}
{pathQa.rematch_applied && rematchLog.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)', fontSize: '11px' }}>
Auto-Rematch
{pathQa.rematch_rounds != null ? ` (${pathQa.rematch_rounds} Runde(n))` : ''}
</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)', fontSize: '11px' }}>
{rematchLog.map((entry, i) => (
<li key={`rematch-${i}-${entry.roadmap_major_step_index}-${entry.action}`}>
{formatRematchLogEntry(entry)}
</li>
))}
</ul>
</>
) : null}
{pathQa.refine_applied && refineLog.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)', fontSize: '11px' }}>
Stufen-Spec verfeinert ({refineLog.length})
</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)', fontSize: '11px' }}>
{refineLog.map((entry, i) => (
<li key={`refine-${i}-${entry.roadmap_major_step_index}`}>
{formatRefineLogEntry(entry)}
</li>
))}
</ul>
</>
) : null}
{optimizationHints.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)', fontSize: '11px' }}>
Handlungsbedarf ({optimizationHints.length})
</p>
<ul
style={{
margin: 0,
padding: 0,
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{optimizationHints.slice(0, compact ? 4 : 8).map((hint, i) => {
const slotIdx = resolveHintSlotIndex(hint, draft)
return (
<li
key={`hint-${i}-${hint.action}-${hint.issue}-${slotIdx ?? 'x'}`}
style={{
padding: '6px 8px',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '11px',
color: 'var(--text2)',
}}
>
<span className="exercise-tag" style={{ marginBottom: '4px', display: 'inline-block' }}>
{optimizationHintActionLabel(hint.action)}
{slotIdx != null ? ` · Slot ${slotIdx + 1}` : ''}
</span>
{hint.title ? (
<div style={{ fontWeight: 600, color: 'var(--text1)' }}>{hint.title}</div>
) : null}
{hint.reason ? <p style={{ margin: '4px 0 0' }}>{hint.reason}</p> : null}
</li>
)
})}
</ul>
</>
) : null}
</div>
)
}
function GapOfferCard({ function GapOfferCard({
offer, offer,
slotCount, slotCount,
@ -159,21 +287,36 @@ export default function ProgressionFindingsPanel({
onInsertGapSlot, onInsertGapSlot,
onGenerateGapAi, onGenerateGapAi,
onRematchSlots = null, onRematchSlots = null,
onOptimizeCompare = null,
optimizationPreviewQa = null,
optimizationPreviewFairQa = null,
canOptimizeCompare = false,
optimizeCompareBusy = false,
rematchBusy = false, rematchBusy = false,
generatingOfferId = null, generatingOfferId = null,
aiBusy = false, aiBusy = false,
evaluateDisabled = false, evaluateDisabled = false,
evaluationStale = false,
}) { }) {
const optimizationHints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : [] const { fixHints: optimizationHints, highlightTexts } = useMemo(
() => splitPathQaHints(pathQa),
[pathQa],
)
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : [] const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : []
const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : [] const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : []
const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function' const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function'
const showOptimizeCompare =
typeof onOptimizeCompare === 'function'
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
const qualityPct = pathQaQualityPercent(pathQa)
const strongResult = pathQaShowsStrongResult(pathQa)
return ( return (
<div className="card" style={{ position: 'sticky', top: '12px' }}> <div className="card" style={{ position: 'sticky', top: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3> <h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}> <p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken. Bewertet den aktuellen Slot-Stand (3-Stufen-QS, ohne Auto-Rematch). Nach Änderungen am Graphen
erscheint ein Hinweis dann erneut Graph bewerten.
</p> </p>
<button <button
@ -192,6 +335,24 @@ export default function ProgressionFindingsPanel({
</p> </p>
) : null} ) : null}
{evaluationStale && pathQa ? (
<div
role="status"
style={{
marginBottom: '12px',
padding: '8px 10px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--danger) 45%, var(--border))',
background: 'color-mix(in srgb, var(--danger) 12%, var(--surface2))',
fontSize: '12px',
lineHeight: 1.45,
color: 'var(--text1)',
}}
>
<strong>Bewertung veraltet, neue Bewertung notwendig.</strong>
</div>
) : null}
{pathQa ? ( {pathQa ? (
<div <div
style={{ style={{
@ -204,13 +365,56 @@ export default function ProgressionFindingsPanel({
> >
<strong> <strong>
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{pathQa.quality_score != null {qualityPct != null ? ` (${qualityPct} %)` : ''}
? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)`
: ''}
</strong> </strong>
{pathQa.assignments_preserved ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
Bewertung des aktuellen Pfads. Übungen matchen öffnet einen Dialog mit Vorschlägen für
alle Slots Übernahme nur nach deiner Auswahl.
</p>
) : null}
{strongResult ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)', fontWeight: 600 }}>
Starker Pfad KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
</p>
) : null}
{pathQa.topic_coverage ? ( {pathQa.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p> <p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
) : null} ) : null}
{highlightTexts.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--accent-dark)' }}>
KI-Highlights ({highlightTexts.length})
</p>
<ul
style={{
margin: 0,
padding: 0,
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{highlightTexts.map((item, i) => (
<li
key={`hl-${i}-${item.text.slice(0, 24)}`}
style={{
padding: '8px 10px',
borderRadius: '6px',
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 10%, var(--surface2))',
fontSize: '11px',
color: 'var(--text1)',
lineHeight: 1.45,
}}
>
{item.text}
</li>
))}
</ul>
</>
) : null}
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? ( {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}> <ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.issues.map((issue) => ( {pathQa.issues.map((issue) => (
@ -218,7 +422,9 @@ export default function ProgressionFindingsPanel({
))} ))}
</ul> </ul>
) : null} ) : null}
{Array.isArray(pathQa.recommendations) && pathQa.recommendations.length > 0 ? ( {Array.isArray(pathQa.recommendations) &&
pathQa.recommendations.length > 0 &&
highlightTexts.length === 0 ? (
<> <>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>Empfehlungen</p> <p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>Empfehlungen</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}> <ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
@ -265,7 +471,7 @@ export default function ProgressionFindingsPanel({
{optimizationHints.length > 0 ? ( {optimizationHints.length > 0 ? (
<> <>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}> <p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
Optimierungspotenziale ({optimizationHints.length}) Handlungsbedarf ({optimizationHints.length})
</p> </p>
<ul <ul
style={{ style={{
@ -303,7 +509,18 @@ export default function ProgressionFindingsPanel({
) )
})} })}
</ul> </ul>
{showRematchAction ? ( {showOptimizeCompare ? (
<button
type="button"
className="btn btn-primary btn-full"
style={{ marginTop: '8px', fontSize: '12px' }}
disabled={optimizeCompareBusy || evaluateDisabled}
onClick={onOptimizeCompare}
>
{optimizeCompareBusy ? 'Vergleich läuft…' : 'Optimierung vergleichen'}
</button>
) : null}
{showRematchAction && !showOptimizeCompare ? (
<button <button
type="button" type="button"
className="btn btn-secondary btn-full" className="btn btn-secondary btn-full"
@ -316,6 +533,15 @@ export default function ProgressionFindingsPanel({
) : null} ) : null}
</> </>
) : null} ) : null}
{optimizationPreviewQa ? (
<PathQaPipelineDetails
pathQa={optimizationPreviewQa}
fairQa={optimizationPreviewFairQa}
draft={draft}
title="3-Stufen-Optimierung (Vorschlag — nur im Vergleichsdialog)"
compact
/>
) : null}
</div> </div>
) : ( ) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}> <p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>

View File

@ -8,6 +8,7 @@ import ExercisePickerModal from './ExercisePickerModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ProgressionSlotCard from './ProgressionSlotCard' import ProgressionSlotCard from './ProgressionSlotCard'
import ProgressionFindingsPanel from './ProgressionFindingsPanel' import ProgressionFindingsPanel from './ProgressionFindingsPanel'
import PlanningCatalogContextFields from './PlanningCatalogContextFields'
import { import {
aiPreviewToQuickCreateDraft, aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview, buildQuickCreateAiPreview,
@ -21,34 +22,45 @@ import {
initialStageLearningGoalFromOffer, initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi' } from '../utils/planningContextForExerciseAi'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal'
import { import {
addSlotToDraft, addSlotToDraft,
applyEvaluateResponseToDraft, applyEvaluateResponseToDraft,
applyGapOfferToDraft, applyGapOfferToDraft,
applyMatchResponseToDraft, applySelectedCompareSteps,
applySelectedSlotSuggestions,
applyResolvedStructuredToDraft, applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft, buildPlanningArtifactFromDraft,
buildProgressionComparePayload,
collectGapOffersFromApiResponse,
compareSlotDiffs,
compareDiffsForDialog,
dedupeGapOffersBySlot,
draftHasLibrarySlotAssignments,
draftRetrievalBoostExerciseIds,
EMPTY_PLANNING_CATALOG_CONTEXT,
filterGapOffersForUnfilledSlots,
hydrateProgressionGraphDraft, hydrateProgressionGraphDraft,
SLOT_MIN,
insertSlotInDraft, insertSlotInDraft,
librarySlotExercise, librarySlotExercise,
majorStepsToOverridePayload, majorStepsToOverridePayload,
mergeGapOffersForDraft,
moveSlotInDraft, moveSlotInDraft,
patchSlotInDraft, patchSlotInDraft,
pathQaQualityPercent,
planningCatalogContextToApi,
rejectedCompareDiffs,
removeSlotFromDraft, removeSlotFromDraft,
saveProgressionGraphDraft, saveProgressionGraphDraft,
setCatalogSelectItems,
setSlotPrimaryLibrary, setSlotPrimaryLibrary,
SLOT_MAX, SLOT_MAX,
SLOT_MIN,
slotsAsPathStepRows, slotsAsPathStepRows,
slotsToEvaluateSteps, slotsToEvaluateSteps,
draftRetrievalBoostExerciseIds,
slotsToSlotAssignments, slotsToSlotAssignments,
syncProgressionRoadmapFromSlots, syncProgressionRoadmapFromSlots,
syncSlotPhasesFromRoadmap, syncSlotPhasesFromRoadmap,
EMPTY_PLANNING_CATALOG_CONTEXT,
getCatalogSelectId,
planningCatalogContextToApi,
setCatalogSelectItems,
} from '../utils/progressionGraphDraft' } from '../utils/progressionGraphDraft'
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
@ -111,6 +123,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [slotQuickSaving, setSlotQuickSaving] = useState(false) const [slotQuickSaving, setSlotQuickSaving] = useState(false)
const [slotQuickError, setSlotQuickError] = useState('') const [slotQuickError, setSlotQuickError] = useState('')
const [activePlanningContextLines, setActivePlanningContextLines] = useState([]) const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
const [compareOpen, setCompareOpen] = useState(false)
const [comparePayload, setComparePayload] = useState(null)
const [compareSource, setCompareSource] = useState('manual')
const [comparing, setComparing] = useState(false)
const [compareApplying, setCompareApplying] = useState(false)
const [proposedPathQa, setProposedPathQa] = useState(null)
const loadGraph = useCallback(async () => { const loadGraph = useCallback(async () => {
if (!graphId) return if (!graphId) return
@ -182,7 +200,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setDraft((prev) => { setDraft((prev) => {
if (!prev) return prev if (!prev) return prev
const next = patchFn(prev) const next = patchFn(prev)
return { ...next, dirty: true } return { ...next, dirty: true, findingsStale: true }
}) })
}, []) }, [])
@ -346,7 +364,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
{ ...prev, progressionRoadmap: roadmap }, { ...prev, progressionRoadmap: roadmap },
roadmap, roadmap,
) )
return { ...structured, dirty: true } return { ...structured, dirty: true, findingsStale: true }
}) })
setStartTargetReady(true) setStartTargetReady(true)
setSemanticBrief(res?.semantic_brief_summary || null) setSemanticBrief(res?.semantic_brief_summary || null)
@ -415,7 +433,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
graphName: draft.graphName, graphName: draft.graphName,
}) })
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap) const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true }) setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true, findingsStale: true })
setSemanticBrief(res?.semantic_brief_summary || null) setSemanticBrief(res?.semantic_brief_summary || null)
} catch (e) { } catch (e) {
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen') setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
@ -424,6 +442,149 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
} }
const buildEvaluateRequest = (synced, { llmPathQa = true, aiGapFill = true } = {}) => {
const override = majorStepsToOverridePayload(synced.slots)
return {
query: (synced.goalQuery || '').trim(),
max_steps: synced.slots.length || draft?.maxSteps || 5,
include_path_qa: true,
include_llm_path_qa: llmPathQa,
include_ai_gap_fill: aiGapFill,
include_path_reorder: false,
include_llm_intent: false,
evaluate_only: true,
evaluate_steps: slotsToEvaluateSteps(synced),
roadmap_override: override,
slot_assignments: slotsToSlotAssignments(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
...catalogApiPayload,
}
}
const fetchPathEvaluate = async (synced, options) =>
api.suggestProgressionPath(buildEvaluateRequest(synced, options))
const applyEvaluateResult = (synced, res) => {
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null)
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
return {
draft: { ...evaluated, lastFindings: res?.path_qa || null, findingsStale: false },
remainingOffers,
}
}
const buildMatchRequestBase = (synced) => {
const override = majorStepsToOverridePayload(synced.slots)
return {
query: (synced.goalQuery || '').trim(),
max_steps: synced.slots.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: false,
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
slot_assignments: slotsToSlotAssignments(synced),
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
...catalogApiPayload,
}
}
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
const baselineRes = await fetchPathEvaluate(synced)
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, baselineRes)
setDraft(evaluated)
const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes)
setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers)
setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…')
let compareRes
let reviewError = null
try {
const reviewRes = await api.suggestProgressionPath({
...buildEvaluateRequest(synced),
evaluate_only: false,
unified_slot_review: true,
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
baseline_path_qa_snapshot: baselineRes?.path_qa || null,
baseline_quality_score:
baselineRes?.path_qa?.quality_score != null
? Number(baselineRes.path_qa.quality_score)
: null,
include_llm_path_qa: false,
include_llm_intent: false,
auto_rematch_after_qa: false,
})
if (!reviewRes?.unified_slot_review) {
reviewError =
'Slot-Review nicht verfügbar — Backend neu starten/deployen (unified_slot_review fehlt).'
compareRes = buildProgressionComparePayload(baselineRes, {
...reviewRes,
unified_slot_review: true,
slot_reviews: [],
review_error: reviewError,
})
} else {
compareRes = buildProgressionComparePayload(baselineRes, reviewRes)
}
setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes))
} catch (e) {
reviewError = e.message || 'Slot-Review fehlgeschlagen'
compareRes = buildProgressionComparePayload(baselineRes, {
unified_slot_review: true,
slot_reviews: [],
review_error: reviewError,
path_qa: baselineRes?.path_qa,
})
}
presentMatchCompare(compareRes, { source, reviewError })
return compareRes
}
const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => {
setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null)
setComparePayload(reviewError ? { ...res, review_error: reviewError } : res)
setCompareSource(source)
setProposedPathQa(res?.proposed_path_qa_pipeline || null)
setCompareOpen(true)
const baselineQa = res?.baseline_path_qa || null
const slotReviews = res?.slot_reviews || []
const autoCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length
const diffCount = autoCount || res?.slot_diff_count || 0
const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length
const problemCount = res?.match_summary?.problem_slot_count
?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0)
const bPct = pathQaQualityPercent(baselineQa)
let notice = reviewError
? `Match: Dialog geöffnet — ${reviewError}`
: slotReviews.length > 0
? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.`
: diffCount > 0
? `Match: ${diffCount} Verbesserung(en).`
: problemCount > 0
? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.`
: 'Match: Pfad geprüft — siehe Dialog.'
if (rejectedCount > 0) {
notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).`
}
const gapCount = collectGapOffersFromApiResponse(res).length
if (gapCount > 0) {
notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.`
}
setMatchNotice(notice)
}
const runMatch = async () => { const runMatch = async () => {
const q = (draft?.goalQuery || '').trim() const q = (draft?.goalQuery || '').trim()
if (q.length < 3) { if (q.length < 3) {
@ -439,65 +600,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setMatchNotice('') setMatchNotice('')
try { try {
const synced = syncProgressionRoadmapFromSlots(draft) const synced = syncProgressionRoadmapFromSlots(draft)
const override = majorStepsToOverridePayload(synced.slots) setProposedPathQa(null)
const res = await api.suggestProgressionPath({ await runMatchCompareFlow(synced, { source: 'match' })
query: q,
max_steps: synced.slots.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: false,
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
slot_assignments: slotsToSlotAssignments(synced),
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
})
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
{
...synced,
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
},
res,
)
setDraft(matched)
setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null)
setPathQa(res?.path_qa || null)
setGapFillOffers(remainingOffers)
const ms = res?.match_summary
const rematchLog = res?.path_qa?.rematch_log
const rematchRounds = res?.path_qa?.rematch_rounds
if (ms) {
const parts = [
`Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.`,
]
if (rematchRounds > 0 && Array.isArray(rematchLog) && rematchLog.length > 0) {
parts.push(
`Auto-Rematch: ${rematchLog.length} Anpassung(en) in ${rematchRounds} Runde(n).`,
)
}
const refineLog = res?.path_qa?.refine_log
if (Array.isArray(refineLog) && refineLog.length > 0) {
parts.push(`Stufen-Spec: ${refineLog.length} Slot(s) verfeinert.`)
}
setMatchNotice(parts.join(' '))
}
try {
await saveProgressionGraphDraft(api, graphId, {
...matched,
lastFindings: res?.path_qa || null,
})
setDraft((prev) => (prev ? { ...prev, dirty: false } : prev))
} catch (saveErr) {
console.warn('Match-Artefakt konnte nicht gespeichert werden', saveErr)
}
} catch (e) { } catch (e) {
setActionErr(e.message || 'Übungs-Match fehlgeschlagen') setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
} finally { } finally {
@ -505,6 +609,61 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
} }
const runOptimizeCompare = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
if (validMajorSteps.length < 2) {
alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.')
return
}
setComparing(true)
setActionErr('')
setMatchNotice('')
try {
const synced = syncProgressionRoadmapFromSlots(draft)
setProposedPathQa(null)
await runMatchCompareFlow(synced, { source: 'manual' })
} catch (e) {
setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen')
} finally {
setComparing(false)
}
}
const applyOptimizeCompare = async (selectedMajorIndices) => {
if (!comparePayload || !draft) return
setCompareApplying(true)
setMatchNotice('Übernahme: Slots aktualisieren …')
try {
const synced = syncProgressionRoadmapFromSlots(draft)
const nextDraft = comparePayload?.unified_slot_review
? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices)
: applySelectedCompareSteps(
synced,
comparePayload.proposed_steps || comparePayload.steps,
selectedMajorIndices,
)
const syncedNext = syncProgressionRoadmapFromSlots(nextDraft)
setDraft({ ...syncedNext, dirty: false, findingsStale: true })
setCompareOpen(false)
setComparePayload(null)
setProposedPathQa(null)
await saveProgressionGraphDraft(api, graphId, { ...syncedNext, findingsStale: true })
setMatchNotice(
'Übernommen und gespeichert. Bewertung bezieht sich noch auf den vorherigen Stand — bitte „Graph bewerten“.',
)
} catch (e) {
setActionErr(e.message || 'Übernahme fehlgeschlagen')
} finally {
setCompareApplying(false)
}
}
const runEvaluate = async () => { const runEvaluate = async () => {
const q = (draft?.goalQuery || '').trim() const q = (draft?.goalQuery || '').trim()
if (q.length < 3) { if (q.length < 3) {
@ -513,30 +672,14 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
setEvaluating(true) setEvaluating(true)
setActionErr('') setActionErr('')
setProposedPathQa(null)
try { try {
const synced = syncProgressionRoadmapFromSlots(draft) const synced = syncProgressionRoadmapFromSlots(draft)
const override = const res = await fetchPathEvaluate(synced)
validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res)
const res = await api.suggestProgressionPath({ setDraft(evaluated)
query: q, const mergedOffers = mergeGapOffersForDraft(evaluated, res)
max_steps: synced.slots.length || draft.maxSteps || 5, setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : remainingOffers)
include_path_qa: true,
include_llm_path_qa: true,
include_ai_gap_fill: true,
include_path_reorder: false,
include_llm_intent: false,
evaluate_only: true,
evaluate_steps: slotsToEvaluateSteps(synced),
roadmap_override: override,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
})
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null)
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
setDraft({ ...evaluated, lastFindings: res?.path_qa || null })
setGapFillOffers(remainingOffers)
} catch (e) { } catch (e) {
setActionErr(e.message || 'Bewertung fehlgeschlagen') setActionErr(e.message || 'Bewertung fehlgeschlagen')
} finally { } finally {
@ -563,7 +706,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const handleApplyGapOffer = (offer, slotIndex) => { const handleApplyGapOffer = (offer, slotIndex) => {
setDraft((prev) => { setDraft((prev) => {
const next = applyGapOfferToDraft(prev, offer, { slotIndex }) const next = applyGapOfferToDraft(prev, offer, { slotIndex })
return { ...next, dirty: true } return { ...next, dirty: true, findingsStale: true }
}) })
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
} }
@ -575,7 +718,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
setDraft((prev) => { setDraft((prev) => {
const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true }) const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true })
return { ...next, dirty: true } return { ...next, dirty: true, findingsStale: true }
}) })
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
} }
@ -689,7 +832,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
: null : null
if (resolvedSlot != null) { if (resolvedSlot != null) {
setSlotQuickCreateIndex(resolvedSlot) setSlotQuickCreateIndex(resolvedSlot)
setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot })) setDraft((prev) => ({
...applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }),
findingsStale: true,
}))
} }
setSlotQuickCreateDraft(aiDraft) setSlotQuickCreateDraft(aiDraft)
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
@ -737,7 +883,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft) const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft)
const created = await api.createExercise(payload) const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen') if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setDraft((prev) => setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created)) setDraft((prev) => ({
...setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created),
dirty: true,
findingsStale: true,
}))
setSlotQuickCreateDraft(null) setSlotQuickCreateDraft(null)
setSlotQuickCreateIndex(null) setSlotQuickCreateIndex(null)
setActiveOffer(null) setActiveOffer(null)
@ -909,83 +1059,16 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
/> />
</div> </div>
</div> </div>
<div <PlanningCatalogContextFields
style={{ catalogCtx={catalogCtx}
display: 'grid', onPatchDimension={patchCatalogDimension}
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', focusAreas={focusAreas}
gap: '10px', styleDirections={styleDirections}
marginTop: '10px', trainingTypes={trainingTypes}
}} targetGroups={targetGroups}
> disabled={busy}
<div className="form-row" style={{ marginBottom: 0 }}> helperText="Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert."
<label className="form-label">Primärfokus</label> />
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.focusAreas)}
onChange={(e) => patchCatalogDimension('focusAreas', e.target.value)}
>
<option value=""> optional </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Stilrichtung</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.styleDirections)}
onChange={(e) => patchCatalogDimension('styleDirections', e.target.value)}
>
<option value=""> optional </option>
{(styleDirections || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Trainingsstil</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.trainingTypes)}
onChange={(e) => patchCatalogDimension('trainingTypes', e.target.value)}
>
<option value=""> optional </option>
{(trainingTypes || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Zielgruppe</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.targetGroups)}
onChange={(e) => patchCatalogDimension('targetGroups', e.target.value)}
>
<option value=""> optional </option>
{(targetGroups || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) unabhängig
von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert.
</p>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}> <p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
Optional zuerst Start/Ziel analysieren, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer, Optional zuerst Start/Ziel analysieren, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang. geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang.
@ -1018,9 +1101,25 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
className="btn btn-secondary" className="btn btn-secondary"
disabled={busy || matching} disabled={busy || matching}
onClick={runMatch} onClick={runMatch}
title={
draftHasLibrarySlotAssignments(draft)
? 'Voller Match mit Auto-Optimierung — bei Abweichungen öffnet sich der Vergleichsdialog'
: 'Bibliotheks-Übungen für leere Slots finden'
}
> >
{matching ? 'Match…' : 'Übungen matchen'} {matching ? 'Match…' : 'Übungen matchen'}
</button> </button>
{draftHasLibrarySlotAssignments(draft) ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy || comparing || matching}
onClick={runOptimizeCompare}
title="Aktuellen Pfad vs. voller Match mit Auto-Optimierung — du wählst pro Slot"
>
{comparing ? 'Vergleich…' : 'Optimierung vergleichen'}
</button>
) : null}
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}> <button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}>
{busy ? 'Speichern…' : 'Graph speichern'} {busy ? 'Speichern…' : 'Graph speichern'}
</button> </button>
@ -1077,11 +1176,15 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
slotCount={draft.slots.length} slotCount={draft.slots.length}
loading={evaluating} loading={evaluating}
error="" error=""
evaluationStale={Boolean(draft?.findingsStale)}
onEvaluate={runEvaluate} onEvaluate={runEvaluate}
onApplyGapOffer={handleApplyGapOffer} onApplyGapOffer={handleApplyGapOffer}
onInsertGapSlot={handleInsertGapSlot} onInsertGapSlot={handleInsertGapSlot}
onGenerateGapAi={openGapFillPrep} onGenerateGapAi={openGapFillPrep}
onRematchSlots={runMatch} onRematchSlots={runMatch}
onOptimizeCompare={runOptimizeCompare}
canOptimizeCompare={validMajorSteps.length >= 2}
optimizeCompareBusy={comparing}
rematchBusy={matching} rematchBusy={matching}
generatingOfferId={generatingOfferId} generatingOfferId={generatingOfferId}
aiBusy={gapAiBusy} aiBusy={gapAiBusy}
@ -1121,6 +1224,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
zIndex={2100} zIndex={2100}
/> />
<ProgressionOptimizeCompareModal
open={compareOpen}
comparison={comparePayload}
mode={compareSource}
onClose={() => {
if (compareApplying) return
setCompareOpen(false)
setComparePayload(null)
setProposedPathQa(null)
}}
onApplySelected={applyOptimizeCompare}
applying={compareApplying}
/>
<ExerciseGapFillPrepModal <ExerciseGapFillPrepModal
open={gapPrepOpen} open={gapPrepOpen}
offer={activeOffer} offer={activeOffer}

View File

@ -0,0 +1,356 @@
/**
* Slot-Match-Dialog: je Slot Bewertung, Bibliotheks-Alternative, optional KI.
*/
import React, { useMemo, useState } from 'react'
import FormModalOverlay from './FormModalOverlay'
import {
compareSlotReviews,
defaultSelectedCompareDiffs,
pathQaQualityPercent,
qualityDeltaPercent,
rejectedCompareDiffs,
slotFitScorePercent,
slotReviewSelectionKey,
} from '../utils/progressionGraphDraft'
function qaLabel(pathQa) {
const pct = pathQaQualityPercent(pathQa)
const ok = pathQa?.overall_ok
if (pct != null) return `${ok ? 'OK' : 'Hinweise'} (${pct} %)`
return ok ? 'OK' : 'Hinweise'
}
function slotScoreLabel(score) {
const pct = slotFitScorePercent(score)
if (pct == null) return '—'
return `${pct} % Stufen-Fit`
}
function ProContraList({ title, items, tone = 'neutral' }) {
if (!items?.length) return null
const color =
tone === 'pro' ? 'var(--accent-dark)' : tone === 'contra' ? 'var(--danger)' : 'var(--text2)'
return (
<div style={{ marginTop: '6px' }}>
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)' }}>{title}</div>
<ul style={{ margin: '4px 0 0', paddingLeft: '16px', color, fontSize: '11px' }}>
{items.map((text, i) => (
<li key={`${title}-${i}`}>{text}</li>
))}
</ul>
</div>
)
}
function SlotReviewRow({ review, selected, onToggle, applying }) {
const midx = Number(review.roadmap_major_step_index)
const lib = review.library_alternative
const ai = review.ai_alternative
const libKey = slotReviewSelectionKey(midx, 'library')
const aiKey = slotReviewSelectionKey(midx, 'ai')
const pc = lib?.pro_contra || {}
const pathDelta = qualityDeltaPercent({ quality_delta: lib?.quality_delta })
const slotDelta = lib?.slot_score_delta
return (
<li
style={{
padding: '12px',
borderRadius: '8px',
border: `1px solid ${review.slot_problem ? 'var(--danger)' : 'var(--border)'}`,
background: 'var(--surface)',
fontSize: '12px',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginBottom: '8px' }}>
<strong>Slot {midx + 1}</strong>
{review.slot_problem ? (
<span style={{ fontSize: '10px', color: 'var(--danger)' }}>Schachstelle</span>
) : review.off_topic ? (
<span style={{ fontSize: '10px', color: 'var(--danger)' }}>Passt nicht</span>
) : (
<span style={{ fontSize: '10px', color: 'var(--text2)' }}>OK</span>
)}
{review.roadmap_learning_goal ? (
<span style={{ fontSize: '10px', color: 'var(--text3)', flex: '1 1 100%' }}>
{review.roadmap_learning_goal}
</span>
) : null}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '10px',
marginBottom: lib || ai ? '10px' : 0,
}}
>
<div>
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
Aktuell
</div>
<div style={{ color: 'var(--text2)' }}>
{review.baseline_title || '— leer —'}
{review.baseline_exercise_id != null ? ` (#${review.baseline_exercise_id})` : ''}
</div>
<div style={{ fontSize: '10px', color: 'var(--text3)', marginTop: '4px' }}>
{slotScoreLabel(review.baseline_slot_score)}
</div>
{(review.problem_reasons || []).slice(0, 3).map((text, i) => (
<p key={`pr-${i}`} style={{ margin: '4px 0 0', color: 'var(--danger)', fontSize: '11px' }}>
{text}
</p>
))}
</div>
<div>
<div style={{ fontSize: '10px', fontWeight: 600, color: 'var(--text3)', marginBottom: '4px' }}>
Beste Bibliotheks-Alternative
</div>
{lib ? (
<>
<div style={{ color: 'var(--accent-dark)' }}>
{lib.title || '—'}
{lib.exercise_id != null ? ` (#${lib.exercise_id})` : ''}
</div>
<div style={{ fontSize: '10px', color: 'var(--text3)', marginTop: '4px' }}>
{slotScoreLabel(lib.slot_score)}
{slotDelta != null && Number(slotDelta) !== 0 ? (
<span style={{ marginLeft: '6px', color: Number(slotDelta) > 0 ? 'var(--accent-dark)' : 'var(--text2)' }}>
({Number(slotDelta) > 0 ? '+' : ''}{Math.round(Number(slotDelta) * 100)} PP)
</span>
) : null}
{pathDelta != null ? (
<span style={{ marginLeft: '6px' }}>· Pfad {pathDelta > 0 ? `+${pathDelta}` : pathDelta} %</span>
) : null}
</div>
<ProContraList title="Pro" items={pc.proposed_pro} tone="pro" />
<ProContraList title="Contra" items={pc.proposed_contra} tone="contra" />
</>
) : (
<div style={{ color: 'var(--text3)' }}>Kein passender Bibliotheks-Treffer</div>
)}
</div>
</div>
{lib ? (
<label
style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
cursor: 'pointer',
padding: '8px',
borderRadius: '6px',
background: selected.has(libKey) ? 'var(--surface2)' : 'transparent',
border: '1px solid var(--border)',
marginBottom: ai ? '8px' : 0,
}}
>
<input
type="checkbox"
checked={selected.has(libKey)}
onChange={() => onToggle(libKey, 'library')}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span>
<strong>Bibliothek übernehmen</strong>
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{lib.auto_select
? 'Empfohlen — Stufen-Fit besser als aktuell'
: 'Optional — nicht besser als aktuell bewertet'}
</span>
</span>
</label>
) : null}
{ai ? (
<label
style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
cursor: 'pointer',
padding: '8px',
borderRadius: '6px',
background: selected.has(aiKey) ? 'var(--surface2)' : 'transparent',
border: '1px solid var(--border)',
}}
>
<input
type="checkbox"
checked={selected.has(aiKey)}
onChange={() => onToggle(aiKey, 'ai')}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span>
<strong>KI-Vorschlag nutzen</strong>
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{ai.title_hint || 'Neue Übung per KI entwerfen'}
</span>
</span>
</label>
) : null}
</li>
)
}
export default function ProgressionOptimizeCompareModal({
open,
comparison,
mode = 'manual',
onClose,
onApplySelected,
applying = false,
}) {
const slotReviews = useMemo(() => compareSlotReviews(comparison), [comparison])
const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison])
const defaultSelected = useMemo(
() => new Set(defaultSelectedCompareDiffs(comparison)),
[comparison],
)
const [selected, setSelected] = useState(() => new Set())
React.useEffect(() => {
if (!open) return
setSelected(new Set(defaultSelected))
}, [open, defaultSelected])
if (!open || !comparison) return null
const baselineQa = comparison.baseline_path_qa
const baselinePct = pathQaQualityPercent(baselineQa)
const rejectedCount = rejected.length
const reviewError = comparison.review_error || null
const toggle = (key, kind) => {
setSelected((prev) => {
const next = new Set(prev)
const parsed = String(key)
const midx = Number(parsed.split(':')[0])
const libKey = slotReviewSelectionKey(midx, 'library')
const aiKey = slotReviewSelectionKey(midx, 'ai')
if (next.has(parsed)) {
next.delete(parsed)
return next
}
if (kind === 'library') next.delete(aiKey)
if (kind === 'ai') next.delete(libKey)
next.add(parsed)
return next
})
}
const title =
mode === 'match' ? 'Übungs-Match — Slot-Bewertung' : 'Optimierung vergleichen'
return (
<FormModalOverlay open={open} raised onBackdropClick={applying ? undefined : onClose}>
<div
className="card modal-panel--form modal-panel--narrow"
style={{ maxHeight: '92vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
<h3 id="optimize-compare-title" style={{ marginTop: 0 }}>
{title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur
vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.
</p>
{reviewError ? (
<p
style={{
fontSize: '12px',
color: 'var(--danger)',
padding: '8px 10px',
borderRadius: '8px',
border: '1px solid var(--danger)',
background: 'var(--surface2)',
}}
>
{reviewError}
</p>
) : null}
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
marginBottom: '14px',
}}
>
<strong>Dein Pfad</strong>
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
{baselineQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
) : null}
</div>
{rejectedCount > 0 ? (
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
{rejectedCount} Alternative(n) ohne Pfad-Gewinn
{baselinePct != null ? ` (Basis ${baselinePct} %)` : ''}.
</p>
) : null}
{slotReviews.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
Keine Slot-Daten Backend-Stand prüfen oder erneut Graph bewerten und Übungen
matchen.
</p>
) : (
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '10px',
}}
>
{slotReviews.map((review) => (
<SlotReviewRow
key={`slot-review-${review.roadmap_major_step_index}`}
review={review}
selected={selected}
onToggle={toggle}
applying={applying}
/>
))}
</ul>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginTop: '16px',
justifyContent: 'flex-end',
}}
>
<button type="button" className="btn btn-secondary" disabled={applying} onClick={onClose}>
Abbrechen
</button>
<button
type="button"
className="btn btn-primary"
disabled={applying || selected.size === 0}
onClick={() => onApplySelected([...selected])}
>
{applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`}
</button>
</div>
</div>
</FormModalOverlay>
)
}

View File

@ -141,6 +141,44 @@ export function optimizationHintActionLabel(action) {
return OPTIMIZATION_ACTION_LABELS[action] || action || 'Hinweis' return OPTIMIZATION_ACTION_LABELS[action] || action || 'Hinweis'
} }
/** LLM-Empfehlungen von technischen Fix-Hinweisen trennen (QS-UI). */
export function splitPathQaHints(pathQa) {
const hints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : []
const fixHints = hints.filter((h) => String(h?.issue || '') !== 'llm_recommendation')
const highlightHints = hints.filter((h) => String(h?.issue || '') === 'llm_recommendation')
const recommendations = Array.isArray(pathQa?.recommendations) ? pathQa.recommendations : []
const highlightTexts = []
const seen = new Set()
for (const rec of recommendations) {
const text = String(rec || '').trim()
const key = text.toLowerCase()
if (text && !seen.has(key)) {
seen.add(key)
highlightTexts.push({ text, source: 'recommendation' })
}
}
for (const hint of highlightHints) {
const text = String(hint.reason || hint.title || '').trim()
const key = text.toLowerCase()
if (text && !seen.has(key)) {
seen.add(key)
highlightTexts.push({ text, source: 'hint', hint })
}
}
return { fixHints, highlightTexts }
}
export function pathQaQualityPercent(pathQa) {
if (pathQa?.quality_score == null || !Number.isFinite(Number(pathQa.quality_score))) return null
return Math.round(Number(pathQa.quality_score) * 100)
}
export function pathQaShowsStrongResult(pathQa) {
const pct = pathQaQualityPercent(pathQa)
if (pathQa?.overall_ok && pct != null && pct >= 85) return true
return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length)
}
/** Slot-Index aus optimization_hint (roadmap_major_step_index oder step_index). */ /** Slot-Index aus optimization_hint (roadmap_major_step_index oder step_index). */
export function resolveHintSlotIndex(hint, draft = null) { export function resolveHintSlotIndex(hint, draft = null) {
if (!hint || typeof hint !== 'object') return null if (!hint || typeof hint !== 'object') return null
@ -349,12 +387,29 @@ export function collectGapOffersFromApiResponse(res) {
} }
for (const offer of res?.gap_fill_offers || []) add(offer) for (const offer of res?.gap_fill_offers || []) add(offer)
for (const offer of res?.path_qa?.gap_fill_offers || []) add(offer) for (const offer of res?.path_qa?.gap_fill_offers || []) add(offer)
for (const step of res?.steps || []) { const stepSources = [
...(res?.steps || []),
...(res?.proposed_steps || []),
...(res?.proposed_steps_pipeline || []),
]
for (const step of stepSources) {
if (step?.gap_offer) add(step.gap_offer) if (step?.gap_offer) add(step.gap_offer)
} }
return out return out
} }
/** KI-Angebote aus einer oder mehreren Planungs-Antworten für leere Slots sammeln. */
export function mergeGapOffersForDraft(draft, ...responses) {
const collected = []
for (const res of responses) {
if (res) collected.push(...collectGapOffersFromApiResponse(res))
}
return filterGapOffersForUnfilledSlots(
draft,
dedupeGapOffersBySlot(collected, draft),
)
}
/** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */ /** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */
export function dedupeGapOffersBySlot(offers, draft) { export function dedupeGapOffersBySlot(offers, draft) {
const bySlot = new Map() const bySlot = new Map()
@ -780,6 +835,7 @@ export function hydrateProgressionGraphDraft({
progressionRoadmap: artifact?.progression_roadmap || null, progressionRoadmap: artifact?.progression_roadmap || null,
planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact), planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact),
lastFindings: artifact?.last_findings || null, lastFindings: artifact?.last_findings || null,
findingsStale: Boolean(artifact?.findings_stale),
primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [], primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
siblingEdgeIds: siblingEdges.map((e) => e.id), siblingEdgeIds: siblingEdges.map((e) => e.id),
dirty: false, dirty: false,
@ -816,6 +872,7 @@ export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined
const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings
if (findings) artifact.last_findings = findings if (findings) artifact.last_findings = findings
artifact.findings_stale = Boolean(draft.findingsStale)
return artifact return artifact
} }
@ -868,6 +925,431 @@ export function slotsToSlotAssignments(draft) {
})) }))
} }
/** Mindestens ein Bibliotheks-Slot belegt. */
export function draftHasLibrarySlotAssignments(draft) {
return slotsToSlotAssignments(draft).length >= 1
}
function normalizeCompareSlotTitle(title) {
return (title || '').trim().toLowerCase()
}
function stepsByMajorIndex(steps) {
const out = new Map()
for (const step of steps || []) {
if (step?.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
continue
}
out.set(Number(step.roadmap_major_step_index), step)
}
return out
}
function buildProgressionSlotDiffs(baselineSteps, proposedSteps) {
const baseBy = stepsByMajorIndex(baselineSteps)
const propBy = stepsByMajorIndex(proposedSteps)
const indices = new Set([...baseBy.keys(), ...propBy.keys()])
const diffs = []
for (const midx of [...indices].sort((a, b) => a - b)) {
const base = baseBy.get(midx) || {}
const prop = propBy.get(midx) || {}
const baseId = base.exercise_id
const propId = prop.exercise_id
if (baseId != null && propId != null && Number(baseId) === Number(propId)) continue
const baseTitle = (base.title || '').trim() || null
const propTitle = (prop.title || '').trim() || null
diffs.push({
roadmap_major_step_index: midx,
baseline_exercise_id: baseId != null ? Number(baseId) : null,
baseline_title: baseTitle,
proposed_exercise_id: propId != null ? Number(propId) : null,
proposed_title: propTitle,
baseline_slot_status: base.slot_status,
proposed_slot_status: prop.slot_status,
changed: baseId !== propId || baseTitle !== propTitle,
})
}
return diffs
}
function annotateCompareSlotDiffs(diffs) {
return (diffs || []).map((raw) => {
const bt = normalizeCompareSlotTitle(raw.baseline_title)
const pt = normalizeCompareSlotTitle(raw.proposed_title)
return {
...raw,
trivial_id_swap: Boolean(bt && pt && bt === pt),
}
})
}
function actionableCompareSlotDiffs(diffs) {
return (diffs || []).filter((d) => !d.trivial_id_swap)
}
/** fill = leerer Slot + Bibliotheks-Treffer; replace = bestehende Übung tauschen; gap_only = nur KI-Angebot. */
export function compareDiffKind(diff) {
if (!diff || diff.trivial_id_swap) return 'skip'
const hasBase = diff.baseline_exercise_id != null
const hasProp = diff.proposed_exercise_id != null
if (!hasBase && hasProp) return 'fill'
if (hasBase && hasProp) return 'replace'
if (!hasBase && !hasProp) return 'gap_only'
if (hasBase && !hasProp) return 'replace'
return 'skip'
}
export function qualityDeltaPercent(diff) {
const delta = diff?.quality_delta
if (delta == null || !Number.isFinite(Number(delta))) return null
return Math.round(Number(delta) * 100)
}
export function annotateCompareDiffKinds(diffs) {
return (diffs || []).map((d) => ({
...d,
diff_kind: compareDiffKind(d),
}))
}
export function slotFitScorePercent(score) {
if (score == null || !Number.isFinite(Number(score))) return null
return Math.round(Number(score) * 100)
}
export function slotReviewSelectionKey(midx, kind = 'library') {
return `${Number(midx)}:${kind}`
}
export function parseSlotReviewSelection(raw) {
if (raw == null) return null
const text = String(raw)
if (text.includes(':')) {
const [midxRaw, kind] = text.split(':')
const midx = Number(midxRaw)
if (!Number.isFinite(midx)) return null
return { midx, kind: kind === 'ai' ? 'ai' : 'library' }
}
const midx = Number(text)
if (!Number.isFinite(midx)) return null
return { midx, kind: 'library' }
}
/** Alle Slot-Reviews aus Match-Antwort (je Slot eine Zeile). */
export function compareSlotReviews(comparison) {
return Array.isArray(comparison?.slot_reviews) ? comparison.slot_reviews : []
}
/** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */
export function compareDiffsForDialog(comparison) {
const reviews = compareSlotReviews(comparison)
if (reviews.length > 0) return reviews
const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path)
if (fromSuggestions.length > 0) {
return fromSuggestions
.map((s) => ({ ...s, diff_kind: suggestionDiffKind(s) }))
.filter(
(d) =>
d.proposed_exercise_id != null
|| (d.suggestion_type === 'ai_gap' && d.gap_offer),
)
}
if (Array.isArray(comparison?.slot_diffs_improving)) {
return comparison.slot_diffs_improving.filter(
(d) => d?.proposed_exercise_id != null && !d?.trivial_id_swap,
)
}
const diffs = annotateCompareDiffKinds(
compareSlotDiffs(comparison, { actionableOnly: true }),
)
return diffs.filter(
(d) =>
(d.diff_kind === 'fill' || d.diff_kind === 'replace')
&& d.proposed_exercise_id != null,
)
}
export function defaultSelectedCompareDiffs(comparison) {
const reviews = compareSlotReviews(comparison)
if (reviews.length > 0) {
return reviews
.filter((review) => review?.library_alternative?.auto_select)
.map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library'))
}
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
}
export function suggestionDiffKind(suggestion) {
if (!suggestion) return 'skip'
if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap'
if (suggestion.baseline_exercise_id == null && suggestion.proposed_exercise_id != null) {
return 'fill'
}
if (suggestion.baseline_exercise_id != null && suggestion.proposed_exercise_id != null) {
return 'replace'
}
return 'skip'
}
export function recommendedCompareDiffs(comparison) {
return compareDiffsForDialog(comparison)
}
export function optionalReplaceCompareDiffs(comparison) {
return []
}
export function rejectedCompareDiffs(comparison) {
return Array.isArray(comparison?.slot_diffs_rejected)
? comparison.slot_diffs_rejected
: []
}
export function gapOnlyCompareDiffs(comparison) {
return annotateCompareDiffKinds(
compareSlotDiffs(comparison, { actionableOnly: true }),
).filter((d) => d.diff_kind === 'gap_only')
}
function mergeGapFillOffersFromSteps(steps, offers) {
const merged = (offers || []).map((o) => ({ ...o }))
const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean))
for (const step of steps || []) {
const go = step?.gap_offer
if (!go || typeof go !== 'object') continue
if (go.offer_id && seen.has(go.offer_id)) continue
if (go.offer_id) seen.add(go.offer_id)
merged.push({ ...go })
}
return merged
}
/**
* Vergleich aus unified_slot_review oder kaskadierten Antworten (Evaluate Match).
*/
export function buildProgressionComparePayload(baselineRes, proposedRes) {
if (proposedRes?.unified_slot_review) {
return buildUnifiedSlotReviewComparePayload(proposedRes, baselineRes)
}
const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : []
const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : []
const baselineQa = baselineRes?.path_qa || null
const pipelineQa = proposedRes?.path_qa || null
const scoring = proposedRes?.slot_diff_scoring
const rawDiffs = annotateCompareDiffKinds(
annotateCompareSlotDiffs(
buildProgressionSlotDiffs(baselineSteps, proposedSteps),
),
)
const improvingDiffs = annotateCompareDiffKinds(
(scoring?.improvement_diffs || []).filter((d) => d?.proposed_exercise_id != null),
)
const rejectedDiffs = annotateCompareDiffKinds(scoring?.rejected_diffs || [])
const dialogDiffs = improvingDiffs.length > 0
? improvingDiffs
: rawDiffs.filter(
(d) =>
!d.trivial_id_swap
&& (d.diff_kind === 'fill' || d.diff_kind === 'replace')
&& d.proposed_exercise_id != null
&& d.improves_path !== false,
)
const actionableDiffs = dialogDiffs
const gapFillOffers = mergeGapFillOffersFromSteps(
proposedSteps,
proposedRes?.gap_fill_offers || [],
)
const baselineScore = scoring?.baseline_quality_score ?? baselineQa?.quality_score
const proposedQa = baselineQa
return {
...proposedRes,
comparison_mode: true,
baseline_steps: baselineSteps,
baseline_path_qa: baselineQa,
proposed_steps: proposedSteps,
proposed_steps_pipeline: proposedSteps,
proposed_path_qa: proposedQa,
proposed_path_qa_pipeline: pipelineQa,
gap_fill_offers: gapFillOffers,
slot_diffs: rawDiffs,
slot_diffs_actionable: actionableDiffs,
slot_diffs_improving: improvingDiffs,
slot_diffs_rejected: rejectedDiffs,
slot_diffs_dialog: dialogDiffs,
slot_diffs_recommended: dialogDiffs,
slot_diff_count: dialogDiffs.length,
slot_diff_count_recommended: dialogDiffs.length,
slot_diff_count_rejected: rejectedDiffs.length,
slot_diff_count_including_trivial: rawDiffs.length,
slot_diffs_source: scoring ? 'incremental_scoring' : 'steps',
slot_diff_scoring: scoring,
baseline_quality_score: baselineScore,
path_qa: proposedQa,
steps: proposedSteps,
}
}
/** Einheitlicher Match-Review (Bewertung + Slot-Vorschläge in einem Lauf). */
export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) {
const baselineSteps = Array.isArray(baselineRes?.steps)
? baselineRes.steps
: (Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || []))
const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null
const scoring = res?.slot_diff_scoring
const slotReviews = compareSlotReviews(res)
const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : []
const improving = suggestions.filter((s) => s?.improves_path || s?.auto_select)
const rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : []
const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean)
const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []
const autoSelectCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length
return {
...res,
comparison_mode: true,
unified_slot_review: true,
baseline_steps: baselineSteps,
baseline_path_qa: baselineQa,
proposed_steps: proposedSteps,
proposed_steps_pipeline: proposedSteps,
proposed_path_qa: baselineQa,
proposed_path_qa_pipeline: null,
gap_fill_offers: gapFillOffers,
slot_reviews: slotReviews,
slot_suggestions: suggestions,
slot_diffs: improving,
slot_diffs_improving: improving,
slot_diffs_rejected: rejected,
slot_diffs_dialog: slotReviews.length > 0 ? slotReviews : improving,
slot_diffs_recommended: improving,
slot_diff_count: autoSelectCount || improving.length,
slot_diff_count_recommended: autoSelectCount || improving.length,
slot_diff_count_rejected: rejected.length,
slot_diffs_source: 'unified_slot_review',
slot_diff_scoring: scoring,
baseline_quality_score: scoring?.baseline_quality_score ?? baselineQa?.quality_score,
path_qa: baselineQa,
steps: baselineSteps,
}
}
function suggestionToApplyStep(suggestion) {
if (!suggestion || suggestion.roadmap_major_step_index == null) return null
const midx = Number(suggestion.roadmap_major_step_index)
if (suggestion.suggestion_type === 'ai_gap' && suggestion.gap_offer) {
const offer = suggestion.gap_offer
return {
roadmap_major_step_index: midx,
exercise_id: null,
title: offer.title_hint || suggestion.proposed_title || `Slot ${midx + 1}`,
is_ai_proposal: true,
proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`,
gap_offer: offer,
slot_status: 'ai_proposal',
}
}
if (suggestion.proposed_exercise_id == null) return null
return {
roadmap_major_step_index: midx,
exercise_id: suggestion.proposed_exercise_id,
title: suggestion.proposed_title,
slot_status: suggestion.proposed_slot_status || 'matched',
is_ai_proposal: false,
}
}
/** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */
export function applySelectedSlotSuggestions(draft, comparison, selectedKeys) {
const reviews = compareSlotReviews(comparison)
if (reviews.length > 0) {
const selected = new Set((selectedKeys || []).map((x) => String(x)))
const steps = []
for (const review of reviews) {
const midx = Number(review.roadmap_major_step_index)
const libKey = slotReviewSelectionKey(midx, 'library')
const aiKey = slotReviewSelectionKey(midx, 'ai')
if (selected.has(aiKey) && review.ai_alternative?.gap_offer) {
const offer = review.ai_alternative.gap_offer
steps.push({
roadmap_major_step_index: midx,
exercise_id: null,
title: offer.title_hint || review.ai_alternative.title_hint || `Slot ${midx + 1}`,
is_ai_proposal: true,
proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`,
gap_offer: offer,
slot_status: 'ai_proposal',
})
continue
}
if (selected.has(libKey) && review.library_alternative?.exercise_id != null) {
steps.push({
roadmap_major_step_index: midx,
exercise_id: review.library_alternative.exercise_id,
title: review.library_alternative.title,
slot_status: 'matched',
is_ai_proposal: false,
})
}
}
if (steps.length) return applyMatchStepsToSlots(draft, steps)
}
const selected = new Set(
(selectedKeys || [])
.map((x) => parseSlotReviewSelection(x)?.midx ?? Number(x))
.filter((x) => Number.isFinite(x)),
)
if (!selected.size) return draft
const legacySteps = (comparison?.slot_suggestions || [])
.filter((s) => selected.has(Number(s.roadmap_major_step_index)))
.map(suggestionToApplyStep)
.filter(Boolean)
if (!legacySteps.length) {
return applySelectedCompareSteps(
draft,
comparison?.proposed_steps || comparison?.steps,
selectedKeys,
)
}
return applyMatchStepsToSlots(draft, legacySteps)
}
/** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */
export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) {
if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) {
return comparison.slot_diffs_actionable
}
return Array.isArray(comparison?.slot_diffs) ? comparison.slot_diffs : []
}
/** Inhaltliche Abweichungen (nicht nur gleicher Titel, andere ID). */
export function compareResponseHasActionableSlotChanges(res) {
const count = res?.slot_diff_count
if (count != null) return Number(count) > 0
return compareSlotDiffs(res, { actionableOnly: true }).length > 0
}
/** Diff-Einträge nur für Slots, die vorher schon eine Bibliotheks-Übung hatten. */
export function curatedSlotDiffs(comparison, { actionableOnly = true } = {}) {
return compareSlotDiffs(comparison, { actionableOnly }).filter(
(d) => d?.baseline_exercise_id != null,
)
}
/** Vergleich würde eine bestehende Zuordnung inhaltlich ändern (Dialog bei Match). */
export function compareResponseHasCuratedSlotChanges(res) {
return curatedSlotDiffs(res, { actionableOnly: true }).length > 0
}
export function compareResponseHadRematchWithoutActionableDiffs(res) {
if (compareResponseHasActionableSlotChanges(res)) return false
const rematch = res?.proposed_path_qa_pipeline?.rematch_log
return Array.isArray(rematch) && rematch.length > 0
}
/** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */ /** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */
export function draftRetrievalBoostExerciseIds(draft) { export function draftRetrievalBoostExerciseIds(draft) {
const ids = new Set() const ids = new Set()
@ -986,15 +1468,59 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
if (!step) { if (!step) {
return base return base
} }
const mappedPrimary = mapStepToPrimary(step, slot)
const apiUnfilled =
step.exercise_id == null &&
(step.slot_status === 'unfilled' ||
step.roadmap_match_source === 'unfilled' ||
mappedPrimary.kind === 'empty')
if (
apiUnfilled &&
slot.primary?.kind === 'library' &&
slot.primary.exerciseId != null
) {
return base
}
return { return {
...base, ...base,
primary: mapStepToPrimary(step, slot), primary: mappedPrimary,
} }
}) })
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
} }
/** Vergleichs-Antwort: mindestens ein inhaltlicher Slot-Unterschied. */
export function compareResponseHasSlotChanges(res) {
return compareResponseHasActionableSlotChanges(res)
}
/** Nur ausgewählte Slots aus Optimierungs-Vorschlag übernehmen. */
export function applySelectedCompareSteps(draft, proposedSteps, selectedMajorIndices) {
const selected = new Set(
(selectedMajorIndices || [])
.map((x) => Number(x))
.filter((x) => Number.isFinite(x)),
)
if (!selected.size) return draft
const stepByMajor = new Map()
for (const step of proposedSteps || []) {
if (step?.roadmap_major_step_index == null) continue
stepByMajor.set(Number(step.roadmap_major_step_index), step)
}
const nextSlots = (draft.slots || []).map((slot) => {
const midx = Number(slot.majorStepIndex)
if (!selected.has(midx)) {
return { ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] }
}
const step = stepByMajor.get(midx)
if (!step) return slot
const patched = applyMatchStepsToSlots({ ...draft, slots: [slot] }, [step])
return patched.slots[0]
})
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
}
/** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */ /** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */
export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) { export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) {
let next = draft let next = draft