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
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:
parent
a4e73c830f
commit
6ab2f20f08
147
backend/planning_catalog_context.py
Normal file
147
backend/planning_catalog_context.py
Normal 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",
|
||||
]
|
||||
|
|
@ -16,6 +16,13 @@ from tenant_context import (
|
|||
library_content_visibility_for_progression_graph_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_path_qa_pipeline import run_multistage_path_qa
|
||||
from planning_path_rematch import (
|
||||
|
|
@ -133,6 +140,27 @@ class ProgressionPathSuggestRequest(BaseModel):
|
|||
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
|
||||
progression_graph_id: Optional[int] = Field(default=None, ge=1)
|
||||
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(
|
||||
|
|
@ -308,8 +336,12 @@ def _build_path_target_profile(
|
|||
goal_query: str,
|
||||
semantic_brief: PlanningSemanticBrief,
|
||||
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]:
|
||||
"""Einmaliges Erwartungsprofil für den gesamten Pfad (Query + Semantik + Skills)."""
|
||||
"""Einmaliges Erwartungsprofil für den gesamten Pfad (Query + Semantik + Katalog)."""
|
||||
empty_unit = {
|
||||
"id": None,
|
||||
"framework_slot_id": None,
|
||||
|
|
@ -345,6 +377,20 @@ def _build_path_target_profile(
|
|||
)
|
||||
skill_weights = resolve_semantic_skill_weights(cur, semantic_brief)
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -1051,7 +1097,7 @@ def _stage_validation_context_for_spec(
|
|||
or ""
|
||||
).strip()
|
||||
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
|
||||
|
||||
for item in technique_sibling_excludes(path_primary):
|
||||
|
|
@ -2239,6 +2285,10 @@ def suggest_progression_path(
|
|||
goal_query=goal_query,
|
||||
semantic_brief=semantic_brief,
|
||||
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
|
||||
if roadmap_ctx and roadmap_ctx.goal_analysis:
|
||||
|
|
|
|||
|
|
@ -471,11 +471,11 @@ def detect_off_topic_steps(
|
|||
resolve_path_primary_topic(
|
||||
goal_query or "",
|
||||
brief,
|
||||
stage_learning_goal=stage_goal_pre or None,
|
||||
stage_learning_goal=None,
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if primary:
|
||||
if primary and brief.topic_type == "technique":
|
||||
siblings = technique_sibling_excludes(primary)
|
||||
if not exercise_passes_technique_path_scope(
|
||||
primary_topic=primary,
|
||||
|
|
|
|||
|
|
@ -37,8 +37,9 @@ class GraphPlanningRoadmapArtifact(BaseModel):
|
|||
path_skill_expectations: Optional[Dict[str, Any]] = None
|
||||
slot_contents: Optional[List[SlotContentEntry]] = 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
|
||||
def _empty_dict_to_none(cls, v):
|
||||
if v == {}:
|
||||
|
|
|
|||
47
backend/tests/test_planning_catalog_context.py
Normal file
47
backend/tests/test_planning_catalog_context.py
Normal 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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.232"
|
||||
APP_VERSION = "0.8.233"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260607090"
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
|
|||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"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_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ import {
|
|||
slotsToSlotAssignments,
|
||||
syncProgressionRoadmapFromSlots,
|
||||
syncSlotPhasesFromRoadmap,
|
||||
EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||
getCatalogSelectId,
|
||||
planningCatalogContextToApi,
|
||||
setCatalogSelectItems,
|
||||
} from '../utils/progressionGraphDraft'
|
||||
|
||||
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
||||
|
|
@ -86,6 +90,9 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
const [semanticBrief, setSemanticBrief] = useState(null)
|
||||
const [targetSummary, setTargetSummary] = useState(null)
|
||||
const [focusAreas, setFocusAreas] = useState([])
|
||||
const [styleDirections, setStyleDirections] = useState([])
|
||||
const [trainingTypes, setTrainingTypes] = useState([])
|
||||
const [targetGroups, setTargetGroups] = useState([])
|
||||
const [skillsCatalog, setSkillsCatalog] = useState([])
|
||||
|
||||
const [activeOffer, setActiveOffer] = useState(null)
|
||||
|
|
@ -144,16 +151,25 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
let cancelled = false
|
||||
Promise.all([
|
||||
api.listFocusAreas({ status: 'active' }),
|
||||
api.listStyleDirections({ status: 'active' }),
|
||||
api.listTrainingTypes({ status: 'active' }),
|
||||
api.listTargetGroups({ status: 'active' }),
|
||||
api.listSkillsCatalog({ status: 'active' }),
|
||||
])
|
||||
.then(([fa, sk]) => {
|
||||
.then(([fa, sd, tt, tg, sk]) => {
|
||||
if (cancelled) return
|
||||
setFocusAreas(Array.isArray(fa) ? fa : [])
|
||||
setStyleDirections(Array.isArray(sd) ? sd : [])
|
||||
setTrainingTypes(Array.isArray(tt) ? tt : [])
|
||||
setTargetGroups(Array.isArray(tg) ? tg : [])
|
||||
setSkillsCatalog(Array.isArray(sk) ? sk : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFocusAreas([])
|
||||
setStyleDirections([])
|
||||
setTrainingTypes([])
|
||||
setTargetGroups([])
|
||||
setSkillsCatalog([])
|
||||
}
|
||||
})
|
||||
|
|
@ -280,6 +296,24 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
|
||||
}, [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 q = (draft?.goalQuery || '').trim()
|
||||
if (q.length < 3) {
|
||||
|
|
@ -303,6 +337,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
start_target_only: true,
|
||||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
...catalogApiPayload,
|
||||
})
|
||||
const roadmap = res?.progression_roadmap
|
||||
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,
|
||||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
...catalogApiPayload,
|
||||
})
|
||||
const roadmap = res?.progression_roadmap
|
||||
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),
|
||||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
...catalogApiPayload,
|
||||
})
|
||||
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
|
||||
{
|
||||
|
|
@ -493,6 +530,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
roadmap_override: override,
|
||||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
...catalogApiPayload,
|
||||
})
|
||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||
setPathQa(res?.path_qa || null)
|
||||
|
|
@ -871,6 +909,83 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: '10px',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Primärfokus</label>
|
||||
<select
|
||||
className="form-input"
|
||||
disabled={busy}
|
||||
value={getCatalogSelectId(catalogCtx.focusAreas)}
|
||||
onChange={(e) => patchCatalogDimension('focusAreas', e.target.value)}
|
||||
>
|
||||
<option value="">— optional —</option>
|
||||
{(focusAreas || []).map((fa) => (
|
||||
<option key={fa.id} value={String(fa.id)}>
|
||||
{fa.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Stilrichtung</label>
|
||||
<select
|
||||
className="form-input"
|
||||
disabled={busy}
|
||||
value={getCatalogSelectId(catalogCtx.styleDirections)}
|
||||
onChange={(e) => patchCatalogDimension('styleDirections', e.target.value)}
|
||||
>
|
||||
<option value="">— optional —</option>
|
||||
{(styleDirections || []).map((row) => (
|
||||
<option key={row.id} value={String(row.id)}>
|
||||
{row.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Trainingsstil</label>
|
||||
<select
|
||||
className="form-input"
|
||||
disabled={busy}
|
||||
value={getCatalogSelectId(catalogCtx.trainingTypes)}
|
||||
onChange={(e) => patchCatalogDimension('trainingTypes', e.target.value)}
|
||||
>
|
||||
<option value="">— optional —</option>
|
||||
{(trainingTypes || []).map((row) => (
|
||||
<option key={row.id} value={String(row.id)}>
|
||||
{row.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Zielgruppe</label>
|
||||
<select
|
||||
className="form-input"
|
||||
disabled={busy}
|
||||
value={getCatalogSelectId(catalogCtx.targetGroups)}
|
||||
onChange={(e) => patchCatalogDimension('targetGroups', e.target.value)}
|
||||
>
|
||||
<option value="">— optional —</option>
|
||||
{(targetGroups || []).map((row) => (
|
||||
<option key={row.id} value={String(row.id)}>
|
||||
{row.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
|
||||
Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig
|
||||
von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert.
|
||||
</p>
|
||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
|
||||
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
|
||||
geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,73 @@ export const SLOT_MAX = 10
|
|||
export const SLOT_MIN = 2
|
||||
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). */
|
||||
export function resolvedStructuredFromRoadmap(progressionRoadmap) {
|
||||
const rs = progressionRoadmap?.resolved_structured
|
||||
|
|
@ -711,6 +778,7 @@ export function hydrateProgressionGraphDraft({
|
|||
slots,
|
||||
pathSkillExpectations: artifact?.path_skill_expectations || null,
|
||||
progressionRoadmap: artifact?.progression_roadmap || null,
|
||||
planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact),
|
||||
lastFindings: artifact?.last_findings || null,
|
||||
primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
|
||||
siblingEdgeIds: siblingEdges.map((e) => e.id),
|
||||
|
|
@ -741,6 +809,11 @@ export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined
|
|||
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
|
||||
if (findings) artifact.last_findings = findings
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user