Merge pull request 'progression V2' (#57) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Reviewed-on: #57
This commit is contained in:
commit
ea7de64061
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
**Plattform-Rechtstexte (P-01, 0.8.95–0.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.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
|
||||
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
**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`**.
|
||||
|
||||
### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009)
|
||||
|
||||
**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR‑010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR‑013**).
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -745,12 +745,44 @@ def build_path_qa_summary(
|
|||
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
|
||||
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
|
||||
|
||||
|
||||
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__ = [
|
||||
"apply_llm_path_reorder",
|
||||
"build_path_qa_summary",
|
||||
"compute_deterministic_path_quality_score",
|
||||
"detect_off_topic_steps",
|
||||
"detect_path_gaps",
|
||||
"is_roadmap_planned_neighbor_pair",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,40 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
|||
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(
|
||||
*,
|
||||
stripped_off_topic: Sequence[Mapping[str, Any]],
|
||||
|
|
@ -80,6 +114,43 @@ def collect_rematch_slot_indices(
|
|||
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(
|
||||
steps_by_major: Mapping[int, Mapping[str, Any]],
|
||||
target_major: int,
|
||||
|
|
@ -178,6 +249,12 @@ def rematch_roadmap_slots(
|
|||
anchor_id=anchor_id,
|
||||
anchor_variant_id=anchor_variant_id,
|
||||
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")
|
||||
|
|
@ -186,12 +263,10 @@ def rematch_roadmap_slots(
|
|||
new_eid = int(new_step.get("exercise_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
new_eid = 0
|
||||
hist = (
|
||||
slot_assignment_history.get(int(major_idx), set())
|
||||
if slot_assignment_history
|
||||
else set()
|
||||
rejected = (
|
||||
rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
|
||||
)
|
||||
if new_eid > 0 and new_eid in hist:
|
||||
if new_eid > 0 and new_eid in rejected:
|
||||
new_step = None
|
||||
if new_step:
|
||||
steps_by_major[int(major_idx)] = new_step
|
||||
|
|
@ -207,6 +282,26 @@ def rematch_roadmap_slots(
|
|||
}
|
||||
)
|
||||
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()
|
||||
major = None
|
||||
if roadmap_ctx.roadmap:
|
||||
|
|
@ -278,6 +373,7 @@ def prune_stripped_after_rematch(
|
|||
|
||||
__all__ = [
|
||||
"collect_rematch_slot_indices",
|
||||
"filter_rematch_slot_indices",
|
||||
"prune_stripped_after_rematch",
|
||||
"rematch_roadmap_slots",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class GraphPlanningRoadmapArtifact(BaseModel):
|
|||
path_skill_expectations: Optional[Dict[str, Any]] = None
|
||||
slot_contents: Optional[List[SlotContentEntry]] = None
|
||||
last_findings: Optional[Dict[str, Any]] = None
|
||||
findings_stale: bool = Field(default=False)
|
||||
planning_catalog_context: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", "planning_catalog_context", mode="before")
|
||||
|
|
|
|||
31
backend/tests/test_planning_assignment_preservation.py
Normal file
31
backend/tests/test_planning_assignment_preservation.py
Normal 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)
|
||||
|
|
@ -45,3 +45,34 @@ def test_normalize_planning_roadmap_with_catalog_context():
|
|||
}
|
||||
)
|
||||
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")
|
||||
|
|
|
|||
127
backend/tests/test_planning_compare_slot_diffs.py
Normal file
127
backend/tests/test_planning_compare_slot_diffs.py
Normal 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
|
||||
21
backend/tests/test_planning_deterministic_quality_score.py
Normal file
21
backend/tests/test_planning_deterministic_quality_score.py
Normal 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
|
||||
39
backend/tests/test_planning_incremental_diff_scoring.py
Normal file
39
backend/tests/test_planning_incremental_diff_scoring.py
Normal 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"
|
||||
|
|
@ -214,6 +214,7 @@ def test_rematch_unfilled_leaves_placeholder_step():
|
|||
slot_indices={1},
|
||||
rematch_reasons={1: "stage_mismatch"},
|
||||
match_slot_fn=_no_match,
|
||||
rejected_by_major={1: {99}},
|
||||
)
|
||||
|
||||
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}]
|
||||
kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)])
|
||||
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}
|
||||
|
|
|
|||
130
backend/tests/test_planning_problematic_slots.py
Normal file
130
backend/tests/test_planning_problematic_slots.py
Normal 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"]
|
||||
|
|
@ -57,3 +57,15 @@ def test_normalize_slot_contents():
|
|||
)
|
||||
assert len(out["slot_contents"]) == 2
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-06-07
|
||||
**App-Version / DB-Schema:** App **`0.8.208`** (Planungs-KI Phase D); DB **`20260606086`** — maßgeblich **`backend/version.py`**.
|
||||
**Stand:** 2026-05-22
|
||||
**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, 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**.
|
||||
|
||||
|
|
@ -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` |
|
||||
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.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 |
|
||||
| **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`)
|
||||
- **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.
|
||||
|
||||
|
|
@ -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.215–216** |
|
||||
| **F8** | Editierbare `stage_specs` (Belastung, Erfolgskriterien) | ✅ **0.8.216** |
|
||||
| **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.225–0.8.230** |
|
||||
| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ **0.8.231–0.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):**
|
||||
1. UI-Wizard (Scroll-Monolith → 4 Schritte) — **separater UI-Chat**
|
||||
2. Graph-Erweiterungsmodus (Start ab Knoten)
|
||||
3. Trainingsplanung Phase G (Gruppenkontext)
|
||||
4. Kontext-Anzeige auf allen Pfad-Schritten
|
||||
1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri)
|
||||
2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor
|
||||
3. QS-UI — positive LLM-Hinweise als Highlights
|
||||
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**)
|
||||
|
||||
|
|
@ -256,11 +269,15 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
|
||||
### Planungs-KI (priorisiert)
|
||||
|
||||
1. **Phase F2:** LLM für Roadmap (Prompts **078**) + `roadmap_first` Retrieval aus `stage_specs`.
|
||||
2. **Phase F4:** Roadmap-Review UI (Major Steps editierbar vor Übungs-Match).
|
||||
3. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
|
||||
4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
||||
5. **Trainingsplanung G:** Kontext-Pack Gruppe/Historie — eigene Pipeline (`AI_PLANNING_KI_MULTISTAGE_FORECAST`); Mitai Workflow-Engine erst danach.
|
||||
1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung).
|
||||
2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest.
|
||||
2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
|
||||
3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale.
|
||||
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 G0–G4:** 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
|
||||
|
||||
|
|
|
|||
229
docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md
Normal file
229
docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md
Normal 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 | 2–4 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 (5–8 Snippets: 2–3 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; H1–H3 Rollout |
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Planungs-KI — Produkt-Roadmap
|
||||
|
||||
**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**.
|
||||
|
||||
|
|
@ -13,9 +13,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
|
|||
## Strategische Entscheidung (verbindlich)
|
||||
|
||||
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.
|
||||
3. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline später, **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0–S4.
|
||||
4. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`); Mitai Workflow-Engine **später**, wenn 2–3 Pipelines stabil sind.
|
||||
2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Katalog-Dimensionen, Start/Ziel, Roadmap, optional Graph-Kanten.
|
||||
3. **Drei Schichten statt monolithischem Vokabular:** Katalog (DB) · Technik-Disambiguierung (Code, nur bei Technik-Themen) · Didaktik (Roadmap + LLM-QS).
|
||||
4. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline (Phase G), **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0–S4 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
|
|||
| A–C2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
|
||||
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
|
||||
| E–E3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
|
||||
| **F0–F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap-first, UI Review | ✅ **0.8.204–209** |
|
||||
| **F0–F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap_first, UI Review | ✅ **0.8.204–209** |
|
||||
| **F5–F9** | Progressionsgraph | Start/Ziel, Gap-Prep, Skill-Expectations, Persistenz | ✅ **0.8.210–217** |
|
||||
| **F10** | Progressionsgraph | Stufen-Lernziel-Gate, kein blindes Rank-Fallback | ✅ **0.8.218** |
|
||||
| **F11–F12** | Progressionsgraph | Auto-Rematch, Spec-Refine, QS-Pipeline-Timing | ✅ **0.8.225–0.8.232** |
|
||||
| **F13–F14** | Progressionsgraph | Katalog-Kontext + GraphEditor-Workbench | ✅ **0.8.233** |
|
||||
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
|
||||
| **UX** | Progressionsgraph | Wizard/Stepper statt Scroll-UI | 🔲 |
|
||||
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0–S4 | 🔲 |
|
||||
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog |
|
||||
| **UX** | Progressionsgraph | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 |
|
||||
| **D′** | Progressionsgraph | Auto KI-Gap-Fill bei persistent leeren Slots | 🔲 Backlog |
|
||||
| **G** | Trainingsplanung | Kontext-Pack Gruppe/Historie, G0–G4 | 🔲 |
|
||||
| **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`
|
||||
- [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
|
||||
### F0–F9 — (Kurz, siehe Ist-Doku)
|
||||
|
||||
### 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.210–214) — Prompt **087**, Zwei-Schritt-UI
|
||||
- [x] F6 Gap-KI-Kontext (0.8.212–214) — `ExerciseGapFillPrepModal`
|
||||
- [x] F7 Fähigkeiten-Scoring (0.8.215–216) — `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
|
||||
- [x] Phase B: `micro_objectives` aus `development_arc` + Konsolidierung auf N
|
||||
- [x] Phase C: heuristische `stage_specs`
|
||||
- [ ] pytest für Konsolidierung
|
||||
### F10 — Stufen-Qualität (0.8.218)
|
||||
|
||||
### 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_*`)
|
||||
- [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)
|
||||
### F11 — Auto-Optimierung (0.8.225–0.8.230)
|
||||
|
||||
### 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
|
||||
- [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)
|
||||
### F12 — Pipeline-Timing & Sync (0.8.231–0.8.232)
|
||||
|
||||
### 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`
|
||||
- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
|
||||
- [x] API `roadmap_only` + `roadmap_override`
|
||||
### F13 — Katalog-Kontext (0.8.233)
|
||||
|
||||
### F5 — Start/Ziel (0.8.210–214)
|
||||
- [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`
|
||||
- [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“
|
||||
### F14 — GraphEditor Workbench (0.8.233)
|
||||
|
||||
### F6 — Gap-KI-Kontext (0.8.212–214)
|
||||
- [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
|
||||
- [x] `planning_exercise_form_context.py` — Gap-Snapshot, `context_preview`
|
||||
- [x] Migration **085** — `planning_context` in Übungs-Prompts
|
||||
### Validierung (Referenz Mae Geri, 2026-05)
|
||||
|
||||
### F7 — Fähigkeiten-Scoring (0.8.215–216)
|
||||
| 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`)
|
||||
- [x] Pro-Stufe-Retrieval + `path_skill_expectations` + UI-Tags
|
||||
- [x] `expected_skills` in Gap-Fill
|
||||
**Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht.
|
||||
|
||||
### 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)
|
||||
- [ ] Progressive disclosure — Details in Panels, nicht alles gleichzeitig
|
||||
- [ ] Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §10
|
||||
- [ ] Bei persistent `roadmap_unfilled` automatisch KI-Vorschlag vorbereiten (ohne manuelles Modal)
|
||||
- [ ] Governance: Trainer bestätigt vor Persistenz
|
||||
|
||||
---
|
||||
|
||||
## 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` S0–S4)
|
||||
- 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 G0–G4
|
||||
|
||||
| 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) | F11–F12 ✅ |
|
||||
| **G3** | Framework-Slot + Gap-Fill | G0, G1 |
|
||||
| **G4** | Gruppenkontext-Pack | G0–G3 |
|
||||
|
||||
**Nicht:** Roadmap-first-Loop 1:1 auf Trainingseinheit mappen.
|
||||
|
||||
Details: **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16 · Domäne **`DOMAIN_MODEL.md`** §1–2.
|
||||
|
||||
---
|
||||
|
||||
## 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 (5–8 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 G0–G4 stabil
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -112,10 +181,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
|
|||
|
||||
| Von | Nach | Hinweis |
|
||||
|-----|------|---------|
|
||||
| F2 | Enrichment / Skills | Bessere Roadmap bei technikspezifischen Skills |
|
||||
| F3 | F2 | LLM-Roadmap oder stabile heuristische B |
|
||||
| G | F4 | Trainingsplanung kann Roadmap aus Graph referenzieren |
|
||||
| H | G + F4 | Workflow-Engine lohnt bei verzweigten Planungsflows |
|
||||
| F13 | G0, **H1** | Katalog-Kontext in Einheitsplanung; Snippets in LLM-Prompts |
|
||||
| F7, F11 | G1, G2 | Skill-Expectations + QS-Muster |
|
||||
| F4, F9 | G3 | Graph-Roadmap kann Rahmen referenzieren |
|
||||
| G | H | Workflow-Engine lohnt bei verzweigten Planungsflows |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Progressionsgraph — KI-Planung (Ist-Stand)
|
||||
|
||||
**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.218** · **DB:** Migration **088**
|
||||
**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`)
|
||||
**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`, `MODULE_VERSIONS.planning_exercise_suggest`)
|
||||
|
||||
> **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.
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` (Zielarchitektur) ·
|
||||
`.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` (Retrieval/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+)
|
||||
|
||||
---
|
||||
|
|
@ -30,19 +31,20 @@
|
|||
|
||||
## 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)
|
||||
② „Start/Ziel analysieren“ (optional, start_target_only)
|
||||
③ „Roadmap vorschlagen“ (roadmap_only, LLM-Roadmap)
|
||||
① Ziel eingeben (+ Planungskontext: Primärfokus, Stil, Trainingsstil, Zielgruppe)
|
||||
② Optional: Start/Ziel-Felder manuell oder „Start/Ziel analysieren“
|
||||
③ „Roadmap generieren“ (roadmap_only, LLM-Roadmap)
|
||||
④ Roadmap bearbeiten (Major Steps + Stufen-Details)
|
||||
⑤ „Übungen matchen“ (roadmap_first + roadmap_override)
|
||||
⑥ Lücken mit KI schließen (gap_fill_offers + Vorbereitungs-Dialog)
|
||||
⑦ „Pfad in Graph speichern“ (Sequenz-Kanten)
|
||||
⑤ „Übungen matchen“ (roadmap_first + roadmap_override + Auto-QS/Rematch)
|
||||
⑥ Lücken: KI-Angebote → „KI anlegen“ (Gap-Prep-Modal) → in Slot
|
||||
⑦ „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
|
||||
flowchart TB
|
||||
subgraph ui [Frontend]
|
||||
PGE[ProgressionGraphEditor]
|
||||
EPB[ExerciseProgressionPathBuilder]
|
||||
GFM[ExerciseGapFillPrepModal]
|
||||
PCtx[planningContextForExerciseAi.js]
|
||||
|
|
@ -71,6 +74,10 @@ flowchart TB
|
|||
|
||||
subgraph match [Match + QA]
|
||||
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]
|
||||
PG[planning_exercise_progression.py]
|
||||
SEM[planning_exercise_semantics.py]
|
||||
|
|
@ -88,11 +95,16 @@ flowchart TB
|
|||
end
|
||||
|
||||
EPB --> PPS
|
||||
EPB --> SEQ
|
||||
EPB --> PUT
|
||||
PGE --> PPS
|
||||
PGE --> SEQ
|
||||
PGE --> PUT
|
||||
GFM --> EAI
|
||||
PPS --> PR
|
||||
PPS --> PB
|
||||
PB --> PCC
|
||||
PB --> REM
|
||||
PB --> REF
|
||||
PB --> QAP
|
||||
PB --> RET
|
||||
PB --> PG
|
||||
PB --> PSE
|
||||
|
|
@ -108,12 +120,18 @@ flowchart TB
|
|||
| Modul | Aufgabe |
|
||||
|--------|---------|
|
||||
| `planning_progression_roadmap.py` | Phasen A–C: 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_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_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) |
|
||||
| `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 |
|
||||
| `roadmap_override` | object | Trainer-bearbeitete `major_steps` + `stage_specs` |
|
||||
| `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_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_path_qa`, `include_ai_gap_fill` | bool | QS, Lücken-Angebote |
|
||||
| `include_llm_intent` | bool | LLM Intent für Semantic Brief |
|
||||
| `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 0–4 (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
|
||||
|
||||
|
|
@ -144,7 +166,29 @@ flowchart TB
|
|||
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
|
||||
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
|
||||
| `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)
|
||||
|
||||
|
|
@ -157,17 +201,32 @@ flowchart TB
|
|||
|
||||
---
|
||||
|
||||
## 5. Roadmap-Match — Stufen-Qualität (0.8.218)
|
||||
## 5. Roadmap-Match — Stufen-Qualität (0.8.218–0.8.233)
|
||||
|
||||
Pro Major Step gilt:
|
||||
|
||||
1. **Stufen-Brief** — `semantic_brief_for_stage()` ergänzt `must_phrases` um das Stufen-Lernziel.
|
||||
2. **Stufen-Gate** — `exercise_passes_stage_learning_goal_gate()` prüft Lernziel-Text in Titel/Summary/Ziel oder Mindest-`semantic_score`.
|
||||
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.
|
||||
4. **Retrieval** — Bonus/Strafe im Hybrid-Score je nach Stufen-Passung.
|
||||
5. **QS** — `detect_off_topic_steps` erkennt `stage_mismatch` anhand `roadmap_learning_goal`.
|
||||
1. **Stufen-Brief** — `build_stage_match_brief()` aus Lernziel, `anti_patterns`, Erfolgskriterien, Pfad-Kontext.
|
||||
2. **Stufen-Gate** — `exercise_passes_stage_fit()` / `exercise_passes_stage_learning_goal_gate()` auf vollem Übungstext.
|
||||
3. **Kein blindes Rank-Fallback** — ohne Gate-Passung → `roadmap_unfilled`, nicht themenfremde Übung.
|
||||
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. **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": "…",
|
||||
"max_steps": 5,
|
||||
"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.215–216 |
|
||||
| F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 |
|
||||
| F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 |
|
||||
| F10 | Stufen-Lernziel-Gate beim Match (kein goal_query-Fallback) | ✅ | 0.8.218 |
|
||||
| **G** | Trainingsplanung eigene Pipeline + Graph-Referenz | 🔲 | — |
|
||||
| **UX** | Wizard/Stepper statt Scroll-Monolith | 🔲 | separater Chat |
|
||||
| F10 | Stufen-Lernziel-Gate + kein goal_query-Fallback | ✅ | 0.8.218 |
|
||||
| **F11** | Auto-Rematch + Stufen-Spec-Refine + mehrstufige QS | ✅ | 0.8.225–0.8.230 |
|
||||
| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.231–0.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)
|
||||
|
||||
1. **UI-Überarbeitung** — Wizard mit 4 Schritten, progressive disclosure (Briefing unten)
|
||||
2. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
|
||||
3. **Kontext auf allen Pfad-Schritten** in UI (nicht nur Lücken)
|
||||
4. **Trainingsplanung Phase G** — Pipeline mit Gruppenkontext, Wiederverwendung `planning_skill_expectations`
|
||||
5. Enrichment / Prompt-Feintuning
|
||||
6. Mitai Workflow-Engine (langfristig)
|
||||
1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
|
||||
2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren
|
||||
2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
|
||||
3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“
|
||||
4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert
|
||||
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` S0–S4)
|
||||
- **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`** §1–2 (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_progression_graph_planning_artifact.py` | JSONB-Artefakt-Validierung |
|
||||
| `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 |
|
||||
|-------|----------|
|
||||
| 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5–F9 |
|
||||
| 2026-05-22 | F11–F14: Auto-Optimierung, Katalog-Kontext, GraphEditor, Mae-Geri-Validierung, Phase-G-Wiederverwendung §16 |
|
||||
|
|
|
|||
|
|
@ -7,6 +7,19 @@ import api from '../utils/api'
|
|||
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
|
||||
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||
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 {
|
||||
aiPreviewToQuickCreateDraft,
|
||||
buildQuickCreateAiPreview,
|
||||
|
|
@ -449,9 +462,13 @@ function buildPlanningRoadmapArtifactSnapshot({
|
|||
maxSteps,
|
||||
progressionRoadmap,
|
||||
pathSkillExpectations,
|
||||
planningCatalogContext,
|
||||
}) {
|
||||
const q = (goalQuery || '').trim()
|
||||
if (!q && !progressionRoadmap) return null
|
||||
const catalogPayload = planningCatalogContextToApi(
|
||||
planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||
)
|
||||
return {
|
||||
schema_version: PLANNING_ARTIFACT_SCHEMA,
|
||||
goal_query: q,
|
||||
|
|
@ -461,6 +478,9 @@ function buildPlanningRoadmapArtifactSnapshot({
|
|||
max_steps: Number(maxSteps) || 5,
|
||||
progression_roadmap: progressionRoadmap || 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 loading = loadingRoadmap || loadingStartTarget || loadingMatch
|
||||
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 [generatingOfferId, setGeneratingOfferId] = useState(null)
|
||||
|
||||
|
|
@ -571,6 +597,22 @@ export default function ExerciseProgressionPathBuilder({
|
|||
[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(
|
||||
() =>
|
||||
buildPlanningRoadmapArtifactSnapshot({
|
||||
|
|
@ -581,6 +623,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
maxSteps,
|
||||
progressionRoadmap,
|
||||
pathSkillExpectations,
|
||||
planningCatalogContext,
|
||||
}),
|
||||
[
|
||||
goalQuery,
|
||||
|
|
@ -590,6 +633,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
maxSteps,
|
||||
progressionRoadmap,
|
||||
pathSkillExpectations,
|
||||
planningCatalogContext,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -634,6 +678,9 @@ export default function ExerciseProgressionPathBuilder({
|
|||
if (art.roadmap_notes) setRoadmapNotes(String(art.roadmap_notes))
|
||||
if (art.max_steps) setMaxSteps(Number(art.max_steps))
|
||||
if (art.path_skill_expectations) setPathSkillExpectations(art.path_skill_expectations)
|
||||
if (art.planning_catalog_context) {
|
||||
setPlanningCatalogContext(parsePlanningCatalogContextFromArtifact(art))
|
||||
}
|
||||
if (art.progression_roadmap) {
|
||||
setProgressionRoadmap(art.progression_roadmap)
|
||||
const majors = mapMajorStepsFromApi(art.progression_roadmap)
|
||||
|
|
@ -670,16 +717,25 @@ export default function ExerciseProgressionPathBuilder({
|
|||
let cancelled = false
|
||||
Promise.all([
|
||||
api.listFocusAreas({ status: 'active' }),
|
||||
api.listStyleDirections({ status: 'active' }),
|
||||
api.listTrainingTypes({ status: 'active' }),
|
||||
api.listTargetGroups({ status: 'active' }),
|
||||
api.listSkillsCatalog({ status: 'active' }),
|
||||
])
|
||||
.then(([fa, sk]) => {
|
||||
.then(([fa, sd, tt, tg, sk]) => {
|
||||
if (cancelled) return
|
||||
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 : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFocusAreas([])
|
||||
setStyleDirections([])
|
||||
setTrainingTypes([])
|
||||
setTargetGroups([])
|
||||
setSkillsCatalog([])
|
||||
}
|
||||
})
|
||||
|
|
@ -1095,6 +1151,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
start_target_only: true,
|
||||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
|
||||
...catalogApiPayload,
|
||||
})
|
||||
applyStartTargetResponse(res)
|
||||
} catch (e) {
|
||||
|
|
@ -1133,6 +1190,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
roadmap_only: true,
|
||||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
|
||||
...catalogApiPayload,
|
||||
})
|
||||
const majors = mapMajorStepsFromApi(res?.progression_roadmap)
|
||||
if (majors.length < 2) {
|
||||
|
|
@ -1190,6 +1248,22 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setError('')
|
||||
try {
|
||||
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({
|
||||
query: q,
|
||||
max_steps: validSteps.length,
|
||||
|
|
@ -1202,8 +1276,24 @@ export default function ExerciseProgressionPathBuilder({
|
|||
include_llm_roadmap: false,
|
||||
roadmap_first: true,
|
||||
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),
|
||||
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
|
||||
...catalogApiPayload,
|
||||
})
|
||||
applyPathMatchResponse(res, q)
|
||||
setMaxSteps(validSteps.length)
|
||||
|
|
@ -1406,6 +1496,16 @@ export default function ExerciseProgressionPathBuilder({
|
|||
/>
|
||||
</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 }}>
|
||||
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.
|
||||
|
|
@ -1826,11 +1926,40 @@ export default function ExerciseProgressionPathBuilder({
|
|||
>
|
||||
<strong>
|
||||
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||
{pathQa.quality_score != null ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)` : ''}
|
||||
{pathQaQualityPercent(pathQa) != null ? ` (${pathQaQualityPercent(pathQa)} %)` : ''}
|
||||
</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 ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
||||
) : 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 ? (
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||
{pathQa.issues.slice(0, 4).map((issue) => (
|
||||
|
|
@ -1838,6 +1967,21 @@ export default function ExerciseProgressionPathBuilder({
|
|||
))}
|
||||
</ul>
|
||||
) : 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 ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
|
||||
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
|
||||
|
|
|
|||
99
frontend/src/components/PlanningCatalogContextFields.jsx
Normal file
99
frontend/src/components/PlanningCatalogContextFields.jsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,8 +10,11 @@ import {
|
|||
formatRematchLogEntry,
|
||||
formatRefineLogEntry,
|
||||
hasRematchSlotHints,
|
||||
pathQaQualityPercent,
|
||||
pathQaShowsStrongResult,
|
||||
resolveHintSlotIndex,
|
||||
resolveOfferSlotIndex,
|
||||
splitPathQaHints,
|
||||
} from '../utils/progressionGraphDraft'
|
||||
|
||||
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({
|
||||
offer,
|
||||
slotCount,
|
||||
|
|
@ -159,21 +287,36 @@ export default function ProgressionFindingsPanel({
|
|||
onInsertGapSlot,
|
||||
onGenerateGapAi,
|
||||
onRematchSlots = null,
|
||||
onOptimizeCompare = null,
|
||||
optimizationPreviewQa = null,
|
||||
optimizationPreviewFairQa = null,
|
||||
canOptimizeCompare = false,
|
||||
optimizeCompareBusy = false,
|
||||
rematchBusy = false,
|
||||
generatingOfferId = null,
|
||||
aiBusy = 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 refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : []
|
||||
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 (
|
||||
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
|
||||
<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>
|
||||
|
||||
<button
|
||||
|
|
@ -192,6 +335,24 @@ export default function ProgressionFindingsPanel({
|
|||
</p>
|
||||
) : 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 ? (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -204,13 +365,56 @@ export default function ProgressionFindingsPanel({
|
|||
>
|
||||
<strong>
|
||||
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||
{pathQa.quality_score != null
|
||||
? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)`
|
||||
: ''}
|
||||
{qualityPct != null ? ` (${qualityPct} %)` : ''}
|
||||
</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 ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
||||
) : 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 ? (
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||
{pathQa.issues.map((issue) => (
|
||||
|
|
@ -218,7 +422,9 @@ export default function ProgressionFindingsPanel({
|
|||
))}
|
||||
</ul>
|
||||
) : 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>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||
|
|
@ -265,7 +471,7 @@ export default function ProgressionFindingsPanel({
|
|||
{optimizationHints.length > 0 ? (
|
||||
<>
|
||||
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
|
||||
Optimierungspotenziale ({optimizationHints.length})
|
||||
Handlungsbedarf ({optimizationHints.length})
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
|
|
@ -303,7 +509,18 @@ export default function ProgressionFindingsPanel({
|
|||
)
|
||||
})}
|
||||
</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
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
|
|
@ -316,6 +533,15 @@ export default function ProgressionFindingsPanel({
|
|||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{optimizationPreviewQa ? (
|
||||
<PathQaPipelineDetails
|
||||
pathQa={optimizationPreviewQa}
|
||||
fairQa={optimizationPreviewFairQa}
|
||||
draft={draft}
|
||||
title="3-Stufen-Optimierung (Vorschlag — nur im Vergleichsdialog)"
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import ExercisePickerModal from './ExercisePickerModal'
|
|||
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||
import ProgressionSlotCard from './ProgressionSlotCard'
|
||||
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
||||
import PlanningCatalogContextFields from './PlanningCatalogContextFields'
|
||||
import {
|
||||
aiPreviewToQuickCreateDraft,
|
||||
buildQuickCreateAiPreview,
|
||||
|
|
@ -21,34 +22,45 @@ import {
|
|||
initialStageLearningGoalFromOffer,
|
||||
} from '../utils/planningContextForExerciseAi'
|
||||
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
||||
import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal'
|
||||
import {
|
||||
addSlotToDraft,
|
||||
applyEvaluateResponseToDraft,
|
||||
applyGapOfferToDraft,
|
||||
applyMatchResponseToDraft,
|
||||
applySelectedCompareSteps,
|
||||
applySelectedSlotSuggestions,
|
||||
applyResolvedStructuredToDraft,
|
||||
buildPlanningArtifactFromDraft,
|
||||
buildProgressionComparePayload,
|
||||
collectGapOffersFromApiResponse,
|
||||
compareSlotDiffs,
|
||||
compareDiffsForDialog,
|
||||
dedupeGapOffersBySlot,
|
||||
draftHasLibrarySlotAssignments,
|
||||
draftRetrievalBoostExerciseIds,
|
||||
EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||
filterGapOffersForUnfilledSlots,
|
||||
hydrateProgressionGraphDraft,
|
||||
SLOT_MIN,
|
||||
insertSlotInDraft,
|
||||
librarySlotExercise,
|
||||
majorStepsToOverridePayload,
|
||||
mergeGapOffersForDraft,
|
||||
moveSlotInDraft,
|
||||
patchSlotInDraft,
|
||||
pathQaQualityPercent,
|
||||
planningCatalogContextToApi,
|
||||
rejectedCompareDiffs,
|
||||
removeSlotFromDraft,
|
||||
saveProgressionGraphDraft,
|
||||
setCatalogSelectItems,
|
||||
setSlotPrimaryLibrary,
|
||||
SLOT_MAX,
|
||||
SLOT_MIN,
|
||||
slotsAsPathStepRows,
|
||||
slotsToEvaluateSteps,
|
||||
draftRetrievalBoostExerciseIds,
|
||||
slotsToSlotAssignments,
|
||||
syncProgressionRoadmapFromSlots,
|
||||
syncSlotPhasesFromRoadmap,
|
||||
EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||
getCatalogSelectId,
|
||||
planningCatalogContextToApi,
|
||||
setCatalogSelectItems,
|
||||
} from '../utils/progressionGraphDraft'
|
||||
|
||||
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
||||
|
|
@ -111,6 +123,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
const [slotQuickSaving, setSlotQuickSaving] = useState(false)
|
||||
const [slotQuickError, setSlotQuickError] = 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 () => {
|
||||
if (!graphId) return
|
||||
|
|
@ -182,7 +200,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
setDraft((prev) => {
|
||||
if (!prev) return 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 },
|
||||
roadmap,
|
||||
)
|
||||
return { ...structured, dirty: true }
|
||||
return { ...structured, dirty: true, findingsStale: true }
|
||||
})
|
||||
setStartTargetReady(true)
|
||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||
|
|
@ -415,7 +433,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
graphName: draft.graphName,
|
||||
})
|
||||
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)
|
||||
} catch (e) {
|
||||
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 q = (draft?.goalQuery || '').trim()
|
||||
if (q.length < 3) {
|
||||
|
|
@ -439,65 +600,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
setMatchNotice('')
|
||||
try {
|
||||
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||
const override = majorStepsToOverridePayload(synced.slots)
|
||||
const res = await api.suggestProgressionPath({
|
||||
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)
|
||||
}
|
||||
setProposedPathQa(null)
|
||||
await runMatchCompareFlow(synced, { source: 'match' })
|
||||
} catch (e) {
|
||||
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
||||
} 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 q = (draft?.goalQuery || '').trim()
|
||||
if (q.length < 3) {
|
||||
|
|
@ -513,30 +672,14 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
}
|
||||
setEvaluating(true)
|
||||
setActionErr('')
|
||||
setProposedPathQa(null)
|
||||
try {
|
||||
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||
const override =
|
||||
validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined
|
||||
const res = await api.suggestProgressionPath({
|
||||
query: q,
|
||||
max_steps: synced.slots.length || draft.maxSteps || 5,
|
||||
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)
|
||||
const res = await fetchPathEvaluate(synced)
|
||||
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res)
|
||||
setDraft(evaluated)
|
||||
const mergedOffers = mergeGapOffersForDraft(evaluated, res)
|
||||
setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : remainingOffers)
|
||||
} catch (e) {
|
||||
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
||||
} finally {
|
||||
|
|
@ -563,7 +706,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
const handleApplyGapOffer = (offer, slotIndex) => {
|
||||
setDraft((prev) => {
|
||||
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))
|
||||
}
|
||||
|
|
@ -575,7 +718,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
}
|
||||
setDraft((prev) => {
|
||||
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))
|
||||
}
|
||||
|
|
@ -689,7 +832,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
: null
|
||||
if (resolvedSlot != null) {
|
||||
setSlotQuickCreateIndex(resolvedSlot)
|
||||
setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }))
|
||||
setDraft((prev) => ({
|
||||
...applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }),
|
||||
findingsStale: true,
|
||||
}))
|
||||
}
|
||||
setSlotQuickCreateDraft(aiDraft)
|
||||
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 created = await api.createExercise(payload)
|
||||
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)
|
||||
setSlotQuickCreateIndex(null)
|
||||
setActiveOffer(null)
|
||||
|
|
@ -909,83 +1059,16 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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={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>
|
||||
<PlanningCatalogContextFields
|
||||
catalogCtx={catalogCtx}
|
||||
onPatchDimension={patchCatalogDimension}
|
||||
focusAreas={focusAreas}
|
||||
styleDirections={styleDirections}
|
||||
trainingTypes={trainingTypes}
|
||||
targetGroups={targetGroups}
|
||||
disabled={busy}
|
||||
helperText="Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert."
|
||||
/>
|
||||
<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,
|
||||
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"
|
||||
disabled={busy || matching}
|
||||
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'}
|
||||
</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}>
|
||||
{busy ? 'Speichern…' : 'Graph speichern'}
|
||||
</button>
|
||||
|
|
@ -1077,11 +1176,15 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
slotCount={draft.slots.length}
|
||||
loading={evaluating}
|
||||
error=""
|
||||
evaluationStale={Boolean(draft?.findingsStale)}
|
||||
onEvaluate={runEvaluate}
|
||||
onApplyGapOffer={handleApplyGapOffer}
|
||||
onInsertGapSlot={handleInsertGapSlot}
|
||||
onGenerateGapAi={openGapFillPrep}
|
||||
onRematchSlots={runMatch}
|
||||
onOptimizeCompare={runOptimizeCompare}
|
||||
canOptimizeCompare={validMajorSteps.length >= 2}
|
||||
optimizeCompareBusy={comparing}
|
||||
rematchBusy={matching}
|
||||
generatingOfferId={generatingOfferId}
|
||||
aiBusy={gapAiBusy}
|
||||
|
|
@ -1121,6 +1224,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
zIndex={2100}
|
||||
/>
|
||||
|
||||
<ProgressionOptimizeCompareModal
|
||||
open={compareOpen}
|
||||
comparison={comparePayload}
|
||||
mode={compareSource}
|
||||
onClose={() => {
|
||||
if (compareApplying) return
|
||||
setCompareOpen(false)
|
||||
setComparePayload(null)
|
||||
setProposedPathQa(null)
|
||||
}}
|
||||
onApplySelected={applyOptimizeCompare}
|
||||
applying={compareApplying}
|
||||
/>
|
||||
|
||||
<ExerciseGapFillPrepModal
|
||||
open={gapPrepOpen}
|
||||
offer={activeOffer}
|
||||
|
|
|
|||
356
frontend/src/components/ProgressionOptimizeCompareModal.jsx
Normal file
356
frontend/src/components/ProgressionOptimizeCompareModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -141,6 +141,44 @@ export function optimizationHintActionLabel(action) {
|
|||
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). */
|
||||
export function resolveHintSlotIndex(hint, draft = 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?.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)
|
||||
}
|
||||
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. */
|
||||
export function dedupeGapOffersBySlot(offers, draft) {
|
||||
const bySlot = new Map()
|
||||
|
|
@ -780,6 +835,7 @@ export function hydrateProgressionGraphDraft({
|
|||
progressionRoadmap: artifact?.progression_roadmap || null,
|
||||
planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact),
|
||||
lastFindings: artifact?.last_findings || null,
|
||||
findingsStale: Boolean(artifact?.findings_stale),
|
||||
primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
|
||||
siblingEdgeIds: siblingEdges.map((e) => e.id),
|
||||
dirty: false,
|
||||
|
|
@ -816,6 +872,7 @@ export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined
|
|||
|
||||
const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings
|
||||
if (findings) artifact.last_findings = findings
|
||||
artifact.findings_stale = Boolean(draft.findingsStale)
|
||||
|
||||
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). */
|
||||
export function draftRetrievalBoostExerciseIds(draft) {
|
||||
const ids = new Set()
|
||||
|
|
@ -986,15 +1468,59 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
|
|||
if (!step) {
|
||||
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 {
|
||||
...base,
|
||||
primary: mapStepToPrimary(step, slot),
|
||||
primary: mappedPrimary,
|
||||
}
|
||||
})
|
||||
|
||||
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. */
|
||||
export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) {
|
||||
let next = draft
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user