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).
|
**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)
|
**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.
|
**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)
|
### 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**).
|
**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"
|
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
|
||||||
for o in off_topic
|
for o in off_topic
|
||||||
]
|
]
|
||||||
|
summary["quality_score"] = compute_deterministic_path_quality_score(
|
||||||
|
gaps=gaps,
|
||||||
|
off_topic_steps=off_topic,
|
||||||
|
steps=steps,
|
||||||
|
multistage_qa=multistage_qa,
|
||||||
|
)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def compute_deterministic_path_quality_score(
|
||||||
|
*,
|
||||||
|
gaps: Sequence[Mapping[str, Any]],
|
||||||
|
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||||
|
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
|
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> float:
|
||||||
|
"""Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche."""
|
||||||
|
score = 0.92
|
||||||
|
score -= 0.08 * len(off_topic_steps or [])
|
||||||
|
score -= 0.05 * len(gaps or [])
|
||||||
|
if steps:
|
||||||
|
empty = sum(
|
||||||
|
1
|
||||||
|
for s in steps
|
||||||
|
if isinstance(s, dict)
|
||||||
|
and s.get("exercise_id") is None
|
||||||
|
and not s.get("is_ai_proposal")
|
||||||
|
)
|
||||||
|
score -= 0.06 * empty
|
||||||
|
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
|
||||||
|
score -= min(0.14, 0.02 * hint_count)
|
||||||
|
return max(0.35, min(0.98, round(score, 4)))
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"apply_llm_path_reorder",
|
"apply_llm_path_reorder",
|
||||||
"build_path_qa_summary",
|
"build_path_qa_summary",
|
||||||
|
"compute_deterministic_path_quality_score",
|
||||||
"detect_off_topic_steps",
|
"detect_off_topic_steps",
|
||||||
"detect_path_gaps",
|
"detect_path_gaps",
|
||||||
"is_roadmap_planned_neighbor_pair",
|
"is_roadmap_planned_neighbor_pair",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,40 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
|
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_priority_for_rematch(
|
||||||
|
body,
|
||||||
|
*,
|
||||||
|
major_idx: int,
|
||||||
|
old: Optional[Mapping[str, Any]],
|
||||||
|
rejected_by_major: Optional[Mapping[int, Set[int]]],
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Bestehende Slot-Zuordnung beim Rematch bevorzugen — außer explizit abgelehnt."""
|
||||||
|
priority_id: Optional[int] = None
|
||||||
|
if body is not None:
|
||||||
|
for raw in getattr(body, "slot_assignments", None) or []:
|
||||||
|
midx = getattr(raw, "roadmap_major_step_index", None)
|
||||||
|
if midx is None or int(midx) != int(major_idx):
|
||||||
|
continue
|
||||||
|
eid = getattr(raw, "exercise_id", None)
|
||||||
|
if eid is not None:
|
||||||
|
try:
|
||||||
|
priority_id = int(eid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
priority_id = None
|
||||||
|
break
|
||||||
|
if priority_id is None and old and old.get("exercise_id") is not None:
|
||||||
|
try:
|
||||||
|
priority_id = int(old["exercise_id"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
priority_id = None
|
||||||
|
if priority_id is None or priority_id < 1:
|
||||||
|
return None
|
||||||
|
rejected = rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
|
||||||
|
if priority_id in rejected:
|
||||||
|
return None
|
||||||
|
return priority_id
|
||||||
|
|
||||||
|
|
||||||
def collect_rematch_slot_indices(
|
def collect_rematch_slot_indices(
|
||||||
*,
|
*,
|
||||||
stripped_off_topic: Sequence[Mapping[str, Any]],
|
stripped_off_topic: Sequence[Mapping[str, Any]],
|
||||||
|
|
@ -80,6 +114,43 @@ def collect_rematch_slot_indices(
|
||||||
return indices, reasons
|
return indices, reasons
|
||||||
|
|
||||||
|
|
||||||
|
def filter_rematch_slot_indices(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
slot_indices: Set[int],
|
||||||
|
*,
|
||||||
|
stripped_off_topic: Sequence[Mapping[str, Any]],
|
||||||
|
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||||
|
) -> Set[int]:
|
||||||
|
"""Trainer-Zuordnungen (slot_best_match) nicht rematchen, außer Slot ist explizit beanstandet."""
|
||||||
|
flagged: Set[int] = set()
|
||||||
|
for item in list(stripped_off_topic or []) + list(off_topic_steps or []):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
midx = item.get("roadmap_major_step_index")
|
||||||
|
if midx is not None:
|
||||||
|
try:
|
||||||
|
flagged.add(int(midx))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
preserved: Set[int] = set()
|
||||||
|
for raw in steps or []:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
midx = raw.get("roadmap_major_step_index")
|
||||||
|
if midx is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
major_idx = int(midx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if raw.get("roadmap_match_source") == "slot_best_match" or raw.get("slot_status") == "preserved":
|
||||||
|
if major_idx not in flagged:
|
||||||
|
preserved.add(major_idx)
|
||||||
|
|
||||||
|
return {idx for idx in slot_indices if idx not in preserved}
|
||||||
|
|
||||||
|
|
||||||
def _context_before_major(
|
def _context_before_major(
|
||||||
steps_by_major: Mapping[int, Mapping[str, Any]],
|
steps_by_major: Mapping[int, Mapping[str, Any]],
|
||||||
target_major: int,
|
target_major: int,
|
||||||
|
|
@ -178,6 +249,12 @@ def rematch_roadmap_slots(
|
||||||
anchor_id=anchor_id,
|
anchor_id=anchor_id,
|
||||||
anchor_variant_id=anchor_variant_id,
|
anchor_variant_id=anchor_variant_id,
|
||||||
used=used,
|
used=used,
|
||||||
|
slot_priority_exercise_id=_slot_priority_for_rematch(
|
||||||
|
body,
|
||||||
|
major_idx=major_idx,
|
||||||
|
old=old,
|
||||||
|
rejected_by_major=rejected_by_major,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot")
|
reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot")
|
||||||
|
|
@ -186,12 +263,10 @@ def rematch_roadmap_slots(
|
||||||
new_eid = int(new_step.get("exercise_id") or 0)
|
new_eid = int(new_step.get("exercise_id") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
new_eid = 0
|
new_eid = 0
|
||||||
hist = (
|
rejected = (
|
||||||
slot_assignment_history.get(int(major_idx), set())
|
rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
|
||||||
if slot_assignment_history
|
|
||||||
else set()
|
|
||||||
)
|
)
|
||||||
if new_eid > 0 and new_eid in hist:
|
if new_eid > 0 and new_eid in rejected:
|
||||||
new_step = None
|
new_step = None
|
||||||
if new_step:
|
if new_step:
|
||||||
steps_by_major[int(major_idx)] = new_step
|
steps_by_major[int(major_idx)] = new_step
|
||||||
|
|
@ -207,6 +282,26 @@ def rematch_roadmap_slots(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
if old and old.get("exercise_id") is not None:
|
||||||
|
try:
|
||||||
|
old_eid = int(old["exercise_id"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
old_eid = 0
|
||||||
|
rejected = (
|
||||||
|
rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
|
||||||
|
)
|
||||||
|
if old_eid > 0 and old_eid not in rejected:
|
||||||
|
steps_by_major[int(major_idx)] = dict(old)
|
||||||
|
rematch_log.append(
|
||||||
|
{
|
||||||
|
"roadmap_major_step_index": int(major_idx),
|
||||||
|
"action": "restored",
|
||||||
|
"reason": reason,
|
||||||
|
"restored_exercise_id": old_eid,
|
||||||
|
"restored_title": old.get("title"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
goal = (stage_spec.learning_goal or "").strip()
|
goal = (stage_spec.learning_goal or "").strip()
|
||||||
major = None
|
major = None
|
||||||
if roadmap_ctx.roadmap:
|
if roadmap_ctx.roadmap:
|
||||||
|
|
@ -278,6 +373,7 @@ def prune_stripped_after_rematch(
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"collect_rematch_slot_indices",
|
"collect_rematch_slot_indices",
|
||||||
|
"filter_rematch_slot_indices",
|
||||||
"prune_stripped_after_rematch",
|
"prune_stripped_after_rematch",
|
||||||
"rematch_roadmap_slots",
|
"rematch_roadmap_slots",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ class GraphPlanningRoadmapArtifact(BaseModel):
|
||||||
path_skill_expectations: Optional[Dict[str, Any]] = None
|
path_skill_expectations: Optional[Dict[str, Any]] = None
|
||||||
slot_contents: Optional[List[SlotContentEntry]] = None
|
slot_contents: Optional[List[SlotContentEntry]] = None
|
||||||
last_findings: Optional[Dict[str, Any]] = None
|
last_findings: Optional[Dict[str, Any]] = None
|
||||||
|
findings_stale: bool = Field(default=False)
|
||||||
planning_catalog_context: Optional[Dict[str, Any]] = None
|
planning_catalog_context: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", "planning_catalog_context", mode="before")
|
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", "planning_catalog_context", mode="before")
|
||||||
|
|
|
||||||
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
|
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},
|
slot_indices={1},
|
||||||
rematch_reasons={1: "stage_mismatch"},
|
rematch_reasons={1: "stage_mismatch"},
|
||||||
match_slot_fn=_no_match,
|
match_slot_fn=_no_match,
|
||||||
|
rejected_by_major={1: {99}},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(ordered) == 2
|
assert len(ordered) == 2
|
||||||
|
|
@ -235,3 +236,103 @@ def test_prune_filled_from_roadmap_unfilled():
|
||||||
unfilled_steps = [{"exercise_id": None, "roadmap_major_step_index": 5}]
|
unfilled_steps = [{"exercise_id": None, "roadmap_major_step_index": 5}]
|
||||||
kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)])
|
kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)])
|
||||||
assert len(kept2) == 1
|
assert len(kept2) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_rematch_keeps_same_exercise_when_not_rejected():
|
||||||
|
"""Regression: slot_assignment_history blockierte gültige Wiederzuordnung → leere Slots."""
|
||||||
|
specs = _stage_specs()
|
||||||
|
ctx = ProgressionRoadmapContext(
|
||||||
|
goal_query="Mae Geri",
|
||||||
|
max_steps=3,
|
||||||
|
stage_specs=specs,
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
|
||||||
|
{"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
def _same_match(cur, *, stage_spec, slot_priority_exercise_id=None, **kwargs):
|
||||||
|
assert slot_priority_exercise_id == 42
|
||||||
|
return (
|
||||||
|
{"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": stage_spec.major_step_index},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
ordered, log, unfilled = rematch_roadmap_slots(
|
||||||
|
None,
|
||||||
|
tenant=None,
|
||||||
|
body=None,
|
||||||
|
goal_query="Mae Geri",
|
||||||
|
max_steps=3,
|
||||||
|
semantic_brief=None,
|
||||||
|
path_target_profile=None,
|
||||||
|
path_intent="",
|
||||||
|
roadmap_ctx=ctx,
|
||||||
|
steps=steps,
|
||||||
|
slot_indices={1},
|
||||||
|
rematch_reasons={1: "refine_stage_spec"},
|
||||||
|
match_slot_fn=_same_match,
|
||||||
|
rejected_by_major={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ordered[1]["exercise_id"] == 42
|
||||||
|
assert log[0]["action"] == "replaced"
|
||||||
|
assert not unfilled
|
||||||
|
|
||||||
|
|
||||||
|
def test_rematch_restores_when_match_fails_and_not_rejected():
|
||||||
|
specs = _stage_specs()
|
||||||
|
ctx = ProgressionRoadmapContext(
|
||||||
|
goal_query="Mae Geri",
|
||||||
|
max_steps=3,
|
||||||
|
stage_specs=specs,
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
|
||||||
|
{"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
def _no_match(cur, *, stage_spec, **kwargs):
|
||||||
|
return None, stage_spec
|
||||||
|
|
||||||
|
ordered, log, unfilled = rematch_roadmap_slots(
|
||||||
|
None,
|
||||||
|
tenant=None,
|
||||||
|
body=None,
|
||||||
|
goal_query="Mae Geri",
|
||||||
|
max_steps=3,
|
||||||
|
semantic_brief=None,
|
||||||
|
path_target_profile=None,
|
||||||
|
path_intent="",
|
||||||
|
roadmap_ctx=ctx,
|
||||||
|
steps=steps,
|
||||||
|
slot_indices={1},
|
||||||
|
rematch_reasons={1: "refine_stage_spec"},
|
||||||
|
match_slot_fn=_no_match,
|
||||||
|
rejected_by_major={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ordered[1]["exercise_id"] == 42
|
||||||
|
assert log[0]["action"] == "restored"
|
||||||
|
assert not unfilled
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_rematch_skips_preserved_slots():
|
||||||
|
from planning_path_rematch import filter_rematch_slot_indices
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"exercise_id": 10,
|
||||||
|
"roadmap_major_step_index": 0,
|
||||||
|
"roadmap_match_source": "slot_best_match",
|
||||||
|
"slot_status": "preserved",
|
||||||
|
},
|
||||||
|
{"exercise_id": 20, "roadmap_major_step_index": 1},
|
||||||
|
]
|
||||||
|
filtered = filter_rematch_slot_indices(
|
||||||
|
steps,
|
||||||
|
{0, 1},
|
||||||
|
stripped_off_topic=[],
|
||||||
|
off_topic_steps=[],
|
||||||
|
)
|
||||||
|
assert filtered == {1}
|
||||||
|
|
|
||||||
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 len(out["slot_contents"]) == 2
|
||||||
assert out["slot_contents"][1]["primary"]["kind"] == "proposal"
|
assert out["slot_contents"][1]["primary"]["kind"] == "proposal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_planning_roadmap_with_findings_stale():
|
||||||
|
out = normalize_planning_roadmap_payload(
|
||||||
|
{
|
||||||
|
"goal_query": "Mae Geri",
|
||||||
|
"last_findings": {"overall_ok": False},
|
||||||
|
"findings_stale": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert out["findings_stale"] is True
|
||||||
|
assert out["last_findings"]["overall_ok"] is False
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-06-07
|
**Stand:** 2026-05-22
|
||||||
**App-Version / DB-Schema:** App **`0.8.208`** (Planungs-KI Phase D); DB **`20260606086`** — maßgeblich **`backend/version.py`**.
|
**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI 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**.
|
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||||
|
|
||||||
|
|
@ -37,6 +37,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
|
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
|
||||||
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
|
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
|
||||||
| **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
|
| **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
|
||||||
|
| **Planungs-KI — Katalog-Prompt-Snippets (H1)** | **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
|
||||||
| **Lieferliste inkl. Medien & Formular-UX** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §6, §16 |
|
| **Lieferliste inkl. Medien & Formular-UX** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §6, §16 |
|
||||||
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
||||||
|
|
||||||
|
|
@ -89,7 +90,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
||||||
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
||||||
|
|
||||||
### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.217**)
|
### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.233**)
|
||||||
|
|
||||||
**Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen.
|
**Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen.
|
||||||
|
|
||||||
|
|
@ -108,20 +109,32 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
| **F7** | `planning_skill_expectations` — Retrieval + UI + Gap | ✅ **0.8.215–216** |
|
| **F7** | `planning_skill_expectations` — Retrieval + UI + Gap | ✅ **0.8.215–216** |
|
||||||
| **F8** | Editierbare `stage_specs` (Belastung, Erfolgskriterien) | ✅ **0.8.216** |
|
| **F8** | Editierbare `stage_specs` (Belastung, Erfolgskriterien) | ✅ **0.8.216** |
|
||||||
| **F9** | `planning_roadmap` JSONB am Graph (Migration **088**) | ✅ **0.8.217** |
|
| **F9** | `planning_roadmap` JSONB am Graph (Migration **088**) | ✅ **0.8.217** |
|
||||||
|
| **F10** | Stufen-Lernziel-Gate, kein blindes Rank-Fallback | ✅ **0.8.218** |
|
||||||
|
| **F11** | Auto-Rematch + Stufen-Spec-Refine + mehrstufige QS | ✅ **0.8.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):**
|
**Offen (priorisiert):**
|
||||||
1. UI-Wizard (Scroll-Monolith → 4 Schritte) — **separater UI-Chat**
|
1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri)
|
||||||
2. Graph-Erweiterungsmodus (Start ab Knoten)
|
2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor
|
||||||
3. Trainingsplanung Phase G (Gruppenkontext)
|
3. QS-UI — positive LLM-Hinweise als Highlights
|
||||||
4. Kontext-Anzeige auf allen Pfad-Schritten
|
4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
|
||||||
|
5. Graph-Erweiterungsmodus (Start ab Knoten)
|
||||||
|
6. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots
|
||||||
|
7. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
|
||||||
|
8. Technik-Katalog konfigurierbar (Backlog)
|
||||||
|
|
||||||
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
||||||
|
|
||||||
|
|
@ -256,11 +269,15 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
|
|
||||||
### Planungs-KI (priorisiert)
|
### Planungs-KI (priorisiert)
|
||||||
|
|
||||||
1. **Phase F2:** LLM für Roadmap (Prompts **078**) + `roadmap_first` Retrieval aus `stage_specs`.
|
1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung).
|
||||||
2. **Phase F4:** Roadmap-Review UI (Major Steps editierbar vor Übungs-Match).
|
2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest.
|
||||||
3. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
|
2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
|
||||||
4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale.
|
||||||
5. **Trainingsplanung G:** Kontext-Pack Gruppe/Historie — eigene Pipeline (`AI_PLANNING_KI_MULTISTAGE_FORECAST`); Mitai Workflow-Engine erst danach.
|
4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert.
|
||||||
|
5. **Phase D′:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`.
|
||||||
|
6. **Trainingsplanung 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
|
### 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
|
# Planungs-KI — Produkt-Roadmap
|
||||||
|
|
||||||
**Stand:** 2026-05-22
|
**Stand:** 2026-05-22
|
||||||
**App-Version:** **0.8.217** — maßgeblich `backend/version.py`
|
**App-Version:** **0.8.233** — maßgeblich `backend/version.py`
|
||||||
|
|
||||||
Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**.
|
Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**.
|
||||||
|
|
||||||
|
|
@ -13,9 +13,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
|
||||||
## Strategische Entscheidung (verbindlich)
|
## Strategische Entscheidung (verbindlich)
|
||||||
|
|
||||||
1. **Progressionsgraph:** Planung **vom Ziel rückwärts** (Roadmap-first), nicht Bibliothek-first.
|
1. **Progressionsgraph:** Planung **vom Ziel rückwärts** (Roadmap-first), nicht Bibliothek-first.
|
||||||
2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Thema, Schrittanzahl, optional Graph-Kanten.
|
2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Katalog-Dimensionen, Start/Ziel, Roadmap, optional Graph-Kanten.
|
||||||
3. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline später, **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0–S4.
|
3. **Drei Schichten statt monolithischem Vokabular:** Katalog (DB) · Technik-Disambiguierung (Code, nur bei Technik-Themen) · Didaktik (Roadmap + LLM-QS).
|
||||||
4. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`); Mitai Workflow-Engine **später**, wenn 2–3 Pipelines stabil sind.
|
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 | ✅ |
|
| A–C2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
|
||||||
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
|
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
|
||||||
| E–E3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
|
| 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** |
|
| **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** |
|
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
|
||||||
| **UX** | Progressionsgraph | Wizard/Stepper statt Scroll-UI | 🔲 |
|
| **UX** | Progressionsgraph | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 |
|
||||||
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0–S4 | 🔲 |
|
| **D′** | Progressionsgraph | Auto KI-Gap-Fill bei persistent leeren Slots | 🔲 Backlog |
|
||||||
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 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`
|
### F0–F9 — (Kurz, siehe Ist-Doku)
|
||||||
- [x] Modul `planning_progression_roadmap.py` (Pydantic, Pipeline-Skeleton)
|
|
||||||
- [x] Migration **078** Prompt-Slugs (Zielanalyse, Roadmap)
|
|
||||||
- [x] API: `include_roadmap_preview` auf `progression-path-suggest`
|
|
||||||
- [x] Doku: HANDOVER, PLANNING_EXERCISE_SUGGEST_CONTEXT, MULTISTAGE_FORECAST
|
|
||||||
|
|
||||||
### F1 — Deterministische Roadmap
|
- [x] F0 Foundation (0.8.204) — Spec, Pipeline-Skeleton, Prompts 078
|
||||||
|
- [x] F1 Deterministische Roadmap — Phase A/B/C heuristisch
|
||||||
|
- [x] F2 LLM Roadmap (0.8.205) — Prompts 078/079
|
||||||
|
- [x] F3 roadmap-first (0.8.206) — Match pro `stage_spec`, `roadmap_unfilled`
|
||||||
|
- [x] F4 UI Review (0.8.207) — `roadmap_override`, Major Steps editierbar
|
||||||
|
- [x] F5 Start/Ziel (0.8.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
|
### F10 — Stufen-Qualität (0.8.218)
|
||||||
- [x] Phase B: `micro_objectives` aus `development_arc` + Konsolidierung auf N
|
|
||||||
- [x] Phase C: heuristische `stage_specs`
|
|
||||||
- [ ] pytest für Konsolidierung
|
|
||||||
|
|
||||||
### F2 — LLM Roadmap (0.8.205)
|
- [x] Stufen-Lernziel-Gate — kein Rank-Fallback ohne Pass
|
||||||
|
- [x] Anti-Pattern-Sanitizer, `stage_mismatch` → leerer Slot + Gap
|
||||||
|
|
||||||
- [x] Prompts **078/079** in `ai_prompts` — Code nur Slugs (`PROMPT_SLUG_*`)
|
### F11 — Auto-Optimierung (0.8.225–0.8.230)
|
||||||
- [x] `include_llm_roadmap` + `load_and_render_ai_prompt` + JSON-Validierung
|
|
||||||
- [x] Deterministischer Fallback wenn Prompt/OpenRouter fehlt
|
|
||||||
- [ ] Response/UI: genutzte `prompt_slugs` sichtbar machen (Admin-Hinweis)
|
|
||||||
|
|
||||||
### F3 — roadmap-first (0.8.206)
|
- [x] `planning_path_rematch.py` — Rematch-Schleife für `rematch_slot` / `roadmap_unfilled`
|
||||||
|
- [x] `planning_path_refine_stage.py` — Spec-Schärfung aus QS
|
||||||
|
- [x] `planning_path_qa_pipeline.py` — mehrstufige QS
|
||||||
|
|
||||||
- [x] Retrieval pro `major_step` + `stage_spec` statt iterativem Pfad-Bau
|
### F12 — Pipeline-Timing & Sync (0.8.231–0.8.232)
|
||||||
- [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`)
|
|
||||||
- [x] QA/Lücken an Roadmap gekoppelt (`roadmap_first_lite`: keine Brücken/Reorder zwischen Major Steps)
|
|
||||||
|
|
||||||
### F4 — UI (0.8.207)
|
- [x] Post-Match-Gate vor Rematch-Akzeptanz
|
||||||
|
- [x] LLM Pfad-QS **nach** Rematch
|
||||||
|
- [x] Gap-Offers vor `path_qa`-Summary
|
||||||
|
- [x] Frontend: `applyMatchStepsToSlots` sync per `majorStepIndex`
|
||||||
|
|
||||||
- [x] Roadmap-Review im `ExerciseProgressionPathBuilder`
|
### F13 — Katalog-Kontext (0.8.233)
|
||||||
- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
|
|
||||||
- [x] API `roadmap_only` + `roadmap_override`
|
|
||||||
|
|
||||||
### F5 — Start/Ziel (0.8.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`
|
### F14 — GraphEditor Workbench (0.8.233)
|
||||||
- [x] Prompt **087** `planning_progression_start_target`
|
|
||||||
- [x] Priorität: Trainer > KI > Regex (`resolve_roadmap_structured_input`)
|
|
||||||
- [x] Zwei-Schritt-UI: „Start/Ziel analysieren“ / „Roadmap vorschlagen“
|
|
||||||
|
|
||||||
### F6 — Gap-KI-Kontext (0.8.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
|
### Validierung (Referenz Mae Geri, 2026-05)
|
||||||
- [x] `planning_exercise_form_context.py` — Gap-Snapshot, `context_preview`
|
|
||||||
- [x] Migration **085** — `planning_context` in Übungs-Prompts
|
|
||||||
|
|
||||||
### F7 — Fähigkeiten-Scoring (0.8.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`)
|
**Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht.
|
||||||
- [x] Pro-Stufe-Retrieval + `path_skill_expectations` + UI-Tags
|
|
||||||
- [x] `expected_skills` in Gap-Fill
|
|
||||||
|
|
||||||
### F8 — Stufen-Details UI (0.8.216)
|
---
|
||||||
|
|
||||||
- [x] Editierbare `stage_specs` in `roadmap_override` (Belastung, Erfolgskriterien, Vermeiden)
|
## UX — UI-Überarbeitung (offen)
|
||||||
|
|
||||||
### F9 — Persistenz (0.8.217)
|
- [ ] Wizard mit 4 Schritten (Ziel & Katalog → Roadmap → Match → Lücken)
|
||||||
|
- [ ] Progressive disclosure — Details in Panels
|
||||||
|
- [ ] PathBuilder-Parität: gleiche Katalog-Dropdowns wie GraphEditor
|
||||||
|
- [ ] QS-UI: positive LLM-Hinweise als Highlights
|
||||||
|
- Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §12
|
||||||
|
|
||||||
- [x] Migration **088** — `planning_roadmap` JSONB am Graph
|
---
|
||||||
- [x] Laden/Speichern über `GET/PUT` Graph + Sequenz-Endpoint
|
|
||||||
|
|
||||||
### UX — UI-Überarbeitung (offen)
|
## Phase D′ — Auto Gap-Fill (Backlog)
|
||||||
|
|
||||||
- [ ] Wizard mit 4 Schritten (Ziel → Roadmap → Match → Lücken)
|
- [ ] Bei persistent `roadmap_unfilled` automatisch KI-Vorschlag vorbereiten (ohne manuelles Modal)
|
||||||
- [ ] Progressive disclosure — Details in Panels, nicht alles gleichzeitig
|
- [ ] Governance: Trainer bestätigt vor Persistenz
|
||||||
- [ ] Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §10
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase G — Trainingsplanung (komplexere Domäne)
|
||||||
|
|
||||||
|
**Ziel:** Einheiten, Rahmen-Slots, Abschnitte und parallele Streams KI-gestützt planen — **ohne** zweite Retrieval-Welt.
|
||||||
|
|
||||||
|
### Wiederverwendung aus Progressionsgraph
|
||||||
|
|
||||||
|
| Baustein | Progressionsgraph | Trainingsplanung |
|
||||||
|
|----------|-------------------|------------------|
|
||||||
|
| `PlanningTargetProfile` | Curriculum-Query + Katalog | Einheit + Abschnitt + Slot + Katalog + Historie |
|
||||||
|
| `planning_catalog_context` | Am Graph gespeichert | Pro Einheit / Slot / Voreinstellung |
|
||||||
|
| `planning_skill_expectations` | `progression_stage`, `progression_path` | **`training_section`**, **`framework_slot`** |
|
||||||
|
| `planning_exercise_retrieval` | Roadmap-Stufen-Match | `suggest_planning_exercises` — **bereits produktiv** |
|
||||||
|
| `planning_path_qa_pipeline` | Curriculum-QS | Abschnitts-QS (Kohärenz, Streams) |
|
||||||
|
| `planning_exercise_form_context` | Pfad-Lücken | Abschnitts-/Slot-Lücken |
|
||||||
|
| Roadmap-Pipeline | Major Steps über Wochen | **Nicht 1:1** — Phasen/Streams + Vorlagen |
|
||||||
|
|
||||||
|
### Was Phase G neu braucht
|
||||||
|
|
||||||
|
- Gruppen-/Historie-Kontext-Pack (`AI_PLANNING_KI_MULTISTAGE_FORECAST` 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 |
|
| Von | Nach | Hinweis |
|
||||||
|-----|------|---------|
|
|-----|------|---------|
|
||||||
| F2 | Enrichment / Skills | Bessere Roadmap bei technikspezifischen Skills |
|
| F13 | G0, **H1** | Katalog-Kontext in Einheitsplanung; Snippets in LLM-Prompts |
|
||||||
| F3 | F2 | LLM-Roadmap oder stabile heuristische B |
|
| F7, F11 | G1, G2 | Skill-Expectations + QS-Muster |
|
||||||
| G | F4 | Trainingsplanung kann Roadmap aus Graph referenzieren |
|
| F4, F9 | G3 | Graph-Roadmap kann Rahmen referenzieren |
|
||||||
| H | G + F4 | Workflow-Engine lohnt bei verzweigten Planungsflows |
|
| G | H | Workflow-Engine lohnt bei verzweigten Planungsflows |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Progressionsgraph — KI-Planung (Ist-Stand)
|
# Progressionsgraph — KI-Planung (Ist-Stand)
|
||||||
|
|
||||||
**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.218** · **DB:** Migration **088**
|
**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.233** · **DB:** Migration **088**
|
||||||
**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`)
|
**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS.planning_exercise_suggest`)
|
||||||
|
|
||||||
> **Diese Datei ist die zentrale Referenz** für die KI-gestützte Planung im Progressionsgraph.
|
> **Diese Datei ist die zentrale Referenz** für die KI-gestützte Planung im Progressionsgraph.
|
||||||
> Ältere Abschnitte in `HANDOVER.md` §2.8 und `PLANNING_KI_ROADMAP.md` verweisen hierher.
|
> Ältere Abschnitte in `HANDOVER.md` §2.8 und `PLANNING_KI_ROADMAP.md` verweisen hierher.
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` (Zielarchitektur) ·
|
`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` (Zielarchitektur) ·
|
||||||
`.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` (Retrieval/Scoring) ·
|
`.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` (Retrieval/Scoring) ·
|
||||||
`.claude/docs/technical/SKILL_SCORING_SPEC.md` (Fähigkeiten-Scoring) ·
|
`.claude/docs/technical/SKILL_SCORING_SPEC.md` (Fähigkeiten-Scoring) ·
|
||||||
|
**`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (H1 — modulare Katalog-Prompts) ·
|
||||||
`docs/architecture/PLANNING_KI_ROADMAP.md` (Produkt-Roadmap Phase G+)
|
`docs/architecture/PLANNING_KI_ROADMAP.md` (Produkt-Roadmap Phase G+)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -30,19 +31,20 @@
|
||||||
|
|
||||||
## 2. Trainer-Workflow (UI)
|
## 2. Trainer-Workflow (UI)
|
||||||
|
|
||||||
Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgressionGraphPanel.jsx`):
|
**Primär:** `ProgressionGraphEditor.jsx` (integrierter Slot-Editor, Phase B).
|
||||||
|
**Legacy/Parallel:** `ExerciseProgressionPathBuilder.jsx` (Scroll-Monolith — gleiche API, Katalog-Kontext-Dropdowns dort noch nachziehen).
|
||||||
|
|
||||||
```
|
```
|
||||||
① Ziel eingeben (+ optional Start/Ziel-Felder manuell)
|
① Ziel eingeben (+ Planungskontext: Primärfokus, Stil, Trainingsstil, Zielgruppe)
|
||||||
② „Start/Ziel analysieren“ (optional, start_target_only)
|
② Optional: Start/Ziel-Felder manuell oder „Start/Ziel analysieren“
|
||||||
③ „Roadmap vorschlagen“ (roadmap_only, LLM-Roadmap)
|
③ „Roadmap generieren“ (roadmap_only, LLM-Roadmap)
|
||||||
④ Roadmap bearbeiten (Major Steps + Stufen-Details)
|
④ Roadmap bearbeiten (Major Steps + Stufen-Details)
|
||||||
⑤ „Übungen matchen“ (roadmap_first + roadmap_override)
|
⑤ „Übungen matchen“ (roadmap_first + roadmap_override + Auto-QS/Rematch)
|
||||||
⑥ Lücken mit KI schließen (gap_fill_offers + Vorbereitungs-Dialog)
|
⑥ Lücken: KI-Angebote → „KI anlegen“ (Gap-Prep-Modal) → in Slot
|
||||||
⑦ „Pfad in Graph speichern“ (Sequenz-Kanten)
|
⑦ „Graph speichern“ (planning_roadmap + optional Kanten-Sequenz)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bekannte UX-Schuld:** Alle Schritte liegen auf **einer langen Scroll-Seite** — Überarbeitung als Wizard/Stepper ist geplant (separater UI-Chat). Briefing-Vorlage siehe unten §10.
|
**Bekannte UX-Schuld:** PathBuilder = lange Scroll-Seite; GraphEditor = kompakter, aber noch kein Wizard. Stepper geplant (separater UI-Chat). Briefing §12.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -51,6 +53,7 @@ Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgres
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph ui [Frontend]
|
subgraph ui [Frontend]
|
||||||
|
PGE[ProgressionGraphEditor]
|
||||||
EPB[ExerciseProgressionPathBuilder]
|
EPB[ExerciseProgressionPathBuilder]
|
||||||
GFM[ExerciseGapFillPrepModal]
|
GFM[ExerciseGapFillPrepModal]
|
||||||
PCtx[planningContextForExerciseAi.js]
|
PCtx[planningContextForExerciseAi.js]
|
||||||
|
|
@ -71,6 +74,10 @@ flowchart TB
|
||||||
|
|
||||||
subgraph match [Match + QA]
|
subgraph match [Match + QA]
|
||||||
PB[planning_exercise_path_builder.py]
|
PB[planning_exercise_path_builder.py]
|
||||||
|
PCC[planning_catalog_context.py]
|
||||||
|
REM[planning_path_rematch.py]
|
||||||
|
REF[planning_path_refine_stage.py]
|
||||||
|
QAP[planning_path_qa_pipeline.py]
|
||||||
RET[planning_exercise_retrieval.py]
|
RET[planning_exercise_retrieval.py]
|
||||||
PG[planning_exercise_progression.py]
|
PG[planning_exercise_progression.py]
|
||||||
SEM[planning_exercise_semantics.py]
|
SEM[planning_exercise_semantics.py]
|
||||||
|
|
@ -88,11 +95,16 @@ flowchart TB
|
||||||
end
|
end
|
||||||
|
|
||||||
EPB --> PPS
|
EPB --> PPS
|
||||||
EPB --> SEQ
|
PGE --> PPS
|
||||||
EPB --> PUT
|
PGE --> SEQ
|
||||||
|
PGE --> PUT
|
||||||
GFM --> EAI
|
GFM --> EAI
|
||||||
PPS --> PR
|
PPS --> PR
|
||||||
PPS --> PB
|
PPS --> PB
|
||||||
|
PB --> PCC
|
||||||
|
PB --> REM
|
||||||
|
PB --> REF
|
||||||
|
PB --> QAP
|
||||||
PB --> RET
|
PB --> RET
|
||||||
PB --> PG
|
PB --> PG
|
||||||
PB --> PSE
|
PB --> PSE
|
||||||
|
|
@ -108,12 +120,18 @@ flowchart TB
|
||||||
| Modul | Aufgabe |
|
| Modul | Aufgabe |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `planning_progression_roadmap.py` | Phasen A–C: Zielanalyse, Roadmap, `stage_specs`; Start/Ziel-Auflösung (Trainer > KI > Regex) |
|
| `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_exercise_progression.py` | Graph auflösen, Nachfolger-Kanten für Retrieval-Bias |
|
||||||
| `planning_skill_expectations.py` | Skill-Erwartungen pro Scope (`progression_stage`, `progression_path`, später `training_section`) |
|
| `planning_skill_expectations.py` | Skill-Erwartungen pro Scope (`progression_stage`, `progression_path`, später `training_section`) |
|
||||||
| `planning_exercise_form_context.py` | `planning_context` / Gap-Snapshot für Übungs-KI |
|
| `planning_exercise_form_context.py` | `planning_context` / Gap-Snapshot für Übungs-KI |
|
||||||
| `planning_exercise_path_ai_fill.py` | Gap-Fill-Angebote, `goal_for_ai`, `context_preview` |
|
| `planning_exercise_path_ai_fill.py` | Gap-Fill-Angebote, `goal_for_ai`, `context_preview` |
|
||||||
| `progression_graph_planning_artifact.py` | Validierung `planning_roadmap` JSON (Schema v1, max. 64 KB) |
|
| `progression_graph_planning_artifact.py` | Validierung `planning_roadmap` JSON (Schema v1, max. 64 KB) |
|
||||||
|
| `planning_exercise_profiles.py` | **Katalog-Scoring** (Fokus/Stil/TT/ZG/Skills) — gemeinsam mit Einheitsplanung |
|
||||||
|
| `planning_exercise_target_pipeline.py` | Query-Intent-Pipeline — Progressionsgraph nutzt `query_only`-Modus + Katalog-Overlay |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -131,10 +149,14 @@ flowchart TB
|
||||||
| `start_target_only` | bool | Nur Start/Ziel-Analyse |
|
| `start_target_only` | bool | Nur Start/Ziel-Analyse |
|
||||||
| `roadmap_override` | object | Trainer-bearbeitete `major_steps` + `stage_specs` |
|
| `roadmap_override` | object | Trainer-bearbeitete `major_steps` + `stage_specs` |
|
||||||
| `start_situation`, `target_state`, `roadmap_notes` | string? | Strukturierte Eingabe (Priorität vor KI) |
|
| `start_situation`, `target_state`, `roadmap_notes` | string? | Strukturierte Eingabe (Priorität vor KI) |
|
||||||
|
| `planning_catalog_context` | object? | Primärfokus, Stilrichtung, Trainingsstil, Zielgruppe (IDs + `is_primary`) |
|
||||||
| `include_llm_start_target` | bool | LLM-Extraktion Start/Ziel (Prompt **087**) |
|
| `include_llm_start_target` | bool | LLM-Extraktion Start/Ziel (Prompt **087**) |
|
||||||
| `include_llm_roadmap` | bool | LLM Roadmap (Prompts **078/079**) |
|
| `include_llm_roadmap` | bool | LLM Roadmap (Prompts **078/079**) |
|
||||||
| `include_llm_intent` | bool | LLM Intent für Semantic Brief (Roadmap-Vorschlag: **true** seit 0.8.217) |
|
| `include_llm_intent` | bool | LLM Intent für Semantic Brief |
|
||||||
| `include_path_qa`, `include_ai_gap_fill` | bool | QS, Lücken-Angebote |
|
| `auto_rematch_after_qa` | bool | Auto-Rematch nach QS (Default **true**) |
|
||||||
|
| `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) |
|
||||||
|
| `max_rematch_rounds` | int | Rematch-Runden 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
|
### 4.2 Wichtige Response-Felder
|
||||||
|
|
||||||
|
|
@ -144,7 +166,29 @@ flowchart TB
|
||||||
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
|
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
|
||||||
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
|
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
|
||||||
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
|
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
|
||||||
| `path_qa` | QS inkl. `roadmap_qa_mode: roadmap_first_lite` |
|
| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log` |
|
||||||
|
| `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) |
|
||||||
|
| `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.4 Planungskontext — Katalog vs. Technik-Vokabular
|
||||||
|
|
||||||
|
Shinkan unterscheidet **drei Schichten** (kein monolithisches „Vokabular“):
|
||||||
|
|
||||||
|
| Schicht | Was | Wo | Beispiel |
|
||||||
|
|---------|-----|-----|----------|
|
||||||
|
| **Katalog-Dimensionen** | Was für Training? | DB: `focus_areas`, `style_directions`, `training_types`, `target_groups`, `skills` | Gewaltschutz, Breitensport, Shotokan |
|
||||||
|
| **Disambiguierung (Technik)** | Verwechslungs-Nachbarn | Code: `planning_exercise_semantics.py` (`_GERI_TECHNIQUES`, …) | Mae Geri ≠ Mawashi Geri |
|
||||||
|
| **Didaktik / Kausalität** | Reihenfolge, Lernphasen | Roadmap + LLM Pfad-QS | Grundlagen vor Geschwindigkeit |
|
||||||
|
|
||||||
|
**Seit 0.8.233:** `planning_catalog_context` im Request und im Graph-Artefakt (`planning_catalog_context` JSON). Fließt in `PlanningTargetProfile` → Hybrid-Retrieval (`score_exercise_against_target`: „Fokusbereich passend“, …). Zusätzlich additive Text-Signale aus Anfrage + Start/Ziel + Notizen (`planning_exercise_text_signals`).
|
||||||
|
|
||||||
|
**Geplant (H1):** dieselben Dimensionen als **kaskadierte Prompt-Snippets** in Roadmap-, Stufen-Spec- und Pfad-QS-Prompts — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung — siehe **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**.
|
||||||
|
|
||||||
|
**Technik-Gates** (`technique_scope`, Geschwister-Ausschluss) nur bei `topic_type == "technique"` — Fokus-Pfade (Gewaltschutz, Fitness, …) werden nicht wie Mae-Geri-Pfade behandelt.
|
||||||
|
|
||||||
|
Fallback: fehlt `planning_catalog_context` im Request, wird aus gespeichertem `planning_roadmap` am Graph geladen.
|
||||||
|
|
||||||
### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding)
|
### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding)
|
||||||
|
|
||||||
|
|
@ -157,17 +201,32 @@ flowchart TB
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Roadmap-Match — Stufen-Qualität (0.8.218)
|
## 5. Roadmap-Match — Stufen-Qualität (0.8.218–0.8.233)
|
||||||
|
|
||||||
Pro Major Step gilt:
|
Pro Major Step gilt:
|
||||||
|
|
||||||
1. **Stufen-Brief** — `semantic_brief_for_stage()` ergänzt `must_phrases` um das Stufen-Lernziel.
|
1. **Stufen-Brief** — `build_stage_match_brief()` aus Lernziel, `anti_patterns`, Erfolgskriterien, Pfad-Kontext.
|
||||||
2. **Stufen-Gate** — `exercise_passes_stage_learning_goal_gate()` prüft Lernziel-Text in Titel/Summary/Ziel oder Mindest-`semantic_score`.
|
2. **Stufen-Gate** — `exercise_passes_stage_fit()` / `exercise_passes_stage_learning_goal_gate()` auf vollem Übungstext.
|
||||||
3. **Kein Fallback** — Bei `roadmap_first` wird **nicht** auf die globale `goal_query` zurückgefallen; passt keine Übung → **Lücke** (`roadmap_unfilled`) statt themenfremder Übung.
|
3. **Kein blindes Rank-Fallback** — ohne Gate-Passung → `roadmap_unfilled`, nicht themenfremde Übung.
|
||||||
4. **Retrieval** — Bonus/Strafe im Hybrid-Score je nach Stufen-Passung.
|
4. **Post-Match-Gate** — `_roadmap_step_passes_post_match_gate()` = gleiche QS wie `detect_off_topic_steps` (kein Rematch-Treffer, der sofort wieder `stage_mismatch` wäre).
|
||||||
5. **QS** — `detect_off_topic_steps` erkennt `stage_mismatch` anhand `roadmap_learning_goal`.
|
5. **Retrieval** — Hybrid-Score: Volltext + Semantik + **Profil/Katalog** + Skill-Erwartungen + optional Graph-Bias.
|
||||||
|
6. **Auto-Optimierung (ein Match-Lauf):**
|
||||||
|
- **Phase B:** Rematch-Schleife (`planning_path_rematch.py`) für `rematch_slot` / `roadmap_unfilled`
|
||||||
|
- **Phase C:** `planning_path_refine_stage.py` — `anti_patterns` / Erfolgskriterien aus QS
|
||||||
|
- Purge persistent `stage_mismatch` → Slot leeren + KI-Gap
|
||||||
|
- LLM Pfad-QS **nach** Rematch auf finalem Pfad
|
||||||
|
- Gap-Offers für alle leeren Slots **vor** `path_qa`-Summary
|
||||||
|
|
||||||
Tests: `test_planning_roadmap_stage_match.py`
|
Tests: `test_planning_roadmap_stage_match.py`, `test_planning_path_rematch.py`, `test_planning_path_refine_stage.py`, `test_planning_catalog_context.py`
|
||||||
|
|
||||||
|
### Referenz-Validierung (Mae Geri, 2026-05)
|
||||||
|
|
||||||
|
| Phase | Pfad-QS | Ergebnis |
|
||||||
|
|-------|---------|----------|
|
||||||
|
| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) |
|
||||||
|
| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen |
|
||||||
|
|
||||||
|
**Lesson:** Workbench + Katalog-Kontext + Roadmap sind der Hebel; Technik-Hardcoding allein reicht nicht für Didaktik.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -209,7 +268,15 @@ Gespeichert am **Graph-Container** (`exercise_progression_graphs`), Schema v1:
|
||||||
"roadmap_notes": "…",
|
"roadmap_notes": "…",
|
||||||
"max_steps": 5,
|
"max_steps": 5,
|
||||||
"progression_roadmap": { },
|
"progression_roadmap": { },
|
||||||
"path_skill_expectations": { }
|
"path_skill_expectations": { },
|
||||||
|
"planning_catalog_context": {
|
||||||
|
"focus_areas": [{ "id": 1, "is_primary": true }],
|
||||||
|
"style_directions": [],
|
||||||
|
"training_types": [{ "id": 2, "is_primary": true }],
|
||||||
|
"target_groups": []
|
||||||
|
},
|
||||||
|
"slot_contents": [ ],
|
||||||
|
"last_findings": { }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -307,26 +374,75 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
|
||||||
| F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215–216 |
|
| F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215–216 |
|
||||||
| F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 |
|
| F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 |
|
||||||
| F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 |
|
| F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 |
|
||||||
| F10 | Stufen-Lernziel-Gate beim Match (kein goal_query-Fallback) | ✅ | 0.8.218 |
|
| F10 | Stufen-Lernziel-Gate + kein goal_query-Fallback | ✅ | 0.8.218 |
|
||||||
| **G** | Trainingsplanung eigene Pipeline + Graph-Referenz | 🔲 | — |
|
| **F11** | Auto-Rematch + Stufen-Spec-Refine + mehrstufige QS | ✅ | 0.8.225–0.8.230 |
|
||||||
| **UX** | Wizard/Stepper statt Scroll-Monolith | 🔲 | separater Chat |
|
| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.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)
|
## 12. Offenes Backlog (priorisiert)
|
||||||
|
|
||||||
1. **UI-Überarbeitung** — Wizard mit 4 Schritten, progressive disclosure (Briefing unten)
|
1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
|
||||||
2. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
|
2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren
|
||||||
3. **Kontext auf allen Pfad-Schritten** in UI (nicht nur Lücken)
|
2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
|
||||||
4. **Trainingsplanung Phase G** — Pipeline mit Gruppenkontext, Wiederverwendung `planning_skill_expectations`
|
3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“
|
||||||
5. Enrichment / Prompt-Feintuning
|
4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert
|
||||||
6. Mitai Workflow-Engine (langfristig)
|
5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
|
||||||
|
6. **Phase D′** — automatisches KI-Gap-Fill bei persistent leeren Slots
|
||||||
|
7. **Trainingsplanung Phase G** — siehe §16
|
||||||
|
8. **Technik-Katalog externalisieren** — konfigurierbare `concept_groups` (Backlog)
|
||||||
|
9. Graph-Metadaten: Primärfokus/Stil als Spalten (Reporting)
|
||||||
|
10. Mitai Workflow-Engine (langfristig)
|
||||||
|
|
||||||
### Briefing-Vorlage UI-Chat (Copy-Paste)
|
### Briefing-Vorlage UI-Chat
|
||||||
|
|
||||||
Siehe Nutzer-Chat 2026-05-22 oder `HANDOVER.md` §2.8 — Abschnitt „UI-Überarbeitung“.
|
Kern: Wizard ① Ziel & Planungskontext → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen.
|
||||||
|
|
||||||
Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen.
|
---
|
||||||
|
|
||||||
|
## 16. Wiederverwendung in der Trainingsplanung (Phase G)
|
||||||
|
|
||||||
|
Die **komplexere Trainingsplanung** (Einheit, Rahmen-Slot, Abschnitt, parallele Streams) soll **keine zweite Retrieval-Welt** bauen, sondern bestehende Module mit **anderem Kontext-Pack** nutzen.
|
||||||
|
|
||||||
|
### 16.1 Was Progressionsgraph liefert (Workbench-Muster)
|
||||||
|
|
||||||
|
| Baustein | Progressionsgraph | Trainingsplanung (Ziel) |
|
||||||
|
|----------|-------------------|-------------------------|
|
||||||
|
| `PlanningTargetProfile` | Query + Katalog + Skills | Einheit + Abschnitt + Slot + Katalog + Historie |
|
||||||
|
| `planning_catalog_context` | Am Graph gespeichert | Pro Einheit / Slot / Trainer-Voreinstellung |
|
||||||
|
| `planning_skill_expectations` | `progression_stage` / `progression_path` | **`training_section`**, **`framework_slot`** |
|
||||||
|
| `planning_exercise_retrieval` | Roadmap-Stufen-Match | Abschnitts-Suche (`suggest_planning_exercises`) — **produktiv** |
|
||||||
|
| `planning_path_qa_pipeline` | Curriculum-QS | Abschnitts-QS (Kohärenz, Streams) |
|
||||||
|
| `planning_intent_context` | Pfad-Ausschlüsse → Stufen | Abschnitts-Guidance → Brief |
|
||||||
|
| `planning_exercise_form_context` | Pfad-Lücken | Abschnitts-/Slot-Lücken |
|
||||||
|
| Roadmap-Pipeline | Curriculum Major Steps | **Nicht 1:1** — Phasen/Streams + Vorlagen |
|
||||||
|
| Technik-Disambiguierung | bei `topic_type=technique` | nur bei explizitem Technik-Abschnitt |
|
||||||
|
|
||||||
|
### 16.2 Was Phase G neu braucht
|
||||||
|
|
||||||
|
- **Gruppen-/Historie-Kontext-Pack** (`AI_PLANNING_KI_MULTISTAGE_FORECAST` 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_planning_exercise_form_context.py` | `planning_context`, Gap-Snapshot |
|
||||||
| `test_progression_graph_planning_artifact.py` | JSONB-Artefakt-Validierung |
|
| `test_progression_graph_planning_artifact.py` | JSONB-Artefakt-Validierung |
|
||||||
| `test_planning_exercise_progression.py` | Graph-Auflösung, Nachfolger |
|
| `test_planning_exercise_progression.py` | Graph-Auflösung, Nachfolger |
|
||||||
|
| `test_planning_path_rematch.py` | Auto-Rematch, unfilled-Platzhalter |
|
||||||
|
| `test_planning_path_refine_stage.py` | Stufen-Spec-Refine |
|
||||||
|
| `test_planning_stage_anti_patterns.py` | Anti-Pattern-Sanitizer, Stufen-Gate |
|
||||||
|
| `test_planning_catalog_context.py` | Katalog-Kontext → Target-Profil |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -366,3 +486,4 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken
|
||||||
| Datum | Änderung |
|
| Datum | Änderung |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5–F9 |
|
| 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 ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
|
||||||
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||||
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
||||||
|
import PlanningCatalogContextFields from './PlanningCatalogContextFields'
|
||||||
|
import {
|
||||||
|
EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||||
|
parsePlanningCatalogContextFromArtifact,
|
||||||
|
planningCatalogContextToApi,
|
||||||
|
pathQaQualityPercent,
|
||||||
|
pathQaShowsStrongResult,
|
||||||
|
setCatalogSelectItems,
|
||||||
|
splitPathQaHints,
|
||||||
|
draftHasLibrarySlotAssignments,
|
||||||
|
slotsToSlotAssignments,
|
||||||
|
draftRetrievalBoostExerciseIds,
|
||||||
|
} from '../utils/progressionGraphDraft'
|
||||||
import {
|
import {
|
||||||
aiPreviewToQuickCreateDraft,
|
aiPreviewToQuickCreateDraft,
|
||||||
buildQuickCreateAiPreview,
|
buildQuickCreateAiPreview,
|
||||||
|
|
@ -449,9 +462,13 @@ function buildPlanningRoadmapArtifactSnapshot({
|
||||||
maxSteps,
|
maxSteps,
|
||||||
progressionRoadmap,
|
progressionRoadmap,
|
||||||
pathSkillExpectations,
|
pathSkillExpectations,
|
||||||
|
planningCatalogContext,
|
||||||
}) {
|
}) {
|
||||||
const q = (goalQuery || '').trim()
|
const q = (goalQuery || '').trim()
|
||||||
if (!q && !progressionRoadmap) return null
|
if (!q && !progressionRoadmap) return null
|
||||||
|
const catalogPayload = planningCatalogContextToApi(
|
||||||
|
planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
schema_version: PLANNING_ARTIFACT_SCHEMA,
|
schema_version: PLANNING_ARTIFACT_SCHEMA,
|
||||||
goal_query: q,
|
goal_query: q,
|
||||||
|
|
@ -461,6 +478,9 @@ function buildPlanningRoadmapArtifactSnapshot({
|
||||||
max_steps: Number(maxSteps) || 5,
|
max_steps: Number(maxSteps) || 5,
|
||||||
progression_roadmap: progressionRoadmap || null,
|
progression_roadmap: progressionRoadmap || null,
|
||||||
path_skill_expectations: pathSkillExpectations || null,
|
path_skill_expectations: pathSkillExpectations || null,
|
||||||
|
...(catalogPayload.planning_catalog_context
|
||||||
|
? { planning_catalog_context: catalogPayload.planning_catalog_context }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -544,6 +564,12 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
const [startTargetAnalyzed, setStartTargetAnalyzed] = useState(false)
|
const [startTargetAnalyzed, setStartTargetAnalyzed] = useState(false)
|
||||||
const loading = loadingRoadmap || loadingStartTarget || loadingMatch
|
const loading = loadingRoadmap || loadingStartTarget || loadingMatch
|
||||||
const [focusAreas, setFocusAreas] = useState([])
|
const [focusAreas, setFocusAreas] = useState([])
|
||||||
|
const [styleDirections, setStyleDirections] = useState([])
|
||||||
|
const [trainingTypes, setTrainingTypes] = useState([])
|
||||||
|
const [targetGroups, setTargetGroups] = useState([])
|
||||||
|
const [planningCatalogContext, setPlanningCatalogContext] = useState(() => ({
|
||||||
|
...EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||||
|
}))
|
||||||
const [skillsCatalog, setSkillsCatalog] = useState([])
|
const [skillsCatalog, setSkillsCatalog] = useState([])
|
||||||
const [generatingOfferId, setGeneratingOfferId] = useState(null)
|
const [generatingOfferId, setGeneratingOfferId] = useState(null)
|
||||||
|
|
||||||
|
|
@ -571,6 +597,22 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
[editableMajorSteps, pathSteps],
|
[editableMajorSteps, pathSteps],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const catalogApiPayload = useMemo(
|
||||||
|
() => planningCatalogContextToApi(planningCatalogContext),
|
||||||
|
[planningCatalogContext],
|
||||||
|
)
|
||||||
|
|
||||||
|
const pathQaSplit = useMemo(() => splitPathQaHints(pathQa), [pathQa])
|
||||||
|
const pathQaHighlights = pathQaSplit.highlightTexts
|
||||||
|
const pathQaFixHints = pathQaSplit.fixHints
|
||||||
|
|
||||||
|
const patchCatalogDimension = useCallback((key, value) => {
|
||||||
|
setPlanningCatalogContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: setCatalogSelectItems(prev?.[key], value),
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const buildPlanningArtifact = useCallback(
|
const buildPlanningArtifact = useCallback(
|
||||||
() =>
|
() =>
|
||||||
buildPlanningRoadmapArtifactSnapshot({
|
buildPlanningRoadmapArtifactSnapshot({
|
||||||
|
|
@ -581,6 +623,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
maxSteps,
|
maxSteps,
|
||||||
progressionRoadmap,
|
progressionRoadmap,
|
||||||
pathSkillExpectations,
|
pathSkillExpectations,
|
||||||
|
planningCatalogContext,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
goalQuery,
|
goalQuery,
|
||||||
|
|
@ -590,6 +633,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
maxSteps,
|
maxSteps,
|
||||||
progressionRoadmap,
|
progressionRoadmap,
|
||||||
pathSkillExpectations,
|
pathSkillExpectations,
|
||||||
|
planningCatalogContext,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -634,6 +678,9 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
if (art.roadmap_notes) setRoadmapNotes(String(art.roadmap_notes))
|
if (art.roadmap_notes) setRoadmapNotes(String(art.roadmap_notes))
|
||||||
if (art.max_steps) setMaxSteps(Number(art.max_steps))
|
if (art.max_steps) setMaxSteps(Number(art.max_steps))
|
||||||
if (art.path_skill_expectations) setPathSkillExpectations(art.path_skill_expectations)
|
if (art.path_skill_expectations) setPathSkillExpectations(art.path_skill_expectations)
|
||||||
|
if (art.planning_catalog_context) {
|
||||||
|
setPlanningCatalogContext(parsePlanningCatalogContextFromArtifact(art))
|
||||||
|
}
|
||||||
if (art.progression_roadmap) {
|
if (art.progression_roadmap) {
|
||||||
setProgressionRoadmap(art.progression_roadmap)
|
setProgressionRoadmap(art.progression_roadmap)
|
||||||
const majors = mapMajorStepsFromApi(art.progression_roadmap)
|
const majors = mapMajorStepsFromApi(art.progression_roadmap)
|
||||||
|
|
@ -670,16 +717,25 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.listFocusAreas({ status: 'active' }),
|
api.listFocusAreas({ status: 'active' }),
|
||||||
|
api.listStyleDirections({ status: 'active' }),
|
||||||
|
api.listTrainingTypes({ status: 'active' }),
|
||||||
|
api.listTargetGroups({ status: 'active' }),
|
||||||
api.listSkillsCatalog({ status: 'active' }),
|
api.listSkillsCatalog({ status: 'active' }),
|
||||||
])
|
])
|
||||||
.then(([fa, sk]) => {
|
.then(([fa, sd, tt, tg, sk]) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setFocusAreas(Array.isArray(fa) ? fa : [])
|
setFocusAreas(Array.isArray(fa) ? fa : [])
|
||||||
|
setStyleDirections(Array.isArray(sd) ? sd : [])
|
||||||
|
setTrainingTypes(Array.isArray(tt) ? tt : [])
|
||||||
|
setTargetGroups(Array.isArray(tg) ? tg : [])
|
||||||
setSkillsCatalog(Array.isArray(sk) ? sk : [])
|
setSkillsCatalog(Array.isArray(sk) ? sk : [])
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setFocusAreas([])
|
setFocusAreas([])
|
||||||
|
setStyleDirections([])
|
||||||
|
setTrainingTypes([])
|
||||||
|
setTargetGroups([])
|
||||||
setSkillsCatalog([])
|
setSkillsCatalog([])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1095,6 +1151,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
start_target_only: true,
|
start_target_only: true,
|
||||||
progression_graph_id: Number(graphId),
|
progression_graph_id: Number(graphId),
|
||||||
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
|
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
|
||||||
|
...catalogApiPayload,
|
||||||
})
|
})
|
||||||
applyStartTargetResponse(res)
|
applyStartTargetResponse(res)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -1133,6 +1190,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
roadmap_only: true,
|
roadmap_only: true,
|
||||||
progression_graph_id: Number(graphId),
|
progression_graph_id: Number(graphId),
|
||||||
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
|
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
|
||||||
|
...catalogApiPayload,
|
||||||
})
|
})
|
||||||
const majors = mapMajorStepsFromApi(res?.progression_roadmap)
|
const majors = mapMajorStepsFromApi(res?.progression_roadmap)
|
||||||
if (majors.length < 2) {
|
if (majors.length < 2) {
|
||||||
|
|
@ -1190,6 +1248,22 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const override = majorStepsToOverridePayload(validSteps)
|
const override = majorStepsToOverridePayload(validSteps)
|
||||||
|
const preserveAssignments = draftHasLibrarySlotAssignments({
|
||||||
|
slots: validSteps.map((s, i) => ({
|
||||||
|
majorStepIndex: i,
|
||||||
|
phase: s.phase,
|
||||||
|
learning_goal: s.learning_goal,
|
||||||
|
primary:
|
||||||
|
pathSteps[i]?.exerciseId != null
|
||||||
|
? {
|
||||||
|
kind: 'library',
|
||||||
|
exerciseId: pathSteps[i].exerciseId,
|
||||||
|
exerciseTitle: pathSteps[i].exerciseTitle,
|
||||||
|
variantId: pathSteps[i].variantId,
|
||||||
|
}
|
||||||
|
: { kind: 'empty' },
|
||||||
|
})),
|
||||||
|
})
|
||||||
const res = await api.suggestProgressionPath({
|
const res = await api.suggestProgressionPath({
|
||||||
query: q,
|
query: q,
|
||||||
max_steps: validSteps.length,
|
max_steps: validSteps.length,
|
||||||
|
|
@ -1202,8 +1276,24 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
include_llm_roadmap: false,
|
include_llm_roadmap: false,
|
||||||
roadmap_first: true,
|
roadmap_first: true,
|
||||||
roadmap_override: override,
|
roadmap_override: override,
|
||||||
|
preserve_slot_assignments: preserveAssignments,
|
||||||
|
slot_assignments: pathSteps
|
||||||
|
.map((row, i) => {
|
||||||
|
if (row.exerciseId == null) return null
|
||||||
|
return {
|
||||||
|
exercise_id: row.exerciseId,
|
||||||
|
variant_id: row.variantId || null,
|
||||||
|
title: row.exerciseTitle || null,
|
||||||
|
is_ai_proposal: false,
|
||||||
|
roadmap_major_step_index: i,
|
||||||
|
roadmap_phase: validSteps[i]?.phase || null,
|
||||||
|
roadmap_learning_goal: validSteps[i]?.learning_goal || null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
progression_graph_id: Number(graphId),
|
progression_graph_id: Number(graphId),
|
||||||
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
|
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
|
||||||
|
...catalogApiPayload,
|
||||||
})
|
})
|
||||||
applyPathMatchResponse(res, q)
|
applyPathMatchResponse(res, q)
|
||||||
setMaxSteps(validSteps.length)
|
setMaxSteps(validSteps.length)
|
||||||
|
|
@ -1406,6 +1496,16 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<PlanningCatalogContextFields
|
||||||
|
catalogCtx={planningCatalogContext}
|
||||||
|
onPatchDimension={patchCatalogDimension}
|
||||||
|
focusAreas={focusAreas}
|
||||||
|
styleDirections={styleDirections}
|
||||||
|
trainingTypes={trainingTypes}
|
||||||
|
targetGroups={targetGroups}
|
||||||
|
disabled={disabled || loading || saving}
|
||||||
|
helperText="Planungskontext steuert Bibliotheks-Matching — wird mit dem Graph gespeichert."
|
||||||
|
/>
|
||||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.4 }}>
|
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.4 }}>
|
||||||
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
|
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
|
||||||
geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang.
|
geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang.
|
||||||
|
|
@ -1826,11 +1926,40 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||||
{pathQa.quality_score != null ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)` : ''}
|
{pathQaQualityPercent(pathQa) != null ? ` (${pathQaQualityPercent(pathQa)} %)` : ''}
|
||||||
</strong>
|
</strong>
|
||||||
|
{pathQaShowsStrongResult(pathQa) ? (
|
||||||
|
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)', fontWeight: 600 }}>
|
||||||
|
Starker Pfad — KI-Highlights können Feinschliff oder optionale Vertiefung sein.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{pathQa.topic_coverage ? (
|
{pathQa.topic_coverage ? (
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{pathQaHighlights.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||||
|
KI-Highlights ({pathQaHighlights.length})
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{pathQaHighlights.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={`hl-${i}-${item.text.slice(0, 24)}`}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))',
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 10%, var(--surface2))',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
|
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
|
||||||
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||||
{pathQa.issues.slice(0, 4).map((issue) => (
|
{pathQa.issues.slice(0, 4).map((issue) => (
|
||||||
|
|
@ -1838,6 +1967,21 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
{pathQaFixHints.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
|
||||||
|
Handlungsbedarf ({pathQaFixHints.length})
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||||
|
{pathQaFixHints.slice(0, 6).map((hint, i) => (
|
||||||
|
<li key={`fix-${i}-${hint.issue}-${hint.step_index ?? 'x'}`}>
|
||||||
|
{hint.title ? `${hint.title}: ` : ''}
|
||||||
|
{hint.reason || hint.issue}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{Number(pathQa.bridge_insert_count) > 0 ? (
|
{Number(pathQa.bridge_insert_count) > 0 ? (
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
|
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
|
||||||
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
|
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
|
||||||
|
|
|
||||||
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,
|
formatRematchLogEntry,
|
||||||
formatRefineLogEntry,
|
formatRefineLogEntry,
|
||||||
hasRematchSlotHints,
|
hasRematchSlotHints,
|
||||||
|
pathQaQualityPercent,
|
||||||
|
pathQaShowsStrongResult,
|
||||||
resolveHintSlotIndex,
|
resolveHintSlotIndex,
|
||||||
resolveOfferSlotIndex,
|
resolveOfferSlotIndex,
|
||||||
|
splitPathQaHints,
|
||||||
} from '../utils/progressionGraphDraft'
|
} from '../utils/progressionGraphDraft'
|
||||||
|
|
||||||
function severityStyle(pathQa) {
|
function severityStyle(pathQa) {
|
||||||
|
|
@ -23,6 +26,131 @@ function severityStyle(pathQa) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) {
|
||||||
|
const { fixHints: optimizationHints } = useMemo(
|
||||||
|
() => splitPathQaHints(pathQa),
|
||||||
|
[pathQa],
|
||||||
|
)
|
||||||
|
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : []
|
||||||
|
const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : []
|
||||||
|
const qaTiers = Array.isArray(pathQa?.qa_tiers) ? pathQa.qa_tiers : []
|
||||||
|
const qualityPct = pathQaQualityPercent(fairQa || pathQa)
|
||||||
|
const hasContent =
|
||||||
|
qaTiers.length > 0
|
||||||
|
|| (pathQa?.rematch_applied && rematchLog.length > 0)
|
||||||
|
|| (pathQa?.refine_applied && refineLog.length > 0)
|
||||||
|
|| optimizationHints.length > 0
|
||||||
|
|
||||||
|
if (!pathQa || !hasContent) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: compact ? '10px' : '12px',
|
||||||
|
padding: compact ? '8px 10px' : '10px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border))',
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface2))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: compact ? '11px' : '12px' }}>
|
||||||
|
{title}
|
||||||
|
{qualityPct != null
|
||||||
|
? ` · ${(fairQa || pathQa)?.overall_ok ? 'OK' : 'Hinweise'} (${qualityPct} % fair bewertet)`
|
||||||
|
: ''}
|
||||||
|
</strong>
|
||||||
|
{fairQa && pathQa && pathQaQualityPercent(pathQa) !== qualityPct ? (
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: '10px', color: 'var(--text3)' }}>
|
||||||
|
Rematch-Protokoll (Pipeline-Score {pathQaQualityPercent(pathQa) ?? '—'} %) — nur Prozessinfo, nicht Pfad-QS.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{qaTiers.length > 0 ? (
|
||||||
|
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)', fontSize: '11px' }}>
|
||||||
|
{qaTiers.map((tier) => (
|
||||||
|
<li key={tier.id || tier.label}>
|
||||||
|
{tier.label || tier.id}
|
||||||
|
{tier.finding_count != null ? ` (${tier.finding_count})` : ''}
|
||||||
|
{tier.applied === false ? ' · LLM aus' : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
{pathQa.rematch_applied && rematchLog.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)', fontSize: '11px' }}>
|
||||||
|
Auto-Rematch
|
||||||
|
{pathQa.rematch_rounds != null ? ` (${pathQa.rematch_rounds} Runde(n))` : ''}
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)', fontSize: '11px' }}>
|
||||||
|
{rematchLog.map((entry, i) => (
|
||||||
|
<li key={`rematch-${i}-${entry.roadmap_major_step_index}-${entry.action}`}>
|
||||||
|
{formatRematchLogEntry(entry)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{pathQa.refine_applied && refineLog.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)', fontSize: '11px' }}>
|
||||||
|
Stufen-Spec verfeinert ({refineLog.length})
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)', fontSize: '11px' }}>
|
||||||
|
{refineLog.map((entry, i) => (
|
||||||
|
<li key={`refine-${i}-${entry.roadmap_major_step_index}`}>
|
||||||
|
{formatRefineLogEntry(entry)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{optimizationHints.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)', fontSize: '11px' }}>
|
||||||
|
Handlungsbedarf ({optimizationHints.length})
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
listStyle: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{optimizationHints.slice(0, compact ? 4 : 8).map((hint, i) => {
|
||||||
|
const slotIdx = resolveHintSlotIndex(hint, draft)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`hint-${i}-${hint.action}-${hint.issue}-${slotIdx ?? 'x'}`}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="exercise-tag" style={{ marginBottom: '4px', display: 'inline-block' }}>
|
||||||
|
{optimizationHintActionLabel(hint.action)}
|
||||||
|
{slotIdx != null ? ` · Slot ${slotIdx + 1}` : ''}
|
||||||
|
</span>
|
||||||
|
{hint.title ? (
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text1)' }}>{hint.title}</div>
|
||||||
|
) : null}
|
||||||
|
{hint.reason ? <p style={{ margin: '4px 0 0' }}>{hint.reason}</p> : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function GapOfferCard({
|
function GapOfferCard({
|
||||||
offer,
|
offer,
|
||||||
slotCount,
|
slotCount,
|
||||||
|
|
@ -159,21 +287,36 @@ export default function ProgressionFindingsPanel({
|
||||||
onInsertGapSlot,
|
onInsertGapSlot,
|
||||||
onGenerateGapAi,
|
onGenerateGapAi,
|
||||||
onRematchSlots = null,
|
onRematchSlots = null,
|
||||||
|
onOptimizeCompare = null,
|
||||||
|
optimizationPreviewQa = null,
|
||||||
|
optimizationPreviewFairQa = null,
|
||||||
|
canOptimizeCompare = false,
|
||||||
|
optimizeCompareBusy = false,
|
||||||
rematchBusy = false,
|
rematchBusy = false,
|
||||||
generatingOfferId = null,
|
generatingOfferId = null,
|
||||||
aiBusy = false,
|
aiBusy = false,
|
||||||
evaluateDisabled = false,
|
evaluateDisabled = false,
|
||||||
|
evaluationStale = false,
|
||||||
}) {
|
}) {
|
||||||
const optimizationHints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : []
|
const { fixHints: optimizationHints, highlightTexts } = useMemo(
|
||||||
|
() => splitPathQaHints(pathQa),
|
||||||
|
[pathQa],
|
||||||
|
)
|
||||||
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : []
|
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : []
|
||||||
const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : []
|
const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : []
|
||||||
const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function'
|
const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function'
|
||||||
|
const showOptimizeCompare =
|
||||||
|
typeof onOptimizeCompare === 'function'
|
||||||
|
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
|
||||||
|
const qualityPct = pathQaQualityPercent(pathQa)
|
||||||
|
const strongResult = pathQaShowsStrongResult(pathQa)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||||
Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken.
|
Bewertet den aktuellen Slot-Stand (3-Stufen-QS, ohne Auto-Rematch). Nach Änderungen am Graphen
|
||||||
|
erscheint ein Hinweis — dann erneut „Graph bewerten“.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -192,6 +335,24 @@ export default function ProgressionFindingsPanel({
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{evaluationStale && pathQa ? (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
marginBottom: '12px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--danger) 45%, var(--border))',
|
||||||
|
background: 'color-mix(in srgb, var(--danger) 12%, var(--surface2))',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
color: 'var(--text1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Bewertung veraltet, neue Bewertung notwendig.</strong>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{pathQa ? (
|
{pathQa ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -204,13 +365,56 @@ export default function ProgressionFindingsPanel({
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||||
{pathQa.quality_score != null
|
{qualityPct != null ? ` (${qualityPct} %)` : ''}
|
||||||
? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)`
|
|
||||||
: ''}
|
|
||||||
</strong>
|
</strong>
|
||||||
|
{pathQa.assignments_preserved ? (
|
||||||
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
|
||||||
|
Bewertung des aktuellen Pfads. „Übungen matchen“ öffnet einen Dialog mit Vorschlägen für
|
||||||
|
alle Slots — Übernahme nur nach deiner Auswahl.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{strongResult ? (
|
||||||
|
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)', fontWeight: 600 }}>
|
||||||
|
Starker Pfad — KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{pathQa.topic_coverage ? (
|
{pathQa.topic_coverage ? (
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{highlightTexts.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||||
|
KI-Highlights ({highlightTexts.length})
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
listStyle: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{highlightTexts.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={`hl-${i}-${item.text.slice(0, 24)}`}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))',
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 10%, var(--surface2))',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text1)',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
|
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
|
||||||
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||||
{pathQa.issues.map((issue) => (
|
{pathQa.issues.map((issue) => (
|
||||||
|
|
@ -218,7 +422,9 @@ export default function ProgressionFindingsPanel({
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
{Array.isArray(pathQa.recommendations) && pathQa.recommendations.length > 0 ? (
|
{Array.isArray(pathQa.recommendations) &&
|
||||||
|
pathQa.recommendations.length > 0 &&
|
||||||
|
highlightTexts.length === 0 ? (
|
||||||
<>
|
<>
|
||||||
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>Empfehlungen</p>
|
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>Empfehlungen</p>
|
||||||
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
|
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||||
|
|
@ -265,7 +471,7 @@ export default function ProgressionFindingsPanel({
|
||||||
{optimizationHints.length > 0 ? (
|
{optimizationHints.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
|
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
|
||||||
Optimierungspotenziale ({optimizationHints.length})
|
Handlungsbedarf ({optimizationHints.length})
|
||||||
</p>
|
</p>
|
||||||
<ul
|
<ul
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -303,7 +509,18 @@ export default function ProgressionFindingsPanel({
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{showRematchAction ? (
|
{showOptimizeCompare ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
style={{ marginTop: '8px', fontSize: '12px' }}
|
||||||
|
disabled={optimizeCompareBusy || evaluateDisabled}
|
||||||
|
onClick={onOptimizeCompare}
|
||||||
|
>
|
||||||
|
{optimizeCompareBusy ? 'Vergleich läuft…' : 'Optimierung vergleichen'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{showRematchAction && !showOptimizeCompare ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary btn-full"
|
className="btn btn-secondary btn-full"
|
||||||
|
|
@ -316,6 +533,15 @@ export default function ProgressionFindingsPanel({
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{optimizationPreviewQa ? (
|
||||||
|
<PathQaPipelineDetails
|
||||||
|
pathQa={optimizationPreviewQa}
|
||||||
|
fairQa={optimizationPreviewFairQa}
|
||||||
|
draft={draft}
|
||||||
|
title="3-Stufen-Optimierung (Vorschlag — nur im Vergleichsdialog)"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import ExercisePickerModal from './ExercisePickerModal'
|
||||||
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||||
import ProgressionSlotCard from './ProgressionSlotCard'
|
import ProgressionSlotCard from './ProgressionSlotCard'
|
||||||
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
||||||
|
import PlanningCatalogContextFields from './PlanningCatalogContextFields'
|
||||||
import {
|
import {
|
||||||
aiPreviewToQuickCreateDraft,
|
aiPreviewToQuickCreateDraft,
|
||||||
buildQuickCreateAiPreview,
|
buildQuickCreateAiPreview,
|
||||||
|
|
@ -21,34 +22,45 @@ import {
|
||||||
initialStageLearningGoalFromOffer,
|
initialStageLearningGoalFromOffer,
|
||||||
} from '../utils/planningContextForExerciseAi'
|
} from '../utils/planningContextForExerciseAi'
|
||||||
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
||||||
|
import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal'
|
||||||
import {
|
import {
|
||||||
addSlotToDraft,
|
addSlotToDraft,
|
||||||
applyEvaluateResponseToDraft,
|
applyEvaluateResponseToDraft,
|
||||||
applyGapOfferToDraft,
|
applyGapOfferToDraft,
|
||||||
applyMatchResponseToDraft,
|
applySelectedCompareSteps,
|
||||||
|
applySelectedSlotSuggestions,
|
||||||
applyResolvedStructuredToDraft,
|
applyResolvedStructuredToDraft,
|
||||||
buildPlanningArtifactFromDraft,
|
buildPlanningArtifactFromDraft,
|
||||||
|
buildProgressionComparePayload,
|
||||||
|
collectGapOffersFromApiResponse,
|
||||||
|
compareSlotDiffs,
|
||||||
|
compareDiffsForDialog,
|
||||||
|
dedupeGapOffersBySlot,
|
||||||
|
draftHasLibrarySlotAssignments,
|
||||||
|
draftRetrievalBoostExerciseIds,
|
||||||
|
EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||||
|
filterGapOffersForUnfilledSlots,
|
||||||
hydrateProgressionGraphDraft,
|
hydrateProgressionGraphDraft,
|
||||||
SLOT_MIN,
|
|
||||||
insertSlotInDraft,
|
insertSlotInDraft,
|
||||||
librarySlotExercise,
|
librarySlotExercise,
|
||||||
majorStepsToOverridePayload,
|
majorStepsToOverridePayload,
|
||||||
|
mergeGapOffersForDraft,
|
||||||
moveSlotInDraft,
|
moveSlotInDraft,
|
||||||
patchSlotInDraft,
|
patchSlotInDraft,
|
||||||
|
pathQaQualityPercent,
|
||||||
|
planningCatalogContextToApi,
|
||||||
|
rejectedCompareDiffs,
|
||||||
removeSlotFromDraft,
|
removeSlotFromDraft,
|
||||||
saveProgressionGraphDraft,
|
saveProgressionGraphDraft,
|
||||||
|
setCatalogSelectItems,
|
||||||
setSlotPrimaryLibrary,
|
setSlotPrimaryLibrary,
|
||||||
SLOT_MAX,
|
SLOT_MAX,
|
||||||
|
SLOT_MIN,
|
||||||
slotsAsPathStepRows,
|
slotsAsPathStepRows,
|
||||||
slotsToEvaluateSteps,
|
slotsToEvaluateSteps,
|
||||||
draftRetrievalBoostExerciseIds,
|
|
||||||
slotsToSlotAssignments,
|
slotsToSlotAssignments,
|
||||||
syncProgressionRoadmapFromSlots,
|
syncProgressionRoadmapFromSlots,
|
||||||
syncSlotPhasesFromRoadmap,
|
syncSlotPhasesFromRoadmap,
|
||||||
EMPTY_PLANNING_CATALOG_CONTEXT,
|
|
||||||
getCatalogSelectId,
|
|
||||||
planningCatalogContextToApi,
|
|
||||||
setCatalogSelectItems,
|
|
||||||
} from '../utils/progressionGraphDraft'
|
} from '../utils/progressionGraphDraft'
|
||||||
|
|
||||||
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
||||||
|
|
@ -111,6 +123,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
const [slotQuickSaving, setSlotQuickSaving] = useState(false)
|
const [slotQuickSaving, setSlotQuickSaving] = useState(false)
|
||||||
const [slotQuickError, setSlotQuickError] = useState('')
|
const [slotQuickError, setSlotQuickError] = useState('')
|
||||||
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
|
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
|
||||||
|
const [compareOpen, setCompareOpen] = useState(false)
|
||||||
|
const [comparePayload, setComparePayload] = useState(null)
|
||||||
|
const [compareSource, setCompareSource] = useState('manual')
|
||||||
|
const [comparing, setComparing] = useState(false)
|
||||||
|
const [compareApplying, setCompareApplying] = useState(false)
|
||||||
|
const [proposedPathQa, setProposedPathQa] = useState(null)
|
||||||
|
|
||||||
const loadGraph = useCallback(async () => {
|
const loadGraph = useCallback(async () => {
|
||||||
if (!graphId) return
|
if (!graphId) return
|
||||||
|
|
@ -182,7 +200,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
const next = patchFn(prev)
|
const next = patchFn(prev)
|
||||||
return { ...next, dirty: true }
|
return { ...next, dirty: true, findingsStale: true }
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -346,7 +364,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
{ ...prev, progressionRoadmap: roadmap },
|
{ ...prev, progressionRoadmap: roadmap },
|
||||||
roadmap,
|
roadmap,
|
||||||
)
|
)
|
||||||
return { ...structured, dirty: true }
|
return { ...structured, dirty: true, findingsStale: true }
|
||||||
})
|
})
|
||||||
setStartTargetReady(true)
|
setStartTargetReady(true)
|
||||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
|
|
@ -415,7 +433,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
graphName: draft.graphName,
|
graphName: draft.graphName,
|
||||||
})
|
})
|
||||||
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
|
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
|
||||||
setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true })
|
setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true, findingsStale: true })
|
||||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
||||||
|
|
@ -424,6 +442,149 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildEvaluateRequest = (synced, { llmPathQa = true, aiGapFill = true } = {}) => {
|
||||||
|
const override = majorStepsToOverridePayload(synced.slots)
|
||||||
|
return {
|
||||||
|
query: (synced.goalQuery || '').trim(),
|
||||||
|
max_steps: synced.slots.length || draft?.maxSteps || 5,
|
||||||
|
include_path_qa: true,
|
||||||
|
include_llm_path_qa: llmPathQa,
|
||||||
|
include_ai_gap_fill: aiGapFill,
|
||||||
|
include_path_reorder: false,
|
||||||
|
include_llm_intent: false,
|
||||||
|
evaluate_only: true,
|
||||||
|
evaluate_steps: slotsToEvaluateSteps(synced),
|
||||||
|
roadmap_override: override,
|
||||||
|
slot_assignments: slotsToSlotAssignments(synced),
|
||||||
|
progression_graph_id: Number(graphId),
|
||||||
|
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
|
||||||
|
...catalogApiPayload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPathEvaluate = async (synced, options) =>
|
||||||
|
api.suggestProgressionPath(buildEvaluateRequest(synced, options))
|
||||||
|
|
||||||
|
const applyEvaluateResult = (synced, res) => {
|
||||||
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
|
setPathQa(res?.path_qa || null)
|
||||||
|
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
|
||||||
|
return {
|
||||||
|
draft: { ...evaluated, lastFindings: res?.path_qa || null, findingsStale: false },
|
||||||
|
remainingOffers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMatchRequestBase = (synced) => {
|
||||||
|
const override = majorStepsToOverridePayload(synced.slots)
|
||||||
|
return {
|
||||||
|
query: (synced.goalQuery || '').trim(),
|
||||||
|
max_steps: synced.slots.length,
|
||||||
|
include_llm_intent: true,
|
||||||
|
include_path_qa: true,
|
||||||
|
include_llm_path_qa: true,
|
||||||
|
include_path_reorder: false,
|
||||||
|
include_ai_gap_fill: true,
|
||||||
|
include_roadmap_preview: true,
|
||||||
|
include_llm_roadmap: false,
|
||||||
|
roadmap_first: true,
|
||||||
|
roadmap_override: override,
|
||||||
|
slot_assignments: slotsToSlotAssignments(synced),
|
||||||
|
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
|
||||||
|
progression_graph_id: Number(graphId),
|
||||||
|
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
|
||||||
|
...catalogApiPayload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
|
||||||
|
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
|
||||||
|
const baselineRes = await fetchPathEvaluate(synced)
|
||||||
|
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, baselineRes)
|
||||||
|
setDraft(evaluated)
|
||||||
|
const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes)
|
||||||
|
setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers)
|
||||||
|
|
||||||
|
setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…')
|
||||||
|
let compareRes
|
||||||
|
let reviewError = null
|
||||||
|
try {
|
||||||
|
const reviewRes = await api.suggestProgressionPath({
|
||||||
|
...buildEvaluateRequest(synced),
|
||||||
|
evaluate_only: false,
|
||||||
|
unified_slot_review: true,
|
||||||
|
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
|
||||||
|
baseline_path_qa_snapshot: baselineRes?.path_qa || null,
|
||||||
|
baseline_quality_score:
|
||||||
|
baselineRes?.path_qa?.quality_score != null
|
||||||
|
? Number(baselineRes.path_qa.quality_score)
|
||||||
|
: null,
|
||||||
|
include_llm_path_qa: false,
|
||||||
|
include_llm_intent: false,
|
||||||
|
auto_rematch_after_qa: false,
|
||||||
|
})
|
||||||
|
if (!reviewRes?.unified_slot_review) {
|
||||||
|
reviewError =
|
||||||
|
'Slot-Review nicht verfügbar — Backend neu starten/deployen (unified_slot_review fehlt).'
|
||||||
|
compareRes = buildProgressionComparePayload(baselineRes, {
|
||||||
|
...reviewRes,
|
||||||
|
unified_slot_review: true,
|
||||||
|
slot_reviews: [],
|
||||||
|
review_error: reviewError,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
compareRes = buildProgressionComparePayload(baselineRes, reviewRes)
|
||||||
|
}
|
||||||
|
setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes))
|
||||||
|
} catch (e) {
|
||||||
|
reviewError = e.message || 'Slot-Review fehlgeschlagen'
|
||||||
|
compareRes = buildProgressionComparePayload(baselineRes, {
|
||||||
|
unified_slot_review: true,
|
||||||
|
slot_reviews: [],
|
||||||
|
review_error: reviewError,
|
||||||
|
path_qa: baselineRes?.path_qa,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
presentMatchCompare(compareRes, { source, reviewError })
|
||||||
|
return compareRes
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => {
|
||||||
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
|
setTargetSummary(res?.target_profile_summary || null)
|
||||||
|
setComparePayload(reviewError ? { ...res, review_error: reviewError } : res)
|
||||||
|
setCompareSource(source)
|
||||||
|
setProposedPathQa(res?.proposed_path_qa_pipeline || null)
|
||||||
|
setCompareOpen(true)
|
||||||
|
|
||||||
|
const baselineQa = res?.baseline_path_qa || null
|
||||||
|
const slotReviews = res?.slot_reviews || []
|
||||||
|
const autoCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length
|
||||||
|
const diffCount = autoCount || res?.slot_diff_count || 0
|
||||||
|
const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length
|
||||||
|
const problemCount = res?.match_summary?.problem_slot_count
|
||||||
|
?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0)
|
||||||
|
const bPct = pathQaQualityPercent(baselineQa)
|
||||||
|
let notice = reviewError
|
||||||
|
? `Match: Dialog geöffnet — ${reviewError}`
|
||||||
|
: slotReviews.length > 0
|
||||||
|
? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.`
|
||||||
|
: diffCount > 0
|
||||||
|
? `Match: ${diffCount} Verbesserung(en).`
|
||||||
|
: problemCount > 0
|
||||||
|
? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.`
|
||||||
|
: 'Match: Pfad geprüft — siehe Dialog.'
|
||||||
|
if (rejectedCount > 0) {
|
||||||
|
notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).`
|
||||||
|
}
|
||||||
|
const gapCount = collectGapOffersFromApiResponse(res).length
|
||||||
|
if (gapCount > 0) {
|
||||||
|
notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.`
|
||||||
|
}
|
||||||
|
setMatchNotice(notice)
|
||||||
|
}
|
||||||
|
|
||||||
const runMatch = async () => {
|
const runMatch = async () => {
|
||||||
const q = (draft?.goalQuery || '').trim()
|
const q = (draft?.goalQuery || '').trim()
|
||||||
if (q.length < 3) {
|
if (q.length < 3) {
|
||||||
|
|
@ -439,65 +600,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
setMatchNotice('')
|
setMatchNotice('')
|
||||||
try {
|
try {
|
||||||
const synced = syncProgressionRoadmapFromSlots(draft)
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||||
const override = majorStepsToOverridePayload(synced.slots)
|
setProposedPathQa(null)
|
||||||
const res = await api.suggestProgressionPath({
|
await runMatchCompareFlow(synced, { source: 'match' })
|
||||||
query: q,
|
|
||||||
max_steps: synced.slots.length,
|
|
||||||
include_llm_intent: true,
|
|
||||||
include_path_qa: true,
|
|
||||||
include_llm_path_qa: true,
|
|
||||||
include_path_reorder: false,
|
|
||||||
include_ai_gap_fill: true,
|
|
||||||
include_roadmap_preview: true,
|
|
||||||
include_llm_roadmap: false,
|
|
||||||
roadmap_first: true,
|
|
||||||
roadmap_override: override,
|
|
||||||
slot_assignments: slotsToSlotAssignments(synced),
|
|
||||||
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
|
|
||||||
progression_graph_id: Number(graphId),
|
|
||||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
|
||||||
...catalogApiPayload,
|
|
||||||
})
|
|
||||||
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
|
|
||||||
{
|
|
||||||
...synced,
|
|
||||||
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
|
|
||||||
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
|
|
||||||
},
|
|
||||||
res,
|
|
||||||
)
|
|
||||||
setDraft(matched)
|
|
||||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
||||||
setTargetSummary(res?.target_profile_summary || null)
|
|
||||||
setPathQa(res?.path_qa || null)
|
|
||||||
setGapFillOffers(remainingOffers)
|
|
||||||
const ms = res?.match_summary
|
|
||||||
const rematchLog = res?.path_qa?.rematch_log
|
|
||||||
const rematchRounds = res?.path_qa?.rematch_rounds
|
|
||||||
if (ms) {
|
|
||||||
const parts = [
|
|
||||||
`Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.`,
|
|
||||||
]
|
|
||||||
if (rematchRounds > 0 && Array.isArray(rematchLog) && rematchLog.length > 0) {
|
|
||||||
parts.push(
|
|
||||||
`Auto-Rematch: ${rematchLog.length} Anpassung(en) in ${rematchRounds} Runde(n).`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const refineLog = res?.path_qa?.refine_log
|
|
||||||
if (Array.isArray(refineLog) && refineLog.length > 0) {
|
|
||||||
parts.push(`Stufen-Spec: ${refineLog.length} Slot(s) verfeinert.`)
|
|
||||||
}
|
|
||||||
setMatchNotice(parts.join(' '))
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await saveProgressionGraphDraft(api, graphId, {
|
|
||||||
...matched,
|
|
||||||
lastFindings: res?.path_qa || null,
|
|
||||||
})
|
|
||||||
setDraft((prev) => (prev ? { ...prev, dirty: false } : prev))
|
|
||||||
} catch (saveErr) {
|
|
||||||
console.warn('Match-Artefakt konnte nicht gespeichert werden', saveErr)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -505,6 +609,61 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runOptimizeCompare = async () => {
|
||||||
|
const q = (draft?.goalQuery || '').trim()
|
||||||
|
if (q.length < 3) {
|
||||||
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (validMajorSteps.length < 2) {
|
||||||
|
alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setComparing(true)
|
||||||
|
setActionErr('')
|
||||||
|
setMatchNotice('')
|
||||||
|
try {
|
||||||
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||||
|
setProposedPathQa(null)
|
||||||
|
await runMatchCompareFlow(synced, { source: 'manual' })
|
||||||
|
} catch (e) {
|
||||||
|
setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setComparing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyOptimizeCompare = async (selectedMajorIndices) => {
|
||||||
|
if (!comparePayload || !draft) return
|
||||||
|
setCompareApplying(true)
|
||||||
|
setMatchNotice('Übernahme: Slots aktualisieren …')
|
||||||
|
try {
|
||||||
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||||
|
const nextDraft = comparePayload?.unified_slot_review
|
||||||
|
? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices)
|
||||||
|
: applySelectedCompareSteps(
|
||||||
|
synced,
|
||||||
|
comparePayload.proposed_steps || comparePayload.steps,
|
||||||
|
selectedMajorIndices,
|
||||||
|
)
|
||||||
|
const syncedNext = syncProgressionRoadmapFromSlots(nextDraft)
|
||||||
|
|
||||||
|
setDraft({ ...syncedNext, dirty: false, findingsStale: true })
|
||||||
|
setCompareOpen(false)
|
||||||
|
setComparePayload(null)
|
||||||
|
setProposedPathQa(null)
|
||||||
|
|
||||||
|
await saveProgressionGraphDraft(api, graphId, { ...syncedNext, findingsStale: true })
|
||||||
|
setMatchNotice(
|
||||||
|
'Übernommen und gespeichert. Bewertung bezieht sich noch auf den vorherigen Stand — bitte „Graph bewerten“.',
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
setActionErr(e.message || 'Übernahme fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setCompareApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runEvaluate = async () => {
|
const runEvaluate = async () => {
|
||||||
const q = (draft?.goalQuery || '').trim()
|
const q = (draft?.goalQuery || '').trim()
|
||||||
if (q.length < 3) {
|
if (q.length < 3) {
|
||||||
|
|
@ -513,30 +672,14 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
}
|
}
|
||||||
setEvaluating(true)
|
setEvaluating(true)
|
||||||
setActionErr('')
|
setActionErr('')
|
||||||
|
setProposedPathQa(null)
|
||||||
try {
|
try {
|
||||||
const synced = syncProgressionRoadmapFromSlots(draft)
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||||
const override =
|
const res = await fetchPathEvaluate(synced)
|
||||||
validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined
|
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res)
|
||||||
const res = await api.suggestProgressionPath({
|
setDraft(evaluated)
|
||||||
query: q,
|
const mergedOffers = mergeGapOffersForDraft(evaluated, res)
|
||||||
max_steps: synced.slots.length || draft.maxSteps || 5,
|
setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : remainingOffers)
|
||||||
include_path_qa: true,
|
|
||||||
include_llm_path_qa: true,
|
|
||||||
include_ai_gap_fill: true,
|
|
||||||
include_path_reorder: false,
|
|
||||||
include_llm_intent: false,
|
|
||||||
evaluate_only: true,
|
|
||||||
evaluate_steps: slotsToEvaluateSteps(synced),
|
|
||||||
roadmap_override: override,
|
|
||||||
progression_graph_id: Number(graphId),
|
|
||||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
|
||||||
...catalogApiPayload,
|
|
||||||
})
|
|
||||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
||||||
setPathQa(res?.path_qa || null)
|
|
||||||
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
|
|
||||||
setDraft({ ...evaluated, lastFindings: res?.path_qa || null })
|
|
||||||
setGapFillOffers(remainingOffers)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -563,7 +706,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
const handleApplyGapOffer = (offer, slotIndex) => {
|
const handleApplyGapOffer = (offer, slotIndex) => {
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
const next = applyGapOfferToDraft(prev, offer, { slotIndex })
|
const next = applyGapOfferToDraft(prev, offer, { slotIndex })
|
||||||
return { ...next, dirty: true }
|
return { ...next, dirty: true, findingsStale: true }
|
||||||
})
|
})
|
||||||
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
||||||
}
|
}
|
||||||
|
|
@ -575,7 +718,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
}
|
}
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true })
|
const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true })
|
||||||
return { ...next, dirty: true }
|
return { ...next, dirty: true, findingsStale: true }
|
||||||
})
|
})
|
||||||
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
||||||
}
|
}
|
||||||
|
|
@ -689,7 +832,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
: null
|
: null
|
||||||
if (resolvedSlot != null) {
|
if (resolvedSlot != null) {
|
||||||
setSlotQuickCreateIndex(resolvedSlot)
|
setSlotQuickCreateIndex(resolvedSlot)
|
||||||
setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }))
|
setDraft((prev) => ({
|
||||||
|
...applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }),
|
||||||
|
findingsStale: true,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
setSlotQuickCreateDraft(aiDraft)
|
setSlotQuickCreateDraft(aiDraft)
|
||||||
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
||||||
|
|
@ -737,7 +883,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft)
|
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft)
|
||||||
const created = await api.createExercise(payload)
|
const created = await api.createExercise(payload)
|
||||||
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||||
setDraft((prev) => setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created))
|
setDraft((prev) => ({
|
||||||
|
...setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created),
|
||||||
|
dirty: true,
|
||||||
|
findingsStale: true,
|
||||||
|
}))
|
||||||
setSlotQuickCreateDraft(null)
|
setSlotQuickCreateDraft(null)
|
||||||
setSlotQuickCreateIndex(null)
|
setSlotQuickCreateIndex(null)
|
||||||
setActiveOffer(null)
|
setActiveOffer(null)
|
||||||
|
|
@ -909,83 +1059,16 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<PlanningCatalogContextFields
|
||||||
style={{
|
catalogCtx={catalogCtx}
|
||||||
display: 'grid',
|
onPatchDimension={patchCatalogDimension}
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
focusAreas={focusAreas}
|
||||||
gap: '10px',
|
styleDirections={styleDirections}
|
||||||
marginTop: '10px',
|
trainingTypes={trainingTypes}
|
||||||
}}
|
targetGroups={targetGroups}
|
||||||
>
|
disabled={busy}
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
helperText="Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert."
|
||||||
<label className="form-label">Primärfokus</label>
|
/>
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
disabled={busy}
|
|
||||||
value={getCatalogSelectId(catalogCtx.focusAreas)}
|
|
||||||
onChange={(e) => patchCatalogDimension('focusAreas', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">— optional —</option>
|
|
||||||
{(focusAreas || []).map((fa) => (
|
|
||||||
<option key={fa.id} value={String(fa.id)}>
|
|
||||||
{fa.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Stilrichtung</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
disabled={busy}
|
|
||||||
value={getCatalogSelectId(catalogCtx.styleDirections)}
|
|
||||||
onChange={(e) => patchCatalogDimension('styleDirections', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">— optional —</option>
|
|
||||||
{(styleDirections || []).map((row) => (
|
|
||||||
<option key={row.id} value={String(row.id)}>
|
|
||||||
{row.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Trainingsstil</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
disabled={busy}
|
|
||||||
value={getCatalogSelectId(catalogCtx.trainingTypes)}
|
|
||||||
onChange={(e) => patchCatalogDimension('trainingTypes', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">— optional —</option>
|
|
||||||
{(trainingTypes || []).map((row) => (
|
|
||||||
<option key={row.id} value={String(row.id)}>
|
|
||||||
{row.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Zielgruppe</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
disabled={busy}
|
|
||||||
value={getCatalogSelectId(catalogCtx.targetGroups)}
|
|
||||||
onChange={(e) => patchCatalogDimension('targetGroups', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">— optional —</option>
|
|
||||||
{(targetGroups || []).map((row) => (
|
|
||||||
<option key={row.id} value={String(row.id)}>
|
|
||||||
{row.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
|
|
||||||
Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig
|
|
||||||
von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert.
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
|
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
|
||||||
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
|
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
|
||||||
geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang.
|
geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang.
|
||||||
|
|
@ -1018,9 +1101,25 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={busy || matching}
|
disabled={busy || matching}
|
||||||
onClick={runMatch}
|
onClick={runMatch}
|
||||||
|
title={
|
||||||
|
draftHasLibrarySlotAssignments(draft)
|
||||||
|
? 'Voller Match mit Auto-Optimierung — bei Abweichungen öffnet sich der Vergleichsdialog'
|
||||||
|
: 'Bibliotheks-Übungen für leere Slots finden'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{matching ? 'Match…' : 'Übungen matchen'}
|
{matching ? 'Match…' : 'Übungen matchen'}
|
||||||
</button>
|
</button>
|
||||||
|
{draftHasLibrarySlotAssignments(draft) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={busy || comparing || matching}
|
||||||
|
onClick={runOptimizeCompare}
|
||||||
|
title="Aktuellen Pfad vs. voller Match mit Auto-Optimierung — du wählst pro Slot"
|
||||||
|
>
|
||||||
|
{comparing ? 'Vergleich…' : 'Optimierung vergleichen'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}>
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}>
|
||||||
{busy ? 'Speichern…' : 'Graph speichern'}
|
{busy ? 'Speichern…' : 'Graph speichern'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1077,11 +1176,15 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
slotCount={draft.slots.length}
|
slotCount={draft.slots.length}
|
||||||
loading={evaluating}
|
loading={evaluating}
|
||||||
error=""
|
error=""
|
||||||
|
evaluationStale={Boolean(draft?.findingsStale)}
|
||||||
onEvaluate={runEvaluate}
|
onEvaluate={runEvaluate}
|
||||||
onApplyGapOffer={handleApplyGapOffer}
|
onApplyGapOffer={handleApplyGapOffer}
|
||||||
onInsertGapSlot={handleInsertGapSlot}
|
onInsertGapSlot={handleInsertGapSlot}
|
||||||
onGenerateGapAi={openGapFillPrep}
|
onGenerateGapAi={openGapFillPrep}
|
||||||
onRematchSlots={runMatch}
|
onRematchSlots={runMatch}
|
||||||
|
onOptimizeCompare={runOptimizeCompare}
|
||||||
|
canOptimizeCompare={validMajorSteps.length >= 2}
|
||||||
|
optimizeCompareBusy={comparing}
|
||||||
rematchBusy={matching}
|
rematchBusy={matching}
|
||||||
generatingOfferId={generatingOfferId}
|
generatingOfferId={generatingOfferId}
|
||||||
aiBusy={gapAiBusy}
|
aiBusy={gapAiBusy}
|
||||||
|
|
@ -1121,6 +1224,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
zIndex={2100}
|
zIndex={2100}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProgressionOptimizeCompareModal
|
||||||
|
open={compareOpen}
|
||||||
|
comparison={comparePayload}
|
||||||
|
mode={compareSource}
|
||||||
|
onClose={() => {
|
||||||
|
if (compareApplying) return
|
||||||
|
setCompareOpen(false)
|
||||||
|
setComparePayload(null)
|
||||||
|
setProposedPathQa(null)
|
||||||
|
}}
|
||||||
|
onApplySelected={applyOptimizeCompare}
|
||||||
|
applying={compareApplying}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExerciseGapFillPrepModal
|
<ExerciseGapFillPrepModal
|
||||||
open={gapPrepOpen}
|
open={gapPrepOpen}
|
||||||
offer={activeOffer}
|
offer={activeOffer}
|
||||||
|
|
|
||||||
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'
|
return OPTIMIZATION_ACTION_LABELS[action] || action || 'Hinweis'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** LLM-Empfehlungen von technischen Fix-Hinweisen trennen (QS-UI). */
|
||||||
|
export function splitPathQaHints(pathQa) {
|
||||||
|
const hints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : []
|
||||||
|
const fixHints = hints.filter((h) => String(h?.issue || '') !== 'llm_recommendation')
|
||||||
|
const highlightHints = hints.filter((h) => String(h?.issue || '') === 'llm_recommendation')
|
||||||
|
const recommendations = Array.isArray(pathQa?.recommendations) ? pathQa.recommendations : []
|
||||||
|
const highlightTexts = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const rec of recommendations) {
|
||||||
|
const text = String(rec || '').trim()
|
||||||
|
const key = text.toLowerCase()
|
||||||
|
if (text && !seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
highlightTexts.push({ text, source: 'recommendation' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const hint of highlightHints) {
|
||||||
|
const text = String(hint.reason || hint.title || '').trim()
|
||||||
|
const key = text.toLowerCase()
|
||||||
|
if (text && !seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
highlightTexts.push({ text, source: 'hint', hint })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fixHints, highlightTexts }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathQaQualityPercent(pathQa) {
|
||||||
|
if (pathQa?.quality_score == null || !Number.isFinite(Number(pathQa.quality_score))) return null
|
||||||
|
return Math.round(Number(pathQa.quality_score) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathQaShowsStrongResult(pathQa) {
|
||||||
|
const pct = pathQaQualityPercent(pathQa)
|
||||||
|
if (pathQa?.overall_ok && pct != null && pct >= 85) return true
|
||||||
|
return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length)
|
||||||
|
}
|
||||||
|
|
||||||
/** Slot-Index aus optimization_hint (roadmap_major_step_index oder step_index). */
|
/** Slot-Index aus optimization_hint (roadmap_major_step_index oder step_index). */
|
||||||
export function resolveHintSlotIndex(hint, draft = null) {
|
export function resolveHintSlotIndex(hint, draft = null) {
|
||||||
if (!hint || typeof hint !== 'object') return null
|
if (!hint || typeof hint !== 'object') return null
|
||||||
|
|
@ -349,12 +387,29 @@ export function collectGapOffersFromApiResponse(res) {
|
||||||
}
|
}
|
||||||
for (const offer of res?.gap_fill_offers || []) add(offer)
|
for (const offer of res?.gap_fill_offers || []) add(offer)
|
||||||
for (const offer of res?.path_qa?.gap_fill_offers || []) add(offer)
|
for (const offer of res?.path_qa?.gap_fill_offers || []) add(offer)
|
||||||
for (const step of res?.steps || []) {
|
const stepSources = [
|
||||||
|
...(res?.steps || []),
|
||||||
|
...(res?.proposed_steps || []),
|
||||||
|
...(res?.proposed_steps_pipeline || []),
|
||||||
|
]
|
||||||
|
for (const step of stepSources) {
|
||||||
if (step?.gap_offer) add(step.gap_offer)
|
if (step?.gap_offer) add(step.gap_offer)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** KI-Angebote aus einer oder mehreren Planungs-Antworten für leere Slots sammeln. */
|
||||||
|
export function mergeGapOffersForDraft(draft, ...responses) {
|
||||||
|
const collected = []
|
||||||
|
for (const res of responses) {
|
||||||
|
if (res) collected.push(...collectGapOffersFromApiResponse(res))
|
||||||
|
}
|
||||||
|
return filterGapOffersForUnfilledSlots(
|
||||||
|
draft,
|
||||||
|
dedupeGapOffersBySlot(collected, draft),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */
|
/** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */
|
||||||
export function dedupeGapOffersBySlot(offers, draft) {
|
export function dedupeGapOffersBySlot(offers, draft) {
|
||||||
const bySlot = new Map()
|
const bySlot = new Map()
|
||||||
|
|
@ -780,6 +835,7 @@ export function hydrateProgressionGraphDraft({
|
||||||
progressionRoadmap: artifact?.progression_roadmap || null,
|
progressionRoadmap: artifact?.progression_roadmap || null,
|
||||||
planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact),
|
planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact),
|
||||||
lastFindings: artifact?.last_findings || null,
|
lastFindings: artifact?.last_findings || null,
|
||||||
|
findingsStale: Boolean(artifact?.findings_stale),
|
||||||
primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
|
primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
|
||||||
siblingEdgeIds: siblingEdges.map((e) => e.id),
|
siblingEdgeIds: siblingEdges.map((e) => e.id),
|
||||||
dirty: false,
|
dirty: false,
|
||||||
|
|
@ -816,6 +872,7 @@ export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined
|
||||||
|
|
||||||
const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings
|
const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings
|
||||||
if (findings) artifact.last_findings = findings
|
if (findings) artifact.last_findings = findings
|
||||||
|
artifact.findings_stale = Boolean(draft.findingsStale)
|
||||||
|
|
||||||
return artifact
|
return artifact
|
||||||
}
|
}
|
||||||
|
|
@ -868,6 +925,431 @@ export function slotsToSlotAssignments(draft) {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mindestens ein Bibliotheks-Slot belegt. */
|
||||||
|
export function draftHasLibrarySlotAssignments(draft) {
|
||||||
|
return slotsToSlotAssignments(draft).length >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCompareSlotTitle(title) {
|
||||||
|
return (title || '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepsByMajorIndex(steps) {
|
||||||
|
const out = new Map()
|
||||||
|
for (const step of steps || []) {
|
||||||
|
if (step?.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.set(Number(step.roadmap_major_step_index), step)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProgressionSlotDiffs(baselineSteps, proposedSteps) {
|
||||||
|
const baseBy = stepsByMajorIndex(baselineSteps)
|
||||||
|
const propBy = stepsByMajorIndex(proposedSteps)
|
||||||
|
const indices = new Set([...baseBy.keys(), ...propBy.keys()])
|
||||||
|
const diffs = []
|
||||||
|
for (const midx of [...indices].sort((a, b) => a - b)) {
|
||||||
|
const base = baseBy.get(midx) || {}
|
||||||
|
const prop = propBy.get(midx) || {}
|
||||||
|
const baseId = base.exercise_id
|
||||||
|
const propId = prop.exercise_id
|
||||||
|
if (baseId != null && propId != null && Number(baseId) === Number(propId)) continue
|
||||||
|
const baseTitle = (base.title || '').trim() || null
|
||||||
|
const propTitle = (prop.title || '').trim() || null
|
||||||
|
diffs.push({
|
||||||
|
roadmap_major_step_index: midx,
|
||||||
|
baseline_exercise_id: baseId != null ? Number(baseId) : null,
|
||||||
|
baseline_title: baseTitle,
|
||||||
|
proposed_exercise_id: propId != null ? Number(propId) : null,
|
||||||
|
proposed_title: propTitle,
|
||||||
|
baseline_slot_status: base.slot_status,
|
||||||
|
proposed_slot_status: prop.slot_status,
|
||||||
|
changed: baseId !== propId || baseTitle !== propTitle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return diffs
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotateCompareSlotDiffs(diffs) {
|
||||||
|
return (diffs || []).map((raw) => {
|
||||||
|
const bt = normalizeCompareSlotTitle(raw.baseline_title)
|
||||||
|
const pt = normalizeCompareSlotTitle(raw.proposed_title)
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
trivial_id_swap: Boolean(bt && pt && bt === pt),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionableCompareSlotDiffs(diffs) {
|
||||||
|
return (diffs || []).filter((d) => !d.trivial_id_swap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fill = leerer Slot + Bibliotheks-Treffer; replace = bestehende Übung tauschen; gap_only = nur KI-Angebot. */
|
||||||
|
export function compareDiffKind(diff) {
|
||||||
|
if (!diff || diff.trivial_id_swap) return 'skip'
|
||||||
|
const hasBase = diff.baseline_exercise_id != null
|
||||||
|
const hasProp = diff.proposed_exercise_id != null
|
||||||
|
if (!hasBase && hasProp) return 'fill'
|
||||||
|
if (hasBase && hasProp) return 'replace'
|
||||||
|
if (!hasBase && !hasProp) return 'gap_only'
|
||||||
|
if (hasBase && !hasProp) return 'replace'
|
||||||
|
return 'skip'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qualityDeltaPercent(diff) {
|
||||||
|
const delta = diff?.quality_delta
|
||||||
|
if (delta == null || !Number.isFinite(Number(delta))) return null
|
||||||
|
return Math.round(Number(delta) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function annotateCompareDiffKinds(diffs) {
|
||||||
|
return (diffs || []).map((d) => ({
|
||||||
|
...d,
|
||||||
|
diff_kind: compareDiffKind(d),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slotFitScorePercent(score) {
|
||||||
|
if (score == null || !Number.isFinite(Number(score))) return null
|
||||||
|
return Math.round(Number(score) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slotReviewSelectionKey(midx, kind = 'library') {
|
||||||
|
return `${Number(midx)}:${kind}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSlotReviewSelection(raw) {
|
||||||
|
if (raw == null) return null
|
||||||
|
const text = String(raw)
|
||||||
|
if (text.includes(':')) {
|
||||||
|
const [midxRaw, kind] = text.split(':')
|
||||||
|
const midx = Number(midxRaw)
|
||||||
|
if (!Number.isFinite(midx)) return null
|
||||||
|
return { midx, kind: kind === 'ai' ? 'ai' : 'library' }
|
||||||
|
}
|
||||||
|
const midx = Number(text)
|
||||||
|
if (!Number.isFinite(midx)) return null
|
||||||
|
return { midx, kind: 'library' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Slot-Reviews aus Match-Antwort (je Slot eine Zeile). */
|
||||||
|
export function compareSlotReviews(comparison) {
|
||||||
|
return Array.isArray(comparison?.slot_reviews) ? comparison.slot_reviews : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */
|
||||||
|
export function compareDiffsForDialog(comparison) {
|
||||||
|
const reviews = compareSlotReviews(comparison)
|
||||||
|
if (reviews.length > 0) return reviews
|
||||||
|
|
||||||
|
const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path)
|
||||||
|
if (fromSuggestions.length > 0) {
|
||||||
|
return fromSuggestions
|
||||||
|
.map((s) => ({ ...s, diff_kind: suggestionDiffKind(s) }))
|
||||||
|
.filter(
|
||||||
|
(d) =>
|
||||||
|
d.proposed_exercise_id != null
|
||||||
|
|| (d.suggestion_type === 'ai_gap' && d.gap_offer),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (Array.isArray(comparison?.slot_diffs_improving)) {
|
||||||
|
return comparison.slot_diffs_improving.filter(
|
||||||
|
(d) => d?.proposed_exercise_id != null && !d?.trivial_id_swap,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const diffs = annotateCompareDiffKinds(
|
||||||
|
compareSlotDiffs(comparison, { actionableOnly: true }),
|
||||||
|
)
|
||||||
|
return diffs.filter(
|
||||||
|
(d) =>
|
||||||
|
(d.diff_kind === 'fill' || d.diff_kind === 'replace')
|
||||||
|
&& d.proposed_exercise_id != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSelectedCompareDiffs(comparison) {
|
||||||
|
const reviews = compareSlotReviews(comparison)
|
||||||
|
if (reviews.length > 0) {
|
||||||
|
return reviews
|
||||||
|
.filter((review) => review?.library_alternative?.auto_select)
|
||||||
|
.map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library'))
|
||||||
|
}
|
||||||
|
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestionDiffKind(suggestion) {
|
||||||
|
if (!suggestion) return 'skip'
|
||||||
|
if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap'
|
||||||
|
if (suggestion.baseline_exercise_id == null && suggestion.proposed_exercise_id != null) {
|
||||||
|
return 'fill'
|
||||||
|
}
|
||||||
|
if (suggestion.baseline_exercise_id != null && suggestion.proposed_exercise_id != null) {
|
||||||
|
return 'replace'
|
||||||
|
}
|
||||||
|
return 'skip'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recommendedCompareDiffs(comparison) {
|
||||||
|
return compareDiffsForDialog(comparison)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalReplaceCompareDiffs(comparison) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectedCompareDiffs(comparison) {
|
||||||
|
return Array.isArray(comparison?.slot_diffs_rejected)
|
||||||
|
? comparison.slot_diffs_rejected
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gapOnlyCompareDiffs(comparison) {
|
||||||
|
return annotateCompareDiffKinds(
|
||||||
|
compareSlotDiffs(comparison, { actionableOnly: true }),
|
||||||
|
).filter((d) => d.diff_kind === 'gap_only')
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeGapFillOffersFromSteps(steps, offers) {
|
||||||
|
const merged = (offers || []).map((o) => ({ ...o }))
|
||||||
|
const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean))
|
||||||
|
for (const step of steps || []) {
|
||||||
|
const go = step?.gap_offer
|
||||||
|
if (!go || typeof go !== 'object') continue
|
||||||
|
if (go.offer_id && seen.has(go.offer_id)) continue
|
||||||
|
if (go.offer_id) seen.add(go.offer_id)
|
||||||
|
merged.push({ ...go })
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergleich aus unified_slot_review oder kaskadierten Antworten (Evaluate → Match).
|
||||||
|
*/
|
||||||
|
export function buildProgressionComparePayload(baselineRes, proposedRes) {
|
||||||
|
if (proposedRes?.unified_slot_review) {
|
||||||
|
return buildUnifiedSlotReviewComparePayload(proposedRes, baselineRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : []
|
||||||
|
const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : []
|
||||||
|
const baselineQa = baselineRes?.path_qa || null
|
||||||
|
const pipelineQa = proposedRes?.path_qa || null
|
||||||
|
const scoring = proposedRes?.slot_diff_scoring
|
||||||
|
const rawDiffs = annotateCompareDiffKinds(
|
||||||
|
annotateCompareSlotDiffs(
|
||||||
|
buildProgressionSlotDiffs(baselineSteps, proposedSteps),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const improvingDiffs = annotateCompareDiffKinds(
|
||||||
|
(scoring?.improvement_diffs || []).filter((d) => d?.proposed_exercise_id != null),
|
||||||
|
)
|
||||||
|
const rejectedDiffs = annotateCompareDiffKinds(scoring?.rejected_diffs || [])
|
||||||
|
const dialogDiffs = improvingDiffs.length > 0
|
||||||
|
? improvingDiffs
|
||||||
|
: rawDiffs.filter(
|
||||||
|
(d) =>
|
||||||
|
!d.trivial_id_swap
|
||||||
|
&& (d.diff_kind === 'fill' || d.diff_kind === 'replace')
|
||||||
|
&& d.proposed_exercise_id != null
|
||||||
|
&& d.improves_path !== false,
|
||||||
|
)
|
||||||
|
const actionableDiffs = dialogDiffs
|
||||||
|
const gapFillOffers = mergeGapFillOffersFromSteps(
|
||||||
|
proposedSteps,
|
||||||
|
proposedRes?.gap_fill_offers || [],
|
||||||
|
)
|
||||||
|
const baselineScore = scoring?.baseline_quality_score ?? baselineQa?.quality_score
|
||||||
|
const proposedQa = baselineQa
|
||||||
|
|
||||||
|
return {
|
||||||
|
...proposedRes,
|
||||||
|
comparison_mode: true,
|
||||||
|
baseline_steps: baselineSteps,
|
||||||
|
baseline_path_qa: baselineQa,
|
||||||
|
proposed_steps: proposedSteps,
|
||||||
|
proposed_steps_pipeline: proposedSteps,
|
||||||
|
proposed_path_qa: proposedQa,
|
||||||
|
proposed_path_qa_pipeline: pipelineQa,
|
||||||
|
gap_fill_offers: gapFillOffers,
|
||||||
|
slot_diffs: rawDiffs,
|
||||||
|
slot_diffs_actionable: actionableDiffs,
|
||||||
|
slot_diffs_improving: improvingDiffs,
|
||||||
|
slot_diffs_rejected: rejectedDiffs,
|
||||||
|
slot_diffs_dialog: dialogDiffs,
|
||||||
|
slot_diffs_recommended: dialogDiffs,
|
||||||
|
slot_diff_count: dialogDiffs.length,
|
||||||
|
slot_diff_count_recommended: dialogDiffs.length,
|
||||||
|
slot_diff_count_rejected: rejectedDiffs.length,
|
||||||
|
slot_diff_count_including_trivial: rawDiffs.length,
|
||||||
|
slot_diffs_source: scoring ? 'incremental_scoring' : 'steps',
|
||||||
|
slot_diff_scoring: scoring,
|
||||||
|
baseline_quality_score: baselineScore,
|
||||||
|
path_qa: proposedQa,
|
||||||
|
steps: proposedSteps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einheitlicher Match-Review (Bewertung + Slot-Vorschläge in einem Lauf). */
|
||||||
|
export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) {
|
||||||
|
const baselineSteps = Array.isArray(baselineRes?.steps)
|
||||||
|
? baselineRes.steps
|
||||||
|
: (Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || []))
|
||||||
|
const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null
|
||||||
|
const scoring = res?.slot_diff_scoring
|
||||||
|
const slotReviews = compareSlotReviews(res)
|
||||||
|
const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : []
|
||||||
|
const improving = suggestions.filter((s) => s?.improves_path || s?.auto_select)
|
||||||
|
const rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : []
|
||||||
|
const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean)
|
||||||
|
const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []
|
||||||
|
const autoSelectCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
comparison_mode: true,
|
||||||
|
unified_slot_review: true,
|
||||||
|
baseline_steps: baselineSteps,
|
||||||
|
baseline_path_qa: baselineQa,
|
||||||
|
proposed_steps: proposedSteps,
|
||||||
|
proposed_steps_pipeline: proposedSteps,
|
||||||
|
proposed_path_qa: baselineQa,
|
||||||
|
proposed_path_qa_pipeline: null,
|
||||||
|
gap_fill_offers: gapFillOffers,
|
||||||
|
slot_reviews: slotReviews,
|
||||||
|
slot_suggestions: suggestions,
|
||||||
|
slot_diffs: improving,
|
||||||
|
slot_diffs_improving: improving,
|
||||||
|
slot_diffs_rejected: rejected,
|
||||||
|
slot_diffs_dialog: slotReviews.length > 0 ? slotReviews : improving,
|
||||||
|
slot_diffs_recommended: improving,
|
||||||
|
slot_diff_count: autoSelectCount || improving.length,
|
||||||
|
slot_diff_count_recommended: autoSelectCount || improving.length,
|
||||||
|
slot_diff_count_rejected: rejected.length,
|
||||||
|
slot_diffs_source: 'unified_slot_review',
|
||||||
|
slot_diff_scoring: scoring,
|
||||||
|
baseline_quality_score: scoring?.baseline_quality_score ?? baselineQa?.quality_score,
|
||||||
|
path_qa: baselineQa,
|
||||||
|
steps: baselineSteps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestionToApplyStep(suggestion) {
|
||||||
|
if (!suggestion || suggestion.roadmap_major_step_index == null) return null
|
||||||
|
const midx = Number(suggestion.roadmap_major_step_index)
|
||||||
|
if (suggestion.suggestion_type === 'ai_gap' && suggestion.gap_offer) {
|
||||||
|
const offer = suggestion.gap_offer
|
||||||
|
return {
|
||||||
|
roadmap_major_step_index: midx,
|
||||||
|
exercise_id: null,
|
||||||
|
title: offer.title_hint || suggestion.proposed_title || `Slot ${midx + 1}`,
|
||||||
|
is_ai_proposal: true,
|
||||||
|
proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`,
|
||||||
|
gap_offer: offer,
|
||||||
|
slot_status: 'ai_proposal',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (suggestion.proposed_exercise_id == null) return null
|
||||||
|
return {
|
||||||
|
roadmap_major_step_index: midx,
|
||||||
|
exercise_id: suggestion.proposed_exercise_id,
|
||||||
|
title: suggestion.proposed_title,
|
||||||
|
slot_status: suggestion.proposed_slot_status || 'matched',
|
||||||
|
is_ai_proposal: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */
|
||||||
|
export function applySelectedSlotSuggestions(draft, comparison, selectedKeys) {
|
||||||
|
const reviews = compareSlotReviews(comparison)
|
||||||
|
if (reviews.length > 0) {
|
||||||
|
const selected = new Set((selectedKeys || []).map((x) => String(x)))
|
||||||
|
const steps = []
|
||||||
|
for (const review of reviews) {
|
||||||
|
const midx = Number(review.roadmap_major_step_index)
|
||||||
|
const libKey = slotReviewSelectionKey(midx, 'library')
|
||||||
|
const aiKey = slotReviewSelectionKey(midx, 'ai')
|
||||||
|
if (selected.has(aiKey) && review.ai_alternative?.gap_offer) {
|
||||||
|
const offer = review.ai_alternative.gap_offer
|
||||||
|
steps.push({
|
||||||
|
roadmap_major_step_index: midx,
|
||||||
|
exercise_id: null,
|
||||||
|
title: offer.title_hint || review.ai_alternative.title_hint || `Slot ${midx + 1}`,
|
||||||
|
is_ai_proposal: true,
|
||||||
|
proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`,
|
||||||
|
gap_offer: offer,
|
||||||
|
slot_status: 'ai_proposal',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (selected.has(libKey) && review.library_alternative?.exercise_id != null) {
|
||||||
|
steps.push({
|
||||||
|
roadmap_major_step_index: midx,
|
||||||
|
exercise_id: review.library_alternative.exercise_id,
|
||||||
|
title: review.library_alternative.title,
|
||||||
|
slot_status: 'matched',
|
||||||
|
is_ai_proposal: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (steps.length) return applyMatchStepsToSlots(draft, steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = new Set(
|
||||||
|
(selectedKeys || [])
|
||||||
|
.map((x) => parseSlotReviewSelection(x)?.midx ?? Number(x))
|
||||||
|
.filter((x) => Number.isFinite(x)),
|
||||||
|
)
|
||||||
|
if (!selected.size) return draft
|
||||||
|
const legacySteps = (comparison?.slot_suggestions || [])
|
||||||
|
.filter((s) => selected.has(Number(s.roadmap_major_step_index)))
|
||||||
|
.map(suggestionToApplyStep)
|
||||||
|
.filter(Boolean)
|
||||||
|
if (!legacySteps.length) {
|
||||||
|
return applySelectedCompareSteps(
|
||||||
|
draft,
|
||||||
|
comparison?.proposed_steps || comparison?.steps,
|
||||||
|
selectedKeys,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return applyMatchStepsToSlots(draft, legacySteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */
|
||||||
|
export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) {
|
||||||
|
if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) {
|
||||||
|
return comparison.slot_diffs_actionable
|
||||||
|
}
|
||||||
|
return Array.isArray(comparison?.slot_diffs) ? comparison.slot_diffs : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inhaltliche Abweichungen (nicht nur gleicher Titel, andere ID). */
|
||||||
|
export function compareResponseHasActionableSlotChanges(res) {
|
||||||
|
const count = res?.slot_diff_count
|
||||||
|
if (count != null) return Number(count) > 0
|
||||||
|
return compareSlotDiffs(res, { actionableOnly: true }).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diff-Einträge nur für Slots, die vorher schon eine Bibliotheks-Übung hatten. */
|
||||||
|
export function curatedSlotDiffs(comparison, { actionableOnly = true } = {}) {
|
||||||
|
return compareSlotDiffs(comparison, { actionableOnly }).filter(
|
||||||
|
(d) => d?.baseline_exercise_id != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vergleich würde eine bestehende Zuordnung inhaltlich ändern (Dialog bei Match). */
|
||||||
|
export function compareResponseHasCuratedSlotChanges(res) {
|
||||||
|
return curatedSlotDiffs(res, { actionableOnly: true }).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareResponseHadRematchWithoutActionableDiffs(res) {
|
||||||
|
if (compareResponseHasActionableSlotChanges(res)) return false
|
||||||
|
const rematch = res?.proposed_path_qa_pipeline?.rematch_log
|
||||||
|
return Array.isArray(rematch) && rematch.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
/** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */
|
/** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */
|
||||||
export function draftRetrievalBoostExerciseIds(draft) {
|
export function draftRetrievalBoostExerciseIds(draft) {
|
||||||
const ids = new Set()
|
const ids = new Set()
|
||||||
|
|
@ -986,15 +1468,59 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
|
||||||
if (!step) {
|
if (!step) {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
const mappedPrimary = mapStepToPrimary(step, slot)
|
||||||
|
const apiUnfilled =
|
||||||
|
step.exercise_id == null &&
|
||||||
|
(step.slot_status === 'unfilled' ||
|
||||||
|
step.roadmap_match_source === 'unfilled' ||
|
||||||
|
mappedPrimary.kind === 'empty')
|
||||||
|
if (
|
||||||
|
apiUnfilled &&
|
||||||
|
slot.primary?.kind === 'library' &&
|
||||||
|
slot.primary.exerciseId != null
|
||||||
|
) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
primary: mapStepToPrimary(step, slot),
|
primary: mappedPrimary,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
|
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vergleichs-Antwort: mindestens ein inhaltlicher Slot-Unterschied. */
|
||||||
|
export function compareResponseHasSlotChanges(res) {
|
||||||
|
return compareResponseHasActionableSlotChanges(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur ausgewählte Slots aus Optimierungs-Vorschlag übernehmen. */
|
||||||
|
export function applySelectedCompareSteps(draft, proposedSteps, selectedMajorIndices) {
|
||||||
|
const selected = new Set(
|
||||||
|
(selectedMajorIndices || [])
|
||||||
|
.map((x) => Number(x))
|
||||||
|
.filter((x) => Number.isFinite(x)),
|
||||||
|
)
|
||||||
|
if (!selected.size) return draft
|
||||||
|
const stepByMajor = new Map()
|
||||||
|
for (const step of proposedSteps || []) {
|
||||||
|
if (step?.roadmap_major_step_index == null) continue
|
||||||
|
stepByMajor.set(Number(step.roadmap_major_step_index), step)
|
||||||
|
}
|
||||||
|
const nextSlots = (draft.slots || []).map((slot) => {
|
||||||
|
const midx = Number(slot.majorStepIndex)
|
||||||
|
if (!selected.has(midx)) {
|
||||||
|
return { ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] }
|
||||||
|
}
|
||||||
|
const step = stepByMajor.get(midx)
|
||||||
|
if (!step) return slot
|
||||||
|
const patched = applyMatchStepsToSlots({ ...draft, slots: [slot] }, [step])
|
||||||
|
return patched.slots[0]
|
||||||
|
})
|
||||||
|
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
|
||||||
|
}
|
||||||
|
|
||||||
/** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */
|
/** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */
|
||||||
export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) {
|
export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) {
|
||||||
let next = draft
|
let next = draft
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user