diff --git a/backend/planning_catalog_context.py b/backend/planning_catalog_context.py new file mode 100644 index 0000000..d1dd143 --- /dev/null +++ b/backend/planning_catalog_context.py @@ -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", +] diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 654f91d..6d3fcc7 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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: diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 79f6b16..472ec35 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -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, diff --git a/backend/progression_graph_planning_artifact.py b/backend/progression_graph_planning_artifact.py index 0a75480..3cffdaf 100644 --- a/backend/progression_graph_planning_artifact.py +++ b/backend/progression_graph_planning_artifact.py @@ -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 == {}: diff --git a/backend/tests/test_planning_catalog_context.py b/backend/tests/test_planning_catalog_context.py new file mode 100644 index 0000000..f525be6 --- /dev/null +++ b/backend/tests/test_planning_catalog_context.py @@ -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 diff --git a/backend/version.py b/backend/version.py index 2e8c8e2..7f1310a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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 diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index c14de11..97cd1c5 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -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 /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+ Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig + von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert. +

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. diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 908cba8..c654b17 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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