Implement EvaluateStepPayload and SlotContentEntry for Enhanced Planning Features
Some checks failed
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Failing after 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m21s
Some checks failed
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Failing after 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m21s
- Introduced EvaluateStepPayload class to facilitate evaluation of exercise steps with optional attributes for AI proposals and roadmap details. - Added SlotContentEntry and SlotExerciseContent classes to manage exercise content within the progression graph planning artifact. - Updated GraphPlanningRoadmapArtifact to include new slot contents and last findings attributes for improved data handling. - Enhanced Exercise Progression Graph Panel with links to the new Slot Editor for streamlined editing of progression graphs. - Incremented application version to reflect these updates.
This commit is contained in:
parent
8d5f0b533c
commit
97efe66306
79
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
79
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Progressionsgraph — Slot-Editor (Phase B)
|
||||
|
||||
**Stand:** 2026-06-10 · **Status:** In Umsetzung
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Progressionsgraph = **ein linearer Hauptpfad** (Roadmap = strukturgebend). Jeder **Major Step** ist ein **Slot** mit:
|
||||
|
||||
- **primary** — Hauptübung des Slots (Pfadknoten)
|
||||
- **siblings** — 0..n Schwestern (gleiche Stufe, `edge_type: sibling`)
|
||||
|
||||
KI-Entwürfe und Bibliotheksübungen leben **im selben Slot-Modell**, ohne sofortige Übungsanlage.
|
||||
|
||||
## Slot-Zustände (`kind`)
|
||||
|
||||
| kind | Bedeutung |
|
||||
|------|-----------|
|
||||
| `empty` | Noch keine Übung |
|
||||
| `library` | `exercise_id` (+ optional `variant_id`) |
|
||||
| `proposal` | KI-Entwurf (`ai_suggestion`, kein `exercise_id`) |
|
||||
|
||||
## Kanten
|
||||
|
||||
- `primary(n) → primary(n+1)` — `next_exercise` (nur befüllte Primärkette, lückenlos verbunden)
|
||||
- `primary ↔ sibling` — `sibling` (pro Slot)
|
||||
|
||||
Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgenden befüllten Primär-Slots.
|
||||
|
||||
## Editor-Zustand (`ProgressionGraphDraft`)
|
||||
|
||||
```ts
|
||||
{
|
||||
goalQuery, startSituation, targetState, roadmapNotes, maxSteps,
|
||||
majorSteps: MajorStep[],
|
||||
slots: Slot[], // index = major_step_index
|
||||
pathSkillExpectations?,
|
||||
lastFindings?, // path_qa-Snapshot
|
||||
dirty: boolean,
|
||||
}
|
||||
```
|
||||
|
||||
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
|
||||
|
||||
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`.
|
||||
|
||||
## Findings-Panel
|
||||
|
||||
Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …).
|
||||
|
||||
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
|
||||
|
||||
Persistenz: `planning_roadmap.last_findings`.
|
||||
|
||||
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
|
||||
|
||||
Zusätzlich optional:
|
||||
|
||||
- `slot_contents[]` — `{ major_step_index, primary, siblings[] }`
|
||||
- `last_findings` — letzter `path_qa`-Snapshot
|
||||
|
||||
## UI & Routing
|
||||
|
||||
- **B.4:** Route `/progression-graphs/:id` — Slots links, Findings rechts
|
||||
- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
|
||||
|
||||
## Ersetzt schrittweise
|
||||
|
||||
- Getrennte `ExerciseProgressionPathBuilder`-Wizard-UI + `ProgressionChainEditor` → integrierter `ProgressionGraphEditor`
|
||||
|
||||
## Implementierungsreihenfolge
|
||||
|
||||
| ID | Inhalt |
|
||||
|----|--------|
|
||||
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten |
|
||||
| B.1 | Slot-Karten, Bibliothek + Entwurf |
|
||||
| B.2 | Findings-Panel + `evaluate_only` |
|
||||
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ |
|
||||
| B.4 | Route + Panel vereinfachen |
|
||||
| B.5 | `last_findings` + Phase-C-Vorbereitung |
|
||||
|
|
@ -74,6 +74,18 @@ from planning_progression_roadmap import (
|
|||
from routers.training_planning import _has_planning_role
|
||||
|
||||
|
||||
class EvaluateStepPayload(BaseModel):
|
||||
exercise_id: Optional[int] = Field(default=None, ge=1)
|
||||
variant_id: Optional[int] = Field(default=None, ge=1)
|
||||
title: Optional[str] = Field(default=None, max_length=500)
|
||||
is_ai_proposal: bool = False
|
||||
ai_suggestion: Optional[Dict[str, Any]] = None
|
||||
proposal_key: Optional[str] = Field(default=None, max_length=120)
|
||||
roadmap_major_step_index: Optional[int] = Field(default=None, ge=0, le=20)
|
||||
roadmap_phase: Optional[str] = Field(default=None, max_length=80)
|
||||
roadmap_learning_goal: Optional[str] = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class ProgressionPathSuggestRequest(BaseModel):
|
||||
query: str = Field(..., min_length=3, max_length=2000)
|
||||
max_steps: int = Field(default=5, ge=2, le=10)
|
||||
|
|
@ -88,6 +100,8 @@ class ProgressionPathSuggestRequest(BaseModel):
|
|||
roadmap_first: bool = False
|
||||
roadmap_only: bool = False
|
||||
start_target_only: bool = False
|
||||
evaluate_only: bool = False
|
||||
evaluate_steps: Optional[List[EvaluateStepPayload]] = None
|
||||
roadmap_override: Optional[RoadmapOverridePayload] = None
|
||||
start_situation: Optional[str] = Field(default=None, max_length=2000)
|
||||
target_state: Optional[str] = Field(default=None, max_length=2000)
|
||||
|
|
@ -606,6 +620,162 @@ def _build_steps_roadmap_first(
|
|||
return steps, unfilled
|
||||
|
||||
|
||||
def _evaluate_steps_from_payload(
|
||||
cur,
|
||||
payloads: List[EvaluateStepPayload],
|
||||
) -> List[Dict[str, Any]]:
|
||||
steps: List[Dict[str, Any]] = []
|
||||
for raw in payloads:
|
||||
is_proposal = bool(raw.is_ai_proposal) or raw.exercise_id is None
|
||||
title = (raw.title or "").strip() or None
|
||||
if is_proposal:
|
||||
steps.append(
|
||||
{
|
||||
"exercise_id": None,
|
||||
"variant_id": None,
|
||||
"title": title or "KI-Vorschlag",
|
||||
"is_ai_proposal": True,
|
||||
"ai_suggestion": raw.ai_suggestion,
|
||||
"proposal_key": raw.proposal_key,
|
||||
"roadmap_major_step_index": raw.roadmap_major_step_index,
|
||||
"roadmap_phase": raw.roadmap_phase,
|
||||
"roadmap_learning_goal": raw.roadmap_learning_goal,
|
||||
"reasons": [],
|
||||
}
|
||||
)
|
||||
continue
|
||||
eid = int(raw.exercise_id)
|
||||
cur.execute(
|
||||
"SELECT id, title, summary FROM exercises WHERE id = %s",
|
||||
(eid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=400, detail=f"Übung {eid} nicht gefunden")
|
||||
steps.append(
|
||||
{
|
||||
"exercise_id": eid,
|
||||
"variant_id": raw.variant_id,
|
||||
"title": title or row.get("title"),
|
||||
"summary": row.get("summary"),
|
||||
"is_ai_proposal": False,
|
||||
"roadmap_major_step_index": raw.roadmap_major_step_index,
|
||||
"roadmap_phase": raw.roadmap_phase,
|
||||
"roadmap_learning_goal": raw.roadmap_learning_goal,
|
||||
"reasons": [],
|
||||
}
|
||||
)
|
||||
return steps
|
||||
|
||||
|
||||
def _run_evaluate_only_path_qa(
|
||||
cur,
|
||||
*,
|
||||
body: ProgressionPathSuggestRequest,
|
||||
goal_query: str,
|
||||
semantic_brief: PlanningSemanticBrief,
|
||||
steps: List[Dict[str, Any]],
|
||||
roadmap_ctx: Optional[ProgressionRoadmapContext],
|
||||
) -> Dict[str, Any]:
|
||||
roadmap_first = roadmap_ctx is not None
|
||||
gaps: List[Dict[str, Any]] = []
|
||||
bridge_inserts: List[Dict[str, Any]] = []
|
||||
unfilled_gaps: List[Dict[str, Any]] = []
|
||||
llm_qa: Optional[Dict[str, Any]] = None
|
||||
llm_qa_applied = False
|
||||
off_topic_steps: List[Dict[str, Any]] = []
|
||||
stripped_off_topic: List[Dict[str, Any]] = []
|
||||
ai_proposals: List[Dict[str, Any]] = []
|
||||
gap_fill_offers: List[Dict[str, Any]] = []
|
||||
roadmap_qa_mode: Optional[str] = None
|
||||
|
||||
if body.include_path_qa:
|
||||
if roadmap_first:
|
||||
roadmap_qa_mode = "roadmap_first_lite"
|
||||
gaps = detect_path_gaps(
|
||||
cur,
|
||||
steps,
|
||||
brief=semantic_brief,
|
||||
roadmap_first=roadmap_first,
|
||||
)
|
||||
if gaps and roadmap_first:
|
||||
unfilled_gaps = list(gaps)
|
||||
|
||||
if body.include_llm_path_qa:
|
||||
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
|
||||
cur,
|
||||
goal_query=goal_query,
|
||||
brief=semantic_brief,
|
||||
steps=steps,
|
||||
gaps=gaps,
|
||||
bridge_inserts=bridge_inserts,
|
||||
)
|
||||
|
||||
off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief)
|
||||
llm_gap_specs = parse_llm_suggested_new_exercises(
|
||||
llm_qa,
|
||||
brief=semantic_brief,
|
||||
step_count=len(steps),
|
||||
)
|
||||
|
||||
if body.include_ai_gap_fill:
|
||||
fresh_large_gaps = [g for g in gaps if g.get("is_large_gap")]
|
||||
gap_specs = collect_gap_fill_specs(
|
||||
steps=steps,
|
||||
unfilled_gaps=fresh_large_gaps or unfilled_gaps,
|
||||
off_topic_steps=off_topic_steps,
|
||||
llm_specs=llm_gap_specs,
|
||||
brief=semantic_brief,
|
||||
goal_query=goal_query,
|
||||
)
|
||||
path_roadmap_snapshot = None
|
||||
if roadmap_ctx:
|
||||
path_roadmap_snapshot = build_progression_gap_snapshot(
|
||||
goal_analysis=(
|
||||
roadmap_ctx.goal_analysis.model_dump()
|
||||
if roadmap_ctx.goal_analysis
|
||||
else None
|
||||
),
|
||||
resolved_structured=(
|
||||
roadmap_ctx.resolved_structured.model_dump()
|
||||
if roadmap_ctx.resolved_structured
|
||||
else None
|
||||
),
|
||||
semantic_brief=roadmap_ctx.semantic_brief
|
||||
or brief_to_summary_dict(semantic_brief),
|
||||
)
|
||||
_, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa(
|
||||
cur,
|
||||
steps,
|
||||
gap_specs,
|
||||
goal_query=goal_query,
|
||||
brief=semantic_brief,
|
||||
include_ai_calls=False,
|
||||
max_ai_proposals=0,
|
||||
auto_insert_proposals=False,
|
||||
roadmap_snapshot=path_roadmap_snapshot,
|
||||
)
|
||||
|
||||
path_qa = build_path_qa_summary(
|
||||
gaps=gaps,
|
||||
bridge_inserts=bridge_inserts,
|
||||
ai_proposals=ai_proposals,
|
||||
gap_fill_offers=gap_fill_offers,
|
||||
off_topic_steps=off_topic_steps,
|
||||
stripped_off_topic=stripped_off_topic,
|
||||
llm_qa=llm_qa,
|
||||
llm_applied=llm_qa_applied,
|
||||
reorder_applied=False,
|
||||
reorder_notes=[],
|
||||
roadmap_qa_mode=roadmap_qa_mode,
|
||||
)
|
||||
return {
|
||||
"path_qa": path_qa,
|
||||
"gap_fill_offers": gap_fill_offers,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
|
||||
def suggest_progression_path(
|
||||
cur,
|
||||
*,
|
||||
|
|
@ -631,6 +801,7 @@ def suggest_progression_path(
|
|||
roadmap_first = bool(body.roadmap_first)
|
||||
roadmap_only = bool(body.roadmap_only)
|
||||
start_target_only = bool(body.start_target_only)
|
||||
evaluate_only = bool(body.evaluate_only)
|
||||
include_roadmap = (
|
||||
roadmap_first or body.include_roadmap_preview or roadmap_only or start_target_only
|
||||
)
|
||||
|
|
@ -719,6 +890,42 @@ def suggest_progression_path(
|
|||
"retrieval_phase": "roadmap_only",
|
||||
}
|
||||
|
||||
if evaluate_only:
|
||||
if not body.evaluate_steps:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="evaluate_only erfordert evaluate_steps",
|
||||
)
|
||||
eval_steps = _evaluate_steps_from_payload(cur, body.evaluate_steps)
|
||||
qa_pack = _run_evaluate_only_path_qa(
|
||||
cur,
|
||||
body=body,
|
||||
goal_query=goal_query,
|
||||
semantic_brief=semantic_brief,
|
||||
steps=eval_steps,
|
||||
roadmap_ctx=roadmap_ctx,
|
||||
)
|
||||
return {
|
||||
"goal_query": goal_query,
|
||||
"max_steps_requested": max_steps,
|
||||
"steps": qa_pack["steps"],
|
||||
"step_count": len(qa_pack["steps"]),
|
||||
"target_profile_summary": None,
|
||||
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
|
||||
"semantic_llm_applied": semantic_llm_applied,
|
||||
"query_intent_summary": {},
|
||||
"progression_graph_id": body.progression_graph_id,
|
||||
"path_qa": qa_pack["path_qa"],
|
||||
"gap_fill_offers": qa_pack["gap_fill_offers"],
|
||||
"progression_roadmap": progression_roadmap,
|
||||
"roadmap_first": bool(roadmap_ctx),
|
||||
"roadmap_only": False,
|
||||
"roadmap_edited": roadmap_edited,
|
||||
"roadmap_unfilled_count": 0,
|
||||
"path_skill_expectations": None,
|
||||
"retrieval_phase": "evaluate_only",
|
||||
}
|
||||
|
||||
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
|
||||
cur,
|
||||
goal_query=goal_query,
|
||||
|
|
@ -1030,6 +1237,7 @@ def suggest_progression_path(
|
|||
|
||||
|
||||
__all__ = [
|
||||
"EvaluateStepPayload",
|
||||
"ProgressionPathSuggestRequest",
|
||||
"suggest_progression_path",
|
||||
"_pick_best_path_hit",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,22 @@ ARTIFACT_SCHEMA_VERSION = 1
|
|||
_MAX_JSON_BYTES = 64_000
|
||||
|
||||
|
||||
class SlotExerciseContent(BaseModel):
|
||||
kind: str = Field(default="empty", pattern=r"^(empty|library|proposal)$")
|
||||
exercise_id: Optional[int] = Field(default=None, ge=1)
|
||||
variant_id: Optional[int] = Field(default=None, ge=1)
|
||||
title: Optional[str] = Field(default=None, max_length=500)
|
||||
variant_name: Optional[str] = Field(default=None, max_length=200)
|
||||
proposal_key: Optional[str] = Field(default=None, max_length=120)
|
||||
ai_suggestion: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class SlotContentEntry(BaseModel):
|
||||
major_step_index: int = Field(ge=0, le=20)
|
||||
primary: SlotExerciseContent = Field(default_factory=SlotExerciseContent)
|
||||
siblings: List[SlotExerciseContent] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GraphPlanningRoadmapArtifact(BaseModel):
|
||||
schema_version: int = Field(default=ARTIFACT_SCHEMA_VERSION, ge=1, le=1)
|
||||
goal_query: str = Field(default="", max_length=2000)
|
||||
|
|
@ -19,14 +35,23 @@ class GraphPlanningRoadmapArtifact(BaseModel):
|
|||
max_steps: int = Field(default=5, ge=2, le=10)
|
||||
progression_roadmap: Optional[Dict[str, Any]] = None
|
||||
path_skill_expectations: Optional[Dict[str, Any]] = None
|
||||
slot_contents: Optional[List[SlotContentEntry]] = None
|
||||
last_findings: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_validator("progression_roadmap", "path_skill_expectations", mode="before")
|
||||
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", mode="before")
|
||||
@classmethod
|
||||
def _empty_dict_to_none(cls, v):
|
||||
if v == {}:
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator("slot_contents", mode="before")
|
||||
@classmethod
|
||||
def _empty_slot_list_to_none(cls, v):
|
||||
if v == []:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def normalize_planning_roadmap_payload(raw: Any) -> Optional[Dict[str, Any]]:
|
||||
"""None erlaubt (löschen); sonst validiertes Dict."""
|
||||
|
|
@ -45,5 +70,7 @@ def normalize_planning_roadmap_payload(raw: Any) -> Optional[Dict[str, Any]]:
|
|||
__all__ = [
|
||||
"ARTIFACT_SCHEMA_VERSION",
|
||||
"GraphPlanningRoadmapArtifact",
|
||||
"SlotContentEntry",
|
||||
"SlotExerciseContent",
|
||||
"normalize_planning_roadmap_payload",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const SettingsSystemInfoPage = lazy(() => import('./pages/SettingsSystemInfoPage
|
|||
const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
|
||||
const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
|
||||
const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
|
||||
const ProgressionGraphEditPage = lazy(() => import('./pages/ProgressionGraphEditPage'))
|
||||
const ClubsPage = lazy(() => import('./pages/ClubsPage'))
|
||||
const InboxPage = lazy(() => import('./pages/InboxPage'))
|
||||
const SkillsPage = lazy(() => import('./pages/SkillsPage'))
|
||||
|
|
@ -244,6 +245,7 @@ const appRouter = createBrowserRouter([
|
|||
{ path: 'settings/system', element: <SettingsSystemInfoPage /> },
|
||||
{ path: 'settings/legal', element: <SettingsLegalPage /> },
|
||||
{ path: 'media', element: <MediaLibraryPage /> },
|
||||
{ path: 'progression-graphs/:id', element: <ProgressionGraphEditPage /> },
|
||||
{
|
||||
path: 'exercises',
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -485,9 +485,9 @@ export default function ExerciseProgressionGraphPanel({
|
|||
)}
|
||||
|
||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
|
||||
Ein Graph enthält eine oder mehrere <strong>Reihen</strong> (lineare Pfade Übung → Übung) sowie optional{' '}
|
||||
<strong>Schwester-Alternativen</strong>. Reihen bearbeiten Sie direkt in der Liste; mit dem KI-Planer legen
|
||||
Sie neue Pfade in vier Schritten an.
|
||||
Ein Graph = ein linearer <strong>Primärpfad</strong> (Roadmap-Slots) plus optionale{' '}
|
||||
<strong>Schwestern</strong>. Für die integrierte Bearbeitung (Slots, KI-Entwürfe, Graph-Bewertung){' '}
|
||||
<strong>Slot-Editor öffnen</strong> — unten weiterhin Kurzansicht und KI-Wizard.
|
||||
</p>
|
||||
|
||||
{loadErr && (
|
||||
|
|
@ -525,6 +525,15 @@ export default function ExerciseProgressionGraphPanel({
|
|||
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
|
||||
Graph löschen
|
||||
</button>
|
||||
{selectedGraphId ? (
|
||||
<Link
|
||||
to={`/progression-graphs/${selectedGraphId}`}
|
||||
className="btn btn-primary"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Slot-Editor öffnen
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateGraph} style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}>
|
||||
|
|
|
|||
141
frontend/src/components/ProgressionFindingsPanel.jsx
Normal file
141
frontend/src/components/ProgressionFindingsPanel.jsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2).
|
||||
*/
|
||||
import React from 'react'
|
||||
|
||||
function severityStyle(pathQa) {
|
||||
if (!pathQa) return {}
|
||||
return {
|
||||
background: pathQa.overall_ok
|
||||
? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))'
|
||||
: 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProgressionFindingsPanel({
|
||||
pathQa = null,
|
||||
gapFillOffers = [],
|
||||
loading = false,
|
||||
error = '',
|
||||
onEvaluate,
|
||||
onApplyGapOffer,
|
||||
evaluateDisabled = false,
|
||||
}) {
|
||||
return (
|
||||
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||
Prüft den aktuellen Slot-Stand (inkl. KI-Entwürfe) ohne erneutes Übungs-Matching.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-full"
|
||||
disabled={loading || evaluateDisabled}
|
||||
onClick={onEvaluate}
|
||||
style={{ marginBottom: '12px' }}
|
||||
>
|
||||
{loading ? 'Bewertung läuft…' : 'Graph bewerten'}
|
||||
</button>
|
||||
|
||||
{error ? (
|
||||
<p className="form-error" style={{ marginTop: 0 }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{pathQa ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.45,
|
||||
...severityStyle(pathQa),
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||
{pathQa.quality_score != null
|
||||
? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)`
|
||||
: ''}
|
||||
</strong>
|
||||
{pathQa.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
||||
) : null}
|
||||
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||
{pathQa.issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{Array.isArray(pathQa.recommendations) && pathQa.recommendations.length > 0 ? (
|
||||
<>
|
||||
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>Empfehlungen</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||
{pathQa.recommendations.map((rec) => (
|
||||
<li key={rec}>{rec}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
{Number(pathQa.off_topic_count) > 0 ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
|
||||
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
|
||||
</p>
|
||||
) : null}
|
||||
{pathQa.roadmap_qa_mode === 'roadmap_first_lite' ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
|
||||
QS an Roadmap gekoppelt (keine Brücken zwischen Major Steps).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
||||
Noch keine Bewertung. Slots befüllen und „Graph bewerten“ ausführen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{Array.isArray(gapFillOffers) && gapFillOffers.length > 0 ? (
|
||||
<div style={{ marginTop: '14px' }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>Lücken-Angebote</h4>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{gapFillOffers.slice(0, 6).map((offer) => (
|
||||
<li
|
||||
key={offer.offer_id || offer.title_hint}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{offer.title_hint || 'Übungsvorschlag'}
|
||||
{offer.roadmap_major_step_index != null
|
||||
? ` · Slot ${Number(offer.roadmap_major_step_index) + 1}`
|
||||
: ''}
|
||||
</div>
|
||||
{offer.rationale ? (
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text2)' }}>{offer.rationale}</p>
|
||||
) : null}
|
||||
{typeof onApplyGapOffer === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: '6px', fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => onApplyGapOffer(offer)}
|
||||
>
|
||||
Als Entwurf in Slot
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
523
frontend/src/components/ProgressionGraphEditor.jsx
Normal file
523
frontend/src/components/ProgressionGraphEditor.jsx
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
/**
|
||||
* Integrierter Slot-Editor für Progressionsgraphen (Phase B).
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExercisePickerModal from './ExercisePickerModal'
|
||||
import ProgressionSlotCard from './ProgressionSlotCard'
|
||||
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
||||
import {
|
||||
applyGapOfferToSlot,
|
||||
applyMatchStepsToSlots,
|
||||
buildPlanningArtifactFromDraft,
|
||||
hydrateProgressionGraphDraft,
|
||||
librarySlotExercise,
|
||||
majorStepsToOverridePayload,
|
||||
reindexMajorSteps,
|
||||
saveProgressionGraphDraft,
|
||||
slotsToEvaluateSteps,
|
||||
} from '../utils/progressionGraphDraft'
|
||||
|
||||
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
||||
|
||||
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
||||
const body = {}
|
||||
const start = (startSituation || '').trim()
|
||||
const target = (targetState || '').trim()
|
||||
const notes = (roadmapNotes || '').trim()
|
||||
if (start) body.start_situation = start
|
||||
if (target) body.target_state = target
|
||||
if (notes) body.roadmap_notes = notes
|
||||
return body
|
||||
}
|
||||
|
||||
export default function ProgressionGraphEditor({ graphId }) {
|
||||
const [graphMeta, setGraphMeta] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [actionErr, setActionErr] = useState('')
|
||||
const [pickContext, setPickContext] = useState(null)
|
||||
const [pathQa, setPathQa] = useState(null)
|
||||
const [gapFillOffers, setGapFillOffers] = useState([])
|
||||
const [evaluating, setEvaluating] = useState(false)
|
||||
const [matching, setMatching] = useState(false)
|
||||
const [roadmapLoading, setRoadmapLoading] = useState(false)
|
||||
|
||||
const loadGraph = useCallback(async () => {
|
||||
if (!graphId) return
|
||||
setBusy(true)
|
||||
setLoadErr('')
|
||||
try {
|
||||
const [graph, edges] = await Promise.all([
|
||||
api.getExerciseProgressionGraph(Number(graphId)),
|
||||
api.listExerciseProgressionEdges(Number(graphId)),
|
||||
])
|
||||
setGraphMeta(graph)
|
||||
setDraft(
|
||||
hydrateProgressionGraphDraft({
|
||||
artifact: graph?.planning_roadmap,
|
||||
edges: Array.isArray(edges) ? edges : [],
|
||||
graphName: graph?.name,
|
||||
}),
|
||||
)
|
||||
const findings = graph?.planning_roadmap?.last_findings
|
||||
if (findings) setPathQa(findings)
|
||||
} catch (e) {
|
||||
setLoadErr(e.message || 'Graph konnte nicht geladen werden')
|
||||
setDraft(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [graphId])
|
||||
|
||||
useEffect(() => {
|
||||
loadGraph()
|
||||
}, [loadGraph])
|
||||
|
||||
const patchDraft = useCallback((patchFn) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev
|
||||
const next = patchFn(prev)
|
||||
return { ...next, dirty: true }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePickExercise = async (exercise) => {
|
||||
if (!pickContext || !exercise?.id) return
|
||||
const { slotIndex, role } = pickContext
|
||||
const entry = librarySlotExercise({
|
||||
exerciseId: exercise.id,
|
||||
exerciseTitle: exercise.title || `Übung #${exercise.id}`,
|
||||
})
|
||||
patchDraft((d) => {
|
||||
const slots = d.slots.map((s, i) => {
|
||||
if (i !== slotIndex) return s
|
||||
if (role === 'primary') return { ...s, primary: entry }
|
||||
const siblings = [...(s.siblings || [])]
|
||||
if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry)
|
||||
return { ...s, siblings }
|
||||
})
|
||||
return { ...d, slots }
|
||||
})
|
||||
setPickContext(null)
|
||||
}
|
||||
|
||||
const handlePatchLearningGoal = (slotIndex, value) => {
|
||||
patchDraft((d) => {
|
||||
const slots = d.slots.map((s, i) => (i === slotIndex ? { ...s, learning_goal: value } : s))
|
||||
const majorSteps = reindexMajorSteps(
|
||||
(d.majorSteps || []).map((m, i) => (i === slotIndex ? { ...m, learning_goal: value } : m)),
|
||||
)
|
||||
return { ...d, slots, majorSteps }
|
||||
})
|
||||
}
|
||||
|
||||
const handleClearPrimary = (slotIndex) => {
|
||||
patchDraft((d) => {
|
||||
const slots = d.slots.map((s, i) =>
|
||||
i === slotIndex ? { ...s, primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null }, siblings: [] } : s,
|
||||
)
|
||||
return { ...d, slots }
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveSibling = (slotIndex, sibIdx) => {
|
||||
patchDraft((d) => {
|
||||
const slots = d.slots.map((s, i) => {
|
||||
if (i !== slotIndex) return s
|
||||
return { ...s, siblings: s.siblings.filter((_, j) => j !== sibIdx) }
|
||||
})
|
||||
return { ...d, slots }
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddSlot = () => {
|
||||
patchDraft((d) => {
|
||||
const idx = d.slots.length
|
||||
const phase = ROADMAP_PHASES[Math.min(idx, ROADMAP_PHASES.length - 1)]
|
||||
const slot = {
|
||||
majorStepIndex: idx,
|
||||
phase,
|
||||
learning_goal: '',
|
||||
consolidates: [],
|
||||
rationale: '',
|
||||
load_profile: [],
|
||||
success_criteria: [],
|
||||
anti_patterns: [],
|
||||
exercise_type: '',
|
||||
primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null },
|
||||
siblings: [],
|
||||
}
|
||||
const major = {
|
||||
index: idx,
|
||||
phase,
|
||||
learning_goal: '',
|
||||
consolidates: [],
|
||||
rationale: '',
|
||||
load_profile: [],
|
||||
success_criteria: [],
|
||||
anti_patterns: [],
|
||||
exercise_type: '',
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
slots: [...d.slots, slot],
|
||||
majorSteps: [...(d.majorSteps || []), major],
|
||||
maxSteps: Math.max(d.maxSteps, idx + 1),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const validMajorSteps = useMemo(() => {
|
||||
if (!draft?.slots) return []
|
||||
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
|
||||
}, [draft?.slots])
|
||||
|
||||
const runRoadmapGenerate = async () => {
|
||||
const q = (draft?.goalQuery || '').trim()
|
||||
if (q.length < 3) {
|
||||
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
||||
return
|
||||
}
|
||||
setRoadmapLoading(true)
|
||||
setActionErr('')
|
||||
try {
|
||||
const res = await api.suggestProgressionPath({
|
||||
query: q,
|
||||
max_steps: draft.maxSteps || 5,
|
||||
include_llm_intent: true,
|
||||
include_path_qa: false,
|
||||
include_llm_path_qa: false,
|
||||
include_path_reorder: false,
|
||||
include_ai_gap_fill: false,
|
||||
include_roadmap_preview: true,
|
||||
include_llm_roadmap: true,
|
||||
roadmap_only: true,
|
||||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
})
|
||||
const roadmap = res?.progression_roadmap
|
||||
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
|
||||
const majors = (roadmap?.roadmap?.major_steps || []).map((s, i) => ({
|
||||
index: i,
|
||||
phase: s.phase || ROADMAP_PHASES[Math.min(i, ROADMAP_PHASES.length - 1)],
|
||||
learning_goal: (s.learning_goal || '').trim(),
|
||||
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
|
||||
rationale: s.rationale || '',
|
||||
load_profile: [],
|
||||
success_criteria: [],
|
||||
anti_patterns: [],
|
||||
exercise_type: '',
|
||||
}))
|
||||
const hydrated = hydrateProgressionGraphDraft({
|
||||
artifact: {
|
||||
...buildPlanningArtifactFromDraft({ ...draft, progressionRoadmap: roadmap }),
|
||||
progression_roadmap: roadmap,
|
||||
},
|
||||
edges: [],
|
||||
graphName: draft.graphName,
|
||||
})
|
||||
setDraft({ ...hydrated, goalQuery: q, dirty: true })
|
||||
} catch (e) {
|
||||
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
||||
} finally {
|
||||
setRoadmapLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runMatch = 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
|
||||
}
|
||||
setMatching(true)
|
||||
setActionErr('')
|
||||
try {
|
||||
const override = majorStepsToOverridePayload(draft.slots.map((s) => ({
|
||||
index: s.majorStepIndex,
|
||||
phase: s.phase,
|
||||
learning_goal: s.learning_goal,
|
||||
consolidates: s.consolidates,
|
||||
rationale: s.rationale,
|
||||
load_profile: s.load_profile,
|
||||
success_criteria: s.success_criteria,
|
||||
anti_patterns: s.anti_patterns,
|
||||
exercise_type: s.exercise_type,
|
||||
})))
|
||||
const res = await api.suggestProgressionPath({
|
||||
query: q,
|
||||
max_steps: validMajorSteps.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,
|
||||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
})
|
||||
const next = applyMatchStepsToSlots(
|
||||
{
|
||||
...draft,
|
||||
progressionRoadmap: res?.progression_roadmap || draft.progressionRoadmap,
|
||||
pathSkillExpectations: res?.path_skill_expectations || draft.pathSkillExpectations,
|
||||
},
|
||||
res?.steps,
|
||||
)
|
||||
setDraft(next)
|
||||
setPathQa(res?.path_qa || null)
|
||||
setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
|
||||
} catch (e) {
|
||||
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
||||
} finally {
|
||||
setMatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runEvaluate = async () => {
|
||||
const q = (draft?.goalQuery || '').trim()
|
||||
if (q.length < 3) {
|
||||
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
||||
return
|
||||
}
|
||||
setEvaluating(true)
|
||||
setActionErr('')
|
||||
try {
|
||||
const override =
|
||||
validMajorSteps.length >= 2
|
||||
? majorStepsToOverridePayload(
|
||||
draft.slots.map((s) => ({
|
||||
index: s.majorStepIndex,
|
||||
phase: s.phase,
|
||||
learning_goal: s.learning_goal,
|
||||
consolidates: s.consolidates,
|
||||
rationale: s.rationale,
|
||||
load_profile: s.load_profile,
|
||||
success_criteria: s.success_criteria,
|
||||
anti_patterns: s.anti_patterns,
|
||||
exercise_type: s.exercise_type,
|
||||
})),
|
||||
)
|
||||
: undefined
|
||||
const res = await api.suggestProgressionPath({
|
||||
query: q,
|
||||
max_steps: draft.slots.length || draft.maxSteps || 5,
|
||||
include_path_qa: true,
|
||||
include_llm_path_qa: true,
|
||||
include_ai_gap_fill: true,
|
||||
include_path_reorder: false,
|
||||
include_llm_intent: false,
|
||||
evaluate_only: true,
|
||||
evaluate_steps: slotsToEvaluateSteps(draft),
|
||||
roadmap_override: override,
|
||||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
})
|
||||
setPathQa(res?.path_qa || null)
|
||||
setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
|
||||
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
|
||||
} catch (e) {
|
||||
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
||||
} finally {
|
||||
setEvaluating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draft || !graphId) return
|
||||
setBusy(true)
|
||||
setActionErr('')
|
||||
try {
|
||||
await saveProgressionGraphDraft(api, graphId, { ...draft, lastFindings: pathQa })
|
||||
await loadGraph()
|
||||
alert('Progressionsgraph gespeichert.')
|
||||
} catch (e) {
|
||||
setActionErr(e.message || 'Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyGapOffer = (offer) => {
|
||||
const idx =
|
||||
offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null
|
||||
if (idx == null || !Number.isFinite(idx)) {
|
||||
alert('Angebot ohne Slot-Zuordnung — bitte manuell zuweisen.')
|
||||
return
|
||||
}
|
||||
setDraft((prev) => applyGapOfferToSlot(prev, idx, offer))
|
||||
}
|
||||
|
||||
if (loadErr) {
|
||||
return (
|
||||
<div className="card">
|
||||
<p className="form-error">{loadErr}</p>
|
||||
<Link to="/exercises" className="btn btn-secondary">
|
||||
Zurück
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.15rem' }}>
|
||||
{graphMeta?.name || draft.graphName || `Graph #${graphId}`}
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
|
||||
Slot-Editor · Roadmap = Struktur · ein Primärpfad + Schwestern
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
|
||||
Zur Übersicht
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{actionErr ? (
|
||||
<p className="form-error" style={{ marginTop: 0 }}>
|
||||
{actionErr}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="progression-graph-editor-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) minmax(260px, 320px)',
|
||||
gap: '14px',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: '12px' }}>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Ziel-Anfrage</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={draft.goalQuery}
|
||||
disabled={busy}
|
||||
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))}
|
||||
placeholder="z. B. Vom Anfänger zum sauberen Gerade-Tritt"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div>
|
||||
<label className="form-label">Start-Situation</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={draft.startSituation}
|
||||
disabled={busy}
|
||||
onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Ziel-Zustand</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={draft.targetState}
|
||||
disabled={busy}
|
||||
onChange={(e) => patchDraft((d) => ({ ...d, targetState: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy || roadmapLoading}
|
||||
onClick={runRoadmapGenerate}
|
||||
>
|
||||
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy || matching}
|
||||
onClick={runMatch}
|
||||
>
|
||||
{matching ? 'Match…' : 'Übungen matchen'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}>
|
||||
{busy ? 'Speichern…' : 'Graph speichern'}
|
||||
</button>
|
||||
</div>
|
||||
{draft.dirty ? (
|
||||
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>
|
||||
Ungespeicherte Änderungen
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Slots ({draft.slots.length})</h3>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px' }} disabled={busy || draft.slots.length >= 10} onClick={handleAddSlot}>
|
||||
Slot hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{draft.slots.map((slot, idx) => (
|
||||
<ProgressionSlotCard
|
||||
key={`slot-${slot.majorStepIndex}-${idx}`}
|
||||
slot={slot}
|
||||
slotIndex={idx}
|
||||
disabled={busy}
|
||||
onPickPrimary={(i) => setPickContext({ slotIndex: i, role: 'primary' })}
|
||||
onPickSibling={(i) => setPickContext({ slotIndex: i, role: 'sibling' })}
|
||||
onClearPrimary={handleClearPrimary}
|
||||
onRemoveSibling={handleRemoveSibling}
|
||||
onPatchLearningGoal={handlePatchLearningGoal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ProgressionFindingsPanel
|
||||
pathQa={pathQa}
|
||||
gapFillOffers={gapFillOffers}
|
||||
loading={evaluating}
|
||||
error={evaluating ? '' : ''}
|
||||
onEvaluate={runEvaluate}
|
||||
onApplyGapOffer={handleApplyGapOffer}
|
||||
evaluateDisabled={busy || !draft.goalQuery?.trim()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pickContext ? (
|
||||
<ExercisePickerModal
|
||||
open
|
||||
onClose={() => setPickContext(null)}
|
||||
onSelectExercise={handlePickExercise}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<style>{`
|
||||
@media (max-width: 900px) {
|
||||
.progression-graph-editor-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
frontend/src/components/ProgressionSlotCard.jsx
Normal file
160
frontend/src/components/ProgressionSlotCard.jsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Einzelner Roadmap-Slot im Progressionsgraph-Editor.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function exerciseLabel(entry) {
|
||||
if (!entry || entry.kind === 'empty') return '— noch leer —'
|
||||
if (entry.kind === 'proposal') return entry.exerciseTitle || 'KI-Entwurf'
|
||||
return entry.exerciseTitle || `Übung #${entry.exerciseId}`
|
||||
}
|
||||
|
||||
export default function ProgressionSlotCard({
|
||||
slot,
|
||||
slotIndex,
|
||||
onPickPrimary,
|
||||
onPickSibling,
|
||||
onClearPrimary,
|
||||
onRemoveSibling,
|
||||
onPatchLearningGoal,
|
||||
disabled = false,
|
||||
}) {
|
||||
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
borderColor: primary.kind === 'empty'
|
||||
? 'var(--border)'
|
||||
: 'color-mix(in srgb, var(--accent) 25%, var(--border))',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>
|
||||
Slot {slotIndex + 1}
|
||||
{phase ? <span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${phase}`}</span> : null}
|
||||
</h4>
|
||||
</div>
|
||||
<span
|
||||
className="exercise-tag"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
borderColor: primary.kind === 'proposal' ? 'var(--danger)' : undefined,
|
||||
}}
|
||||
>
|
||||
{primary.kind === 'empty' ? 'leer' : primary.kind === 'proposal' ? 'KI-Entwurf' : 'Bibliothek'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginTop: '10px', marginBottom: '8px' }}>
|
||||
<label className="form-label">Lernziel (Major Step)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={learningGoal || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onPatchLearningGoal(slotIndex, e.target.value)}
|
||||
placeholder="Was soll in dieser Stufe erreicht werden?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--surface2)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text3)', marginBottom: '4px' }}>Hauptpfad (primary)</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600 }}>
|
||||
{primary.kind === 'library' && primary.exerciseId ? (
|
||||
<Link to={`/exercises/${primary.exerciseId}`}>{exerciseLabel(primary)}</Link>
|
||||
) : (
|
||||
exerciseLabel(primary)
|
||||
)}
|
||||
{primary.variantName ? (
|
||||
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${primary.variantName}`}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||
disabled={disabled}
|
||||
onClick={() => onPickPrimary(slotIndex)}
|
||||
>
|
||||
{primary.kind === 'empty' ? 'Übung wählen' : 'Übung ändern'}
|
||||
</button>
|
||||
{primary.kind !== 'empty' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||
disabled={disabled}
|
||||
onClick={() => onClearPrimary(slotIndex)}
|
||||
>
|
||||
Leeren
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text3)', marginBottom: '6px' }}>Schwestern (Alternativen)</div>
|
||||
{siblings.length === 0 ? (
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>Keine Schwestern.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{siblings.map((sib, sibIdx) => (
|
||||
<li
|
||||
key={`${sib.exerciseId || sib.proposalKey}-${sibIdx}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '6px 8px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{sib.kind === 'library' && sib.exerciseId ? (
|
||||
<Link to={`/exercises/${sib.exerciseId}`}>{exerciseLabel(sib)}</Link>
|
||||
) : (
|
||||
exerciseLabel(sib)
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '11px', padding: '2px 8px' }}
|
||||
disabled={disabled}
|
||||
onClick={() => onRemoveSibling(slotIndex, sibIdx)}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||
disabled={disabled || primary.kind !== 'library'}
|
||||
onClick={() => onPickSibling(slotIndex)}
|
||||
title={primary.kind !== 'library' ? 'Zuerst eine Hauptübung wählen' : undefined}
|
||||
>
|
||||
Schwester hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
frontend/src/pages/ProgressionGraphEditPage.jsx
Normal file
25
frontend/src/pages/ProgressionGraphEditPage.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import ProgressionGraphEditor from '../components/ProgressionGraphEditor'
|
||||
|
||||
export default function ProgressionGraphEditPage() {
|
||||
const { id } = useParams()
|
||||
const graphId = Number(id)
|
||||
|
||||
if (!Number.isFinite(graphId) || graphId < 1) {
|
||||
return (
|
||||
<div className="page" style={{ padding: '16px' }}>
|
||||
<p className="form-error">Ungültige Graph-ID.</p>
|
||||
<Link to="/exercises" className="btn btn-secondary">
|
||||
Zurück
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page" style={{ padding: '16px', paddingBottom: '80px' }}>
|
||||
<ProgressionGraphEditor graphId={graphId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
541
frontend/src/utils/progressionGraphDraft.js
Normal file
541
frontend/src/utils/progressionGraphDraft.js
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
/**
|
||||
* Progressionsgraph Slot-Editor — Draft-Hydration und Speichern (Phase B).
|
||||
*/
|
||||
|
||||
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
||||
export const PLANNING_ARTIFACT_SCHEMA = 1
|
||||
|
||||
export function emptySlotExercise() {
|
||||
return {
|
||||
kind: 'empty',
|
||||
exerciseId: null,
|
||||
variantId: null,
|
||||
exerciseTitle: '',
|
||||
variantName: null,
|
||||
proposalKey: null,
|
||||
aiSuggestion: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function librarySlotExercise({ exerciseId, exerciseTitle, variantId = null, variantName = null }) {
|
||||
return {
|
||||
kind: 'library',
|
||||
exerciseId: Number(exerciseId),
|
||||
variantId: variantId != null ? Number(variantId) : null,
|
||||
exerciseTitle: exerciseTitle || `Übung #${exerciseId}`,
|
||||
variantName: variantName || null,
|
||||
proposalKey: null,
|
||||
aiSuggestion: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function proposalSlotExercise({ title, proposalKey = null, aiSuggestion = null }) {
|
||||
return {
|
||||
kind: 'proposal',
|
||||
exerciseId: null,
|
||||
variantId: null,
|
||||
exerciseTitle: (title || 'KI-Vorschlag').trim(),
|
||||
variantName: null,
|
||||
proposalKey: proposalKey || `proposal-${Date.now()}`,
|
||||
aiSuggestion: aiSuggestion || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function slotExerciseFromApi(raw) {
|
||||
if (!raw || typeof raw !== 'object') return emptySlotExercise()
|
||||
const kind = raw.kind || (raw.exercise_id != null ? 'library' : raw.ai_suggestion ? 'proposal' : 'empty')
|
||||
if (kind === 'proposal' || raw.ai_suggestion) {
|
||||
return proposalSlotExercise({
|
||||
title: raw.title || raw.exercise_title,
|
||||
proposalKey: raw.proposal_key,
|
||||
aiSuggestion: raw.ai_suggestion,
|
||||
})
|
||||
}
|
||||
if (kind === 'library' && raw.exercise_id != null) {
|
||||
return librarySlotExercise({
|
||||
exerciseId: raw.exercise_id,
|
||||
exerciseTitle: raw.title || raw.exercise_title,
|
||||
variantId: raw.variant_id,
|
||||
variantName: raw.variant_name,
|
||||
})
|
||||
}
|
||||
return emptySlotExercise()
|
||||
}
|
||||
|
||||
export function slotExerciseToApi(entry) {
|
||||
if (!entry || entry.kind === 'empty') {
|
||||
return { kind: 'empty' }
|
||||
}
|
||||
if (entry.kind === 'proposal') {
|
||||
return {
|
||||
kind: 'proposal',
|
||||
title: entry.exerciseTitle || 'KI-Vorschlag',
|
||||
proposal_key: entry.proposalKey || null,
|
||||
ai_suggestion: entry.aiSuggestion || null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'library',
|
||||
exercise_id: entry.exerciseId,
|
||||
variant_id: entry.variantId || null,
|
||||
title: entry.exerciseTitle || null,
|
||||
variant_name: entry.variantName || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapMajorStepsFromApi(apiRoadmap) {
|
||||
const raw = apiRoadmap?.roadmap?.major_steps
|
||||
if (!Array.isArray(raw)) return []
|
||||
const rows = raw.map((s, i) => ({
|
||||
index: i,
|
||||
phase: s.phase || 'vertiefung',
|
||||
learning_goal: (s.learning_goal || '').trim(),
|
||||
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
|
||||
rationale: s.rationale || '',
|
||||
load_profile: [],
|
||||
success_criteria: [],
|
||||
anti_patterns: [],
|
||||
exercise_type: '',
|
||||
}))
|
||||
const specs = apiRoadmap?.stage_specs
|
||||
if (!Array.isArray(specs) || !rows.length) return rows
|
||||
return rows.map((row, i) => {
|
||||
const spec =
|
||||
specs.find((s) => Number(s.major_step_index) === i) ||
|
||||
specs.find((s) => Number(s.major_step_index) === row.index) ||
|
||||
specs[i]
|
||||
if (!spec) return row
|
||||
return {
|
||||
...row,
|
||||
load_profile: Array.isArray(spec.load_profile) ? [...spec.load_profile] : [],
|
||||
success_criteria: Array.isArray(spec.success_criteria) ? [...spec.success_criteria] : [],
|
||||
anti_patterns: Array.isArray(spec.anti_patterns) ? [...spec.anti_patterns] : [],
|
||||
exercise_type: (spec.exercise_type || '').trim(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function reindexMajorSteps(rows) {
|
||||
return rows.map((row, i) => ({ ...row, index: i }))
|
||||
}
|
||||
|
||||
export function majorStepsToOverridePayload(rows) {
|
||||
const indexed = reindexMajorSteps(rows)
|
||||
return {
|
||||
major_steps: indexed.map((row) => ({
|
||||
index: row.index,
|
||||
phase: row.phase || 'vertiefung',
|
||||
learning_goal: row.learning_goal.trim(),
|
||||
consolidates: row.consolidates || [],
|
||||
rationale: row.rationale || '',
|
||||
})),
|
||||
stage_specs: indexed.map((row, i) => ({
|
||||
major_step_index: i,
|
||||
learning_goal: row.learning_goal.trim(),
|
||||
load_profile: Array.isArray(row.load_profile) ? row.load_profile : [],
|
||||
exercise_type: (row.exercise_type || '').trim(),
|
||||
success_criteria: Array.isArray(row.success_criteria) ? row.success_criteria : [],
|
||||
anti_patterns: Array.isArray(row.anti_patterns) ? row.anti_patterns : [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function nodeKey(exerciseId, variantId) {
|
||||
return `${exerciseId}:${variantId ?? ''}`
|
||||
}
|
||||
|
||||
/** Maximale lineare Segmente aus next_exercise-Kanten. */
|
||||
export function maximalLinearChains(nextEdges) {
|
||||
if (!nextEdges?.length) return []
|
||||
const outMap = new Map()
|
||||
const inMap = new Map()
|
||||
|
||||
for (const e of nextEdges) {
|
||||
const f = nodeKey(e.from_exercise_id, e.from_exercise_variant_id)
|
||||
const t = nodeKey(e.to_exercise_id, e.to_exercise_variant_id)
|
||||
if (!outMap.has(f)) outMap.set(f, [])
|
||||
outMap.get(f).push(e)
|
||||
if (!inMap.has(t)) inMap.set(t, [])
|
||||
inMap.get(t).push(e)
|
||||
}
|
||||
|
||||
const used = new Set()
|
||||
const chains = []
|
||||
|
||||
for (const startEdge of nextEdges) {
|
||||
if (used.has(startEdge.id)) continue
|
||||
const edgesSeq = [startEdge]
|
||||
|
||||
let fk = nodeKey(startEdge.from_exercise_id, startEdge.from_exercise_variant_id)
|
||||
while (true) {
|
||||
const preds = inMap.get(fk)
|
||||
if (!preds || preds.length !== 1) break
|
||||
const pred = preds[0]
|
||||
if (used.has(pred.id)) break
|
||||
edgesSeq.unshift(pred)
|
||||
fk = nodeKey(pred.from_exercise_id, pred.from_exercise_variant_id)
|
||||
}
|
||||
|
||||
let tk = nodeKey(startEdge.to_exercise_id, startEdge.to_exercise_variant_id)
|
||||
while (true) {
|
||||
const outs = outMap.get(tk)
|
||||
if (!outs || outs.length !== 1) break
|
||||
const nx = outs[0]
|
||||
if (used.has(nx.id)) break
|
||||
edgesSeq.push(nx)
|
||||
tk = nodeKey(nx.to_exercise_id, nx.to_exercise_variant_id)
|
||||
}
|
||||
|
||||
edgesSeq.forEach((ed) => used.add(ed.id))
|
||||
const first = edgesSeq[0]
|
||||
const nodes = [
|
||||
{
|
||||
exercise_id: first.from_exercise_id,
|
||||
variant_id: first.from_exercise_variant_id ?? null,
|
||||
title: first.from_exercise_title,
|
||||
variant_name: first.from_variant_name ?? null,
|
||||
},
|
||||
]
|
||||
for (const ed of edgesSeq) {
|
||||
nodes.push({
|
||||
exercise_id: ed.to_exercise_id,
|
||||
variant_id: ed.to_exercise_variant_id ?? null,
|
||||
title: ed.to_exercise_title,
|
||||
variant_name: ed.to_variant_name ?? null,
|
||||
})
|
||||
}
|
||||
chains.push({ nodes, edges: edgesSeq })
|
||||
}
|
||||
return chains
|
||||
}
|
||||
|
||||
function chainNodeToLibrary(node) {
|
||||
if (!node?.exercise_id) return emptySlotExercise()
|
||||
return librarySlotExercise({
|
||||
exerciseId: node.exercise_id,
|
||||
exerciseTitle: node.title || `Übung #${node.exercise_id}`,
|
||||
variantId: node.variant_id,
|
||||
variantName: node.variant_name,
|
||||
})
|
||||
}
|
||||
|
||||
function buildSlotsFromSources({ majorSteps, slotContents, primaryChain, siblingEdges }) {
|
||||
const slotCount = Math.max(majorSteps.length, primaryChain?.nodes?.length || 0, 2)
|
||||
const slots = []
|
||||
|
||||
for (let i = 0; i < slotCount; i += 1) {
|
||||
const major = majorSteps[i] || {
|
||||
index: i,
|
||||
phase: ROADMAP_PHASES[Math.min(i, ROADMAP_PHASES.length - 1)],
|
||||
learning_goal: '',
|
||||
consolidates: [],
|
||||
rationale: '',
|
||||
load_profile: [],
|
||||
success_criteria: [],
|
||||
anti_patterns: [],
|
||||
exercise_type: '',
|
||||
}
|
||||
const saved = Array.isArray(slotContents)
|
||||
? slotContents.find((s) => Number(s.major_step_index) === i)
|
||||
: null
|
||||
|
||||
let primary = saved?.primary
|
||||
? slotExerciseFromApi(saved.primary)
|
||||
: chainNodeToLibrary(primaryChain?.nodes?.[i])
|
||||
|
||||
if (primary.kind === 'empty' && saved?.primary) {
|
||||
primary = slotExerciseFromApi(saved.primary)
|
||||
}
|
||||
|
||||
const siblings = []
|
||||
const seenSiblingKeys = new Set()
|
||||
|
||||
if (Array.isArray(saved?.siblings)) {
|
||||
for (const sib of saved.siblings) {
|
||||
const entry = slotExerciseFromApi(sib)
|
||||
if (entry.kind !== 'empty') {
|
||||
const key = entry.kind === 'library' ? `lib-${entry.exerciseId}` : `prop-${entry.proposalKey}`
|
||||
if (!seenSiblingKeys.has(key)) {
|
||||
seenSiblingKeys.add(key)
|
||||
siblings.push(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (primary.kind === 'library' && Array.isArray(siblingEdges)) {
|
||||
const pid = primary.exerciseId
|
||||
for (const edge of siblingEdges) {
|
||||
let other = null
|
||||
if (Number(edge.from_exercise_id) === pid) {
|
||||
other = librarySlotExercise({
|
||||
exerciseId: edge.to_exercise_id,
|
||||
exerciseTitle: edge.to_exercise_title,
|
||||
variantId: edge.to_exercise_variant_id,
|
||||
variantName: edge.to_variant_name,
|
||||
})
|
||||
} else if (Number(edge.to_exercise_id) === pid) {
|
||||
other = librarySlotExercise({
|
||||
exerciseId: edge.from_exercise_id,
|
||||
exerciseTitle: edge.from_exercise_title,
|
||||
variantId: edge.from_exercise_variant_id,
|
||||
variantName: edge.from_variant_name,
|
||||
})
|
||||
}
|
||||
if (other) {
|
||||
const key = `lib-${other.exerciseId}`
|
||||
if (!seenSiblingKeys.has(key)) {
|
||||
seenSiblingKeys.add(key)
|
||||
siblings.push(other)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slots.push({
|
||||
majorStepIndex: i,
|
||||
phase: major.phase,
|
||||
learning_goal: major.learning_goal,
|
||||
consolidates: major.consolidates,
|
||||
rationale: major.rationale,
|
||||
load_profile: major.load_profile,
|
||||
success_criteria: major.success_criteria,
|
||||
anti_patterns: major.anti_patterns,
|
||||
exercise_type: major.exercise_type,
|
||||
primary,
|
||||
siblings,
|
||||
})
|
||||
}
|
||||
|
||||
return slots
|
||||
}
|
||||
|
||||
export function hydrateProgressionGraphDraft({
|
||||
artifact = null,
|
||||
edges = [],
|
||||
graphName = '',
|
||||
}) {
|
||||
const nextEdges = (edges || []).filter((e) => (e.edge_type || 'next_exercise') === 'next_exercise')
|
||||
const siblingEdges = (edges || []).filter((e) => e.edge_type === 'sibling')
|
||||
const chains = maximalLinearChains(nextEdges)
|
||||
const primaryChain = chains.sort((a, b) => b.nodes.length - a.nodes.length)[0] || null
|
||||
|
||||
const majorSteps = artifact?.progression_roadmap
|
||||
? mapMajorStepsFromApi(artifact.progression_roadmap)
|
||||
: []
|
||||
|
||||
const slots = buildSlotsFromSources({
|
||||
majorSteps,
|
||||
slotContents: artifact?.slot_contents,
|
||||
primaryChain,
|
||||
siblingEdges,
|
||||
})
|
||||
|
||||
return {
|
||||
graphName: graphName || '',
|
||||
goalQuery: artifact?.goal_query || '',
|
||||
startSituation: artifact?.start_situation || '',
|
||||
targetState: artifact?.target_state || '',
|
||||
roadmapNotes: artifact?.roadmap_notes || '',
|
||||
maxSteps: Number(artifact?.max_steps) || Math.max(slots.length, 5),
|
||||
majorSteps: majorSteps.length ? majorSteps : slots.map((s, i) => ({
|
||||
index: i,
|
||||
phase: s.phase,
|
||||
learning_goal: s.learning_goal,
|
||||
consolidates: s.consolidates,
|
||||
rationale: s.rationale,
|
||||
load_profile: s.load_profile,
|
||||
success_criteria: s.success_criteria,
|
||||
anti_patterns: s.anti_patterns,
|
||||
exercise_type: s.exercise_type,
|
||||
})),
|
||||
slots,
|
||||
pathSkillExpectations: artifact?.path_skill_expectations || null,
|
||||
progressionRoadmap: artifact?.progression_roadmap || null,
|
||||
lastFindings: artifact?.last_findings || null,
|
||||
primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
|
||||
siblingEdgeIds: siblingEdges.map((e) => e.id),
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined } = {}) {
|
||||
const q = (draft.goalQuery || '').trim()
|
||||
const progressionRoadmap = draft.progressionRoadmap || null
|
||||
if (!q && !progressionRoadmap && !draft.slots?.length) return null
|
||||
|
||||
const slot_contents = (draft.slots || []).map((slot) => ({
|
||||
major_step_index: slot.majorStepIndex,
|
||||
primary: slotExerciseToApi(slot.primary),
|
||||
siblings: (slot.siblings || []).map(slotExerciseToApi).filter((s) => s.kind !== 'empty'),
|
||||
}))
|
||||
|
||||
const artifact = {
|
||||
schema_version: PLANNING_ARTIFACT_SCHEMA,
|
||||
goal_query: q,
|
||||
start_situation: (draft.startSituation || '').trim() || null,
|
||||
target_state: (draft.targetState || '').trim() || null,
|
||||
roadmap_notes: (draft.roadmapNotes || '').trim() || null,
|
||||
max_steps: Number(draft.maxSteps) || draft.slots?.length || 5,
|
||||
progression_roadmap: progressionRoadmap,
|
||||
path_skill_expectations: draft.pathSkillExpectations || null,
|
||||
slot_contents,
|
||||
}
|
||||
|
||||
const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings
|
||||
if (findings) artifact.last_findings = findings
|
||||
|
||||
return artifact
|
||||
}
|
||||
|
||||
/** Befüllte Primärkette (nur library) für edges/sequence. */
|
||||
export function draftPrimaryChainSteps(draft) {
|
||||
const steps = []
|
||||
for (const slot of draft.slots || []) {
|
||||
if (slot.primary?.kind === 'library' && slot.primary.exerciseId != null) {
|
||||
steps.push({
|
||||
exerciseId: slot.primary.exerciseId,
|
||||
variantId: slot.primary.variantId,
|
||||
exerciseTitle: slot.primary.exerciseTitle,
|
||||
majorStepIndex: slot.majorStepIndex,
|
||||
phase: slot.phase,
|
||||
learningGoal: slot.learning_goal,
|
||||
})
|
||||
}
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
export function draftSiblingEdgePairs(draft) {
|
||||
const pairs = []
|
||||
for (const slot of draft.slots || []) {
|
||||
if (slot.primary?.kind !== 'library' || slot.primary.exerciseId == null) continue
|
||||
for (const sib of slot.siblings || []) {
|
||||
if (sib.kind !== 'library' || sib.exerciseId == null) continue
|
||||
pairs.push({
|
||||
from: { exerciseId: slot.primary.exerciseId, variantId: slot.primary.variantId },
|
||||
to: { exerciseId: sib.exerciseId, variantId: sib.variantId },
|
||||
})
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
export function slotsToEvaluateSteps(draft) {
|
||||
return (draft.slots || []).map((slot) => {
|
||||
const p = slot.primary
|
||||
if (p.kind === 'proposal') {
|
||||
return {
|
||||
exercise_id: null,
|
||||
variant_id: null,
|
||||
title: p.exerciseTitle,
|
||||
is_ai_proposal: true,
|
||||
ai_suggestion: p.aiSuggestion,
|
||||
proposal_key: p.proposalKey,
|
||||
roadmap_major_step_index: slot.majorStepIndex,
|
||||
roadmap_phase: slot.phase,
|
||||
roadmap_learning_goal: slot.learning_goal,
|
||||
}
|
||||
}
|
||||
if (p.kind === 'library' && p.exerciseId != null) {
|
||||
return {
|
||||
exercise_id: p.exerciseId,
|
||||
variant_id: p.variantId || null,
|
||||
title: p.exerciseTitle,
|
||||
is_ai_proposal: false,
|
||||
roadmap_major_step_index: slot.majorStepIndex,
|
||||
roadmap_phase: slot.phase,
|
||||
roadmap_learning_goal: slot.learning_goal,
|
||||
}
|
||||
}
|
||||
return {
|
||||
exercise_id: null,
|
||||
variant_id: null,
|
||||
title: `(leer: ${slot.learning_goal || `Slot ${slot.majorStepIndex + 1}`})`,
|
||||
is_ai_proposal: true,
|
||||
roadmap_major_step_index: slot.majorStepIndex,
|
||||
roadmap_phase: slot.phase,
|
||||
roadmap_learning_goal: slot.learning_goal,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function applyMatchStepsToSlots(draft, apiSteps) {
|
||||
const steps = Array.isArray(apiSteps) ? apiSteps : []
|
||||
const nextSlots = (draft.slots || []).map((slot) => ({ ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] }))
|
||||
|
||||
for (const step of steps) {
|
||||
const idx =
|
||||
step.roadmap_major_step_index != null
|
||||
? Number(step.roadmap_major_step_index)
|
||||
: steps.indexOf(step)
|
||||
if (!Number.isFinite(idx) || idx < 0 || idx >= nextSlots.length) continue
|
||||
|
||||
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
|
||||
if (isProposal) {
|
||||
nextSlots[idx].primary = proposalSlotExercise({
|
||||
title: step.title,
|
||||
proposalKey: step.proposal_key,
|
||||
aiSuggestion: step.ai_suggestion,
|
||||
})
|
||||
} else {
|
||||
nextSlots[idx].primary = librarySlotExercise({
|
||||
exerciseId: step.exercise_id,
|
||||
exerciseTitle: step.title || `Übung #${step.exercise_id}`,
|
||||
variantId: step.variant_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { ...draft, slots: nextSlots, dirty: true }
|
||||
}
|
||||
|
||||
export function applyGapOfferToSlot(draft, slotIndex, offer, aiSuggestion = null) {
|
||||
const nextSlots = (draft.slots || []).map((s) => ({ ...s, primary: { ...s.primary }, siblings: [...(s.siblings || [])] }))
|
||||
if (slotIndex < 0 || slotIndex >= nextSlots.length) return draft
|
||||
nextSlots[slotIndex].primary = proposalSlotExercise({
|
||||
title: offer?.title_hint || offer?.title || 'KI-Vorschlag',
|
||||
proposalKey: offer?.offer_id || offer?.proposal_key,
|
||||
aiSuggestion: aiSuggestion || offer?.ai_suggestion || null,
|
||||
})
|
||||
return { ...draft, slots: nextSlots, dirty: true }
|
||||
}
|
||||
|
||||
export async function saveProgressionGraphDraft(api, graphId, draft) {
|
||||
const primarySteps = draftPrimaryChainSteps(draft)
|
||||
const siblingPairs = draftSiblingEdgePairs(draft)
|
||||
const artifact = buildPlanningArtifactFromDraft(draft)
|
||||
const edgeIds = [
|
||||
...(draft.primaryChainEdgeIds || []),
|
||||
...(draft.siblingEdgeIds || []),
|
||||
].filter((id) => Number.isFinite(Number(id)))
|
||||
|
||||
if (edgeIds.length > 0) {
|
||||
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds)
|
||||
}
|
||||
|
||||
if (primarySteps.length >= 2) {
|
||||
await api.createExerciseProgressionSequence(Number(graphId), {
|
||||
steps: primarySteps.map((s) => ({
|
||||
exercise_id: s.exerciseId,
|
||||
variant_id: s.variantId || null,
|
||||
})),
|
||||
segment_notes: primarySteps.slice(1).map(() => null),
|
||||
...(artifact ? { planning_roadmap: artifact } : {}),
|
||||
})
|
||||
} else if (artifact) {
|
||||
await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact })
|
||||
}
|
||||
|
||||
for (const pair of siblingPairs) {
|
||||
await api.createExerciseProgressionEdge(Number(graphId), {
|
||||
from_exercise_id: pair.from.exerciseId,
|
||||
from_exercise_variant_id: pair.from.variantId,
|
||||
to_exercise_id: pair.to.exerciseId,
|
||||
to_exercise_variant_id: pair.to.variantId,
|
||||
edge_type: 'sibling',
|
||||
})
|
||||
}
|
||||
|
||||
return { primaryCount: primarySteps.length, siblingCount: siblingPairs.length }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user