Enhance Progression Path Suggestion with Planning Catalog Context Integration
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m16s

- Introduced `planning_catalog_context` to `ProgressionPathSuggestRequest` for improved handling of catalog-related data during path suggestions.
- Implemented `_resolve_planning_catalog_context` to retrieve and validate the planning catalog context, enhancing the robustness of the suggestion process.
- Updated `_build_path_target_profile` to incorporate catalog context, enriching target profiles with relevant planning data.
- Enhanced frontend components in `ProgressionGraphEditor` to manage and display planning catalog context, including new selection options for focus areas, style directions, training types, and target groups.
- Added utility functions for parsing and transforming planning catalog context data for API interactions.
- Bumped version to 0.8.233 to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-12 10:16:55 +02:00
parent a4e73c830f
commit 6ab2f20f08
8 changed files with 441 additions and 8 deletions

View File

@ -0,0 +1,147 @@
"""
Katalog-Kontext für Progressionsgraph-Planung Fokusbereich, Stil, Trainingsstil, Zielgruppe.
Explizite Trainer-Auswahl ergänzt Freitext/LLM; ersetzt kein Roadmap-Didaktik-Modell.
"""
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Sequence
from pydantic import BaseModel, Field
from planning_exercise_profiles import PlanningTargetProfile, _normalize_weight_map
from planning_exercise_target_pipeline import (
SCENARIO_FREE_SEARCH,
merge_query_overlay_into_target,
)
from planning_exercise_text_signals import resolve_planning_text_to_catalog_weights
class PlanningCatalogContextItem(BaseModel):
id: int = Field(..., ge=1)
is_primary: bool = False
weight: float = Field(default=1.0, ge=0.1, le=1.0)
class ProgressionPlanningCatalogContext(BaseModel):
focus_areas: List[PlanningCatalogContextItem] = Field(default_factory=list)
style_directions: List[PlanningCatalogContextItem] = Field(default_factory=list)
training_types: List[PlanningCatalogContextItem] = Field(default_factory=list)
target_groups: List[PlanningCatalogContextItem] = Field(default_factory=list)
def catalog_context_has_items(catalog: Optional[ProgressionPlanningCatalogContext]) -> bool:
if catalog is None:
return False
return bool(
catalog.focus_areas
or catalog.style_directions
or catalog.training_types
or catalog.target_groups
)
def catalog_items_to_weight_map(
items: Sequence[PlanningCatalogContextItem],
*,
primary_weight: float = 0.95,
secondary_weight: float = 0.78,
) -> Dict[int, float]:
out: Dict[int, float] = {}
for item in items or []:
base = primary_weight if item.is_primary else secondary_weight
w = base * float(item.weight)
iid = int(item.id)
out[iid] = max(out.get(iid, 0.0), w)
return _normalize_weight_map(out) if out else out
def merge_catalog_context_into_target(
target: PlanningTargetProfile,
catalog: Optional[ProgressionPlanningCatalogContext],
*,
emphasis: str = "replace",
) -> PlanningTargetProfile:
"""Trainer-Katalog-Kontext ins Erwartungsprofil — beeinflusst Retrieval-Scoring."""
if not catalog_context_has_items(catalog):
return target
focus = catalog_items_to_weight_map(catalog.focus_areas)
style = catalog_items_to_weight_map(catalog.style_directions, primary_weight=0.9, secondary_weight=0.72)
tt = catalog_items_to_weight_map(catalog.training_types, primary_weight=0.9, secondary_weight=0.72)
tg = catalog_items_to_weight_map(catalog.target_groups, primary_weight=0.88, secondary_weight=0.7)
merged = merge_query_overlay_into_target(
target,
focus=focus,
style=style,
tt=tt,
tg=tg,
skills={},
emphasis=emphasis,
scenario=SCENARIO_FREE_SEARCH,
)
sources = list(merged.sources or [])
if "catalog_context" not in sources:
sources.append("catalog_context")
merged.sources = sources
return merged
def enrich_target_from_planning_text_blobs(
cur,
target: PlanningTargetProfile,
*text_blobs: Optional[str],
) -> PlanningTargetProfile:
"""Additive Katalog-Signale aus Freitext (Anfrage, Start/Ziel, Notizen)."""
combined = " ".join(str(t or "").strip() for t in text_blobs if (t or "").strip())
if len(combined) < 4:
return target
focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(cur, combined)
if not (focus or style or tt or tg or skills):
return target
merged = merge_query_overlay_into_target(
target,
focus=focus,
style=style,
tt=tt,
tg=tg,
skills=skills,
emphasis="additive",
scenario=SCENARIO_FREE_SEARCH,
)
sources = list(merged.sources or [])
if "text_catalog_signals" not in sources:
sources.append("text_catalog_signals")
merged.sources = sources
return merged
def catalog_context_from_mapping(raw: Any) -> Optional[ProgressionPlanningCatalogContext]:
if not raw or not isinstance(raw, Mapping):
return None
try:
ctx = ProgressionPlanningCatalogContext.model_validate(dict(raw))
except Exception:
return None
return ctx if catalog_context_has_items(ctx) else None
def load_catalog_context_from_graph_row(
planning_roadmap: Any,
) -> Optional[ProgressionPlanningCatalogContext]:
if not isinstance(planning_roadmap, dict):
return None
return catalog_context_from_mapping(planning_roadmap.get("planning_catalog_context"))
__all__ = [
"PlanningCatalogContextItem",
"ProgressionPlanningCatalogContext",
"catalog_context_from_mapping",
"catalog_context_has_items",
"catalog_items_to_weight_map",
"enrich_target_from_planning_text_blobs",
"load_catalog_context_from_graph_row",
"merge_catalog_context_into_target",
]

View File

@ -16,6 +16,13 @@ from tenant_context import (
library_content_visibility_for_progression_graph_sql, library_content_visibility_for_progression_graph_sql,
library_content_visibility_sql, library_content_visibility_sql,
) )
from planning_catalog_context import (
ProgressionPlanningCatalogContext,
catalog_context_has_items,
enrich_target_from_planning_text_blobs,
load_catalog_context_from_graph_row,
merge_catalog_context_into_target,
)
from planning_exercise_profiles import PlanningTargetProfile from planning_exercise_profiles import PlanningTargetProfile
from planning_path_qa_pipeline import run_multistage_path_qa from planning_path_qa_pipeline import run_multistage_path_qa
from planning_path_rematch import ( from planning_path_rematch import (
@ -133,6 +140,27 @@ class ProgressionPathSuggestRequest(BaseModel):
roadmap_notes: Optional[str] = Field(default=None, max_length=2000) roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
progression_graph_id: Optional[int] = Field(default=None, ge=1) progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None exercise_kind_any: Optional[List[str]] = None
planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None
def _resolve_planning_catalog_context(
cur,
body: ProgressionPathSuggestRequest,
) -> Optional[ProgressionPlanningCatalogContext]:
"""Request-Kontext oder gespeichertes Graph-Artefakt."""
if body.planning_catalog_context and catalog_context_has_items(body.planning_catalog_context):
return body.planning_catalog_context
gid = body.progression_graph_id
if not gid or int(gid) < 1:
return None
cur.execute(
"SELECT planning_roadmap FROM exercise_progression_graphs WHERE id = %s",
(int(gid),),
)
row = cur.fetchone()
if not row:
return None
return load_catalog_context_from_graph_row(row.get("planning_roadmap"))
def _roadmap_gap_snapshot_for_spec( def _roadmap_gap_snapshot_for_spec(
@ -308,8 +336,12 @@ def _build_path_target_profile(
goal_query: str, goal_query: str,
semantic_brief: PlanningSemanticBrief, semantic_brief: PlanningSemanticBrief,
include_llm_intent: bool, include_llm_intent: bool,
start_situation: Optional[str] = None,
target_state: Optional[str] = None,
roadmap_notes: Optional[str] = None,
catalog_context: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[PlanningTargetProfile, Dict[str, Any], str]: ) -> Tuple[PlanningTargetProfile, Dict[str, Any], str]:
"""Einmaliges Erwartungsprofil für den gesamten Pfad (Query + Semantik + Skills).""" """Einmaliges Erwartungsprofil für den gesamten Pfad (Query + Semantik + Katalog)."""
empty_unit = { empty_unit = {
"id": None, "id": None,
"framework_slot_id": None, "framework_slot_id": None,
@ -345,6 +377,20 @@ def _build_path_target_profile(
) )
skill_weights = resolve_semantic_skill_weights(cur, semantic_brief) skill_weights = resolve_semantic_skill_weights(cur, semantic_brief)
target = enrich_target_with_semantic_expectations(target, skill_weights=skill_weights) target = enrich_target_with_semantic_expectations(target, skill_weights=skill_weights)
target = enrich_target_from_planning_text_blobs(
cur,
target,
goal_query,
start_situation,
target_state,
roadmap_notes,
)
if catalog_context and catalog_context_has_items(catalog_context):
target = merge_catalog_context_into_target(
target,
catalog_context,
emphasis="replace",
)
return target, query_intent_summary, intent return target, query_intent_summary, intent
@ -1051,7 +1097,7 @@ def _stage_validation_context_for_spec(
or "" or ""
).strip() ).strip()
path_tech_excludes = list(semantic_brief.exclude_phrases or []) path_tech_excludes = list(semantic_brief.exclude_phrases or [])
if path_primary: if path_primary and semantic_brief.topic_type == "technique":
from planning_exercise_semantics import technique_sibling_excludes from planning_exercise_semantics import technique_sibling_excludes
for item in technique_sibling_excludes(path_primary): for item in technique_sibling_excludes(path_primary):
@ -2239,6 +2285,10 @@ def suggest_progression_path(
goal_query=goal_query, goal_query=goal_query,
semantic_brief=semantic_brief, semantic_brief=semantic_brief,
include_llm_intent=body.include_llm_intent, include_llm_intent=body.include_llm_intent,
start_situation=body.start_situation,
target_state=body.target_state,
roadmap_notes=body.roadmap_notes,
catalog_context=_resolve_planning_catalog_context(cur, body),
) )
path_skill_expectations: Optional[Dict[str, Any]] = None path_skill_expectations: Optional[Dict[str, Any]] = None
if roadmap_ctx and roadmap_ctx.goal_analysis: if roadmap_ctx and roadmap_ctx.goal_analysis:

View File

@ -471,11 +471,11 @@ def detect_off_topic_steps(
resolve_path_primary_topic( resolve_path_primary_topic(
goal_query or "", goal_query or "",
brief, brief,
stage_learning_goal=stage_goal_pre or None, stage_learning_goal=None,
) )
or "" or ""
).strip() ).strip()
if primary: if primary and brief.topic_type == "technique":
siblings = technique_sibling_excludes(primary) siblings = technique_sibling_excludes(primary)
if not exercise_passes_technique_path_scope( if not exercise_passes_technique_path_scope(
primary_topic=primary, primary_topic=primary,

View File

@ -37,8 +37,9 @@ 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
planning_catalog_context: Optional[Dict[str, Any]] = None
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", mode="before") @field_validator("progression_roadmap", "path_skill_expectations", "last_findings", "planning_catalog_context", mode="before")
@classmethod @classmethod
def _empty_dict_to_none(cls, v): def _empty_dict_to_none(cls, v):
if v == {}: if v == {}:

View File

@ -0,0 +1,47 @@
"""Tests Katalog-Kontext für Progressionsgraph-Matching."""
from planning_catalog_context import (
ProgressionPlanningCatalogContext,
PlanningCatalogContextItem,
catalog_context_has_items,
merge_catalog_context_into_target,
)
from planning_exercise_profiles import PlanningTargetProfile
def test_catalog_context_has_items():
assert not catalog_context_has_items(None)
assert not catalog_context_has_items(ProgressionPlanningCatalogContext())
assert catalog_context_has_items(
ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=3, is_primary=True)],
)
)
def test_merge_catalog_context_into_target_sets_focus():
base = PlanningTargetProfile(sources=["query_only"])
merged = merge_catalog_context_into_target(
base,
ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=7, is_primary=True)],
training_types=[PlanningCatalogContextItem(id=2, is_primary=True)],
),
)
assert merged.focus_area_ids.get(7, 0) > 0.5
assert merged.training_type_ids.get(2, 0) > 0.5
assert "catalog_context" in merged.sources
def test_normalize_planning_roadmap_with_catalog_context():
from progression_graph_planning_artifact import normalize_planning_roadmap_payload
out = normalize_planning_roadmap_payload(
{
"goal_query": "Deeskalation Kinder",
"planning_catalog_context": {
"focus_areas": [{"id": 4, "is_primary": True}],
"target_groups": [{"id": 9, "is_primary": True}],
},
}
)
assert out["planning_catalog_context"]["focus_areas"][0]["id"] == 4

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.232" APP_VERSION = "0.8.233"
BUILD_DATE = "2026-05-22" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090" DB_SCHEMA_VERSION = "20260607090"
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume "exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"planning_exercise_suggest": "0.23.7", # roadmap_unfilled nach Rematch-Treffer bereinigen "planning_exercise_suggest": "0.23.8", # Progressionsgraph: Katalog-Kontext (Fokus/Stil/TT/ZG) im Match-Profil
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung

View File

@ -45,6 +45,10 @@ import {
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) {
@ -86,6 +90,9 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [semanticBrief, setSemanticBrief] = useState(null) const [semanticBrief, setSemanticBrief] = useState(null)
const [targetSummary, setTargetSummary] = useState(null) const [targetSummary, setTargetSummary] = useState(null)
const [focusAreas, setFocusAreas] = useState([]) const [focusAreas, setFocusAreas] = useState([])
const [styleDirections, setStyleDirections] = useState([])
const [trainingTypes, setTrainingTypes] = useState([])
const [targetGroups, setTargetGroups] = useState([])
const [skillsCatalog, setSkillsCatalog] = useState([]) const [skillsCatalog, setSkillsCatalog] = useState([])
const [activeOffer, setActiveOffer] = useState(null) const [activeOffer, setActiveOffer] = useState(null)
@ -144,16 +151,25 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
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([])
} }
}) })
@ -280,6 +296,24 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3) return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
}, [draft?.slots]) }, [draft?.slots])
const catalogCtx = draft?.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT
const patchCatalogDimension = (key, value) => {
patchDraft((d) => ({
...d,
dirty: true,
planningCatalogContext: {
...(d.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT),
[key]: setCatalogSelectItems(d.planningCatalogContext?.[key], value),
},
}))
}
const catalogApiPayload = useMemo(
() => planningCatalogContextToApi(catalogCtx),
[catalogCtx],
)
const runAnalyzeStartTarget = async () => { const runAnalyzeStartTarget = async () => {
const q = (draft?.goalQuery || '').trim() const q = (draft?.goalQuery || '').trim()
if (q.length < 3) { if (q.length < 3) {
@ -303,6 +337,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
start_target_only: true, start_target_only: true,
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
}) })
const roadmap = res?.progression_roadmap const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Start/Ziel-Analyse in der Antwort') if (!roadmap) throw new Error('Keine Start/Ziel-Analyse in der Antwort')
@ -346,6 +381,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
roadmap_only: true, roadmap_only: true,
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
}) })
const roadmap = res?.progression_roadmap const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Roadmap in der Antwort') if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
@ -420,6 +456,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced), retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
}) })
const { draft: matched, remainingOffers } = applyMatchResponseToDraft( const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
{ {
@ -493,6 +530,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
roadmap_override: override, roadmap_override: override,
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
}) })
setSemanticBrief(res?.semantic_brief_summary || null) setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null) setPathQa(res?.path_qa || null)
@ -871,6 +909,83 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
/> />
</div> </div>
</div> </div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '10px',
marginTop: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Primärfokus</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.focusAreas)}
onChange={(e) => patchCatalogDimension('focusAreas', e.target.value)}
>
<option value=""> optional </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Stilrichtung</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.styleDirections)}
onChange={(e) => patchCatalogDimension('styleDirections', e.target.value)}
>
<option value=""> optional </option>
{(styleDirections || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Trainingsstil</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.trainingTypes)}
onChange={(e) => patchCatalogDimension('trainingTypes', e.target.value)}
>
<option value=""> optional </option>
{(trainingTypes || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Zielgruppe</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.targetGroups)}
onChange={(e) => patchCatalogDimension('targetGroups', e.target.value)}
>
<option value=""> optional </option>
{(targetGroups || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) unabhängig
von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert.
</p>
<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.

View File

@ -7,6 +7,73 @@ export const SLOT_MAX = 10
export const SLOT_MIN = 2 export const SLOT_MIN = 2
export const PLANNING_ARTIFACT_SCHEMA = 1 export const PLANNING_ARTIFACT_SCHEMA = 1
export const EMPTY_PLANNING_CATALOG_CONTEXT = {
focusAreas: [],
styleDirections: [],
trainingTypes: [],
targetGroups: [],
}
function mapCatalogItemsFromApi(list) {
if (!Array.isArray(list)) return []
return list
.map((row) => ({
id: Number(row?.id),
isPrimary: Boolean(row?.is_primary ?? row?.isPrimary),
weight: row?.weight != null ? Number(row.weight) : 1,
}))
.filter((row) => Number.isFinite(row.id) && row.id > 0)
}
export function parsePlanningCatalogContextFromArtifact(artifact) {
const raw = artifact?.planning_catalog_context
if (!raw || typeof raw !== 'object') return { ...EMPTY_PLANNING_CATALOG_CONTEXT }
return {
focusAreas: mapCatalogItemsFromApi(raw.focus_areas),
styleDirections: mapCatalogItemsFromApi(raw.style_directions),
trainingTypes: mapCatalogItemsFromApi(raw.training_types),
targetGroups: mapCatalogItemsFromApi(raw.target_groups),
}
}
export function getCatalogSelectId(items) {
const list = Array.isArray(items) ? items : []
const primary = list.find((x) => x.isPrimary) || list[0]
return primary?.id != null && Number.isFinite(Number(primary.id)) ? String(primary.id) : ''
}
export function setCatalogSelectItems(_items, id) {
const n = Number(id)
if (!Number.isFinite(n) || n < 1) return []
return [{ id: n, isPrimary: true, weight: 1 }]
}
export function planningCatalogContextToApi(ctx) {
const mapOut = (items) =>
(items || [])
.filter((row) => row?.id != null && Number(row.id) > 0)
.map((row) => ({
id: Number(row.id),
is_primary: Boolean(row.isPrimary),
weight: Number.isFinite(Number(row.weight)) ? Number(row.weight) : 1,
}))
const focus_areas = mapOut(ctx?.focusAreas)
const style_directions = mapOut(ctx?.styleDirections)
const training_types = mapOut(ctx?.trainingTypes)
const target_groups = mapOut(ctx?.targetGroups)
if (!focus_areas.length && !style_directions.length && !training_types.length && !target_groups.length) {
return {}
}
return {
planning_catalog_context: {
focus_areas,
style_directions,
training_types,
target_groups,
},
}
}
/** Start/Ziel/Ergänzungen aus KI-Roadmap-Antwort (resolved_structured). */ /** Start/Ziel/Ergänzungen aus KI-Roadmap-Antwort (resolved_structured). */
export function resolvedStructuredFromRoadmap(progressionRoadmap) { export function resolvedStructuredFromRoadmap(progressionRoadmap) {
const rs = progressionRoadmap?.resolved_structured const rs = progressionRoadmap?.resolved_structured
@ -711,6 +778,7 @@ export function hydrateProgressionGraphDraft({
slots, slots,
pathSkillExpectations: artifact?.path_skill_expectations || null, pathSkillExpectations: artifact?.path_skill_expectations || null,
progressionRoadmap: artifact?.progression_roadmap || null, progressionRoadmap: artifact?.progression_roadmap || null,
planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact),
lastFindings: artifact?.last_findings || null, lastFindings: artifact?.last_findings || null,
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),
@ -741,6 +809,11 @@ export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined
slot_contents, slot_contents,
} }
const catalog = planningCatalogContextToApi(draft.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT)
if (catalog.planning_catalog_context) {
artifact.planning_catalog_context = catalog.planning_catalog_context
}
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