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_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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 == {}:
|
||||||
|
|
|
||||||
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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user