Compare commits

...

34 Commits

Author SHA1 Message Date
9b4d091637 Merge pull request 'Progression optimiert Phase A' (#55) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 48s
Reviewed-on: #55
2026-06-11 21:26:54 +02:00
df93da9a03 Enhance Gap Fill and Rematch Logic in Progression Path
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 39s
Test Suite / playwright-tests (push) Successful in 1m22s
- Introduced `_step_neighbors_at_index` to safely retrieve neighboring steps without causing IndexErrors, improving robustness in gap fill specifications.
- Updated `collect_gap_fill_specs` to utilize the new neighbor retrieval function, ensuring safe access to adjacent steps during gap fill processing.
- Enhanced rematch logic in `_run_roadmap_rematch_loop` to incorporate `max_rematch_rounds`, allowing for controlled iterations during roadmap rematching.
- Improved handling of unfilled roadmap slots in `collect_rematch_slot_indices`, ensuring accurate identification of gaps in the progression path.
- Added tests to validate the new gap fill handling and rematch logic, ensuring reliability in path suggestion features.
2026-06-11 21:20:47 +02:00
de939481ba Enhance Gap Fill Offer Handling and Progression Path Logic
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m24s
- Updated `suggest_progression_path` to ensure unique gap fill offers are collected and added based on their IDs, improving the relevance of suggestions.
- Refined the logic for setting `slot_status` and handling `gap_offer` and `proposal_key` in steps, enhancing clarity in progression path management.
- Improved the `collectGapOffersFromApiResponse` function to consolidate gap offers from various sources, ensuring comprehensive offer retrieval.
- Enhanced the handling of unfilled slots in `applyMatchStepsToSlots`, ensuring proper assignment of proposals and gap offers.
- Added tests to validate the new logic for gap fill offers and slot assignments, ensuring robustness in path suggestion features.
2026-06-11 13:13:46 +02:00
6d130a7e09 Implement Learning Goal Candidate Retrieval and Roadmap Fallback Logic
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s
- Added `_safe_tsquery_fragment` to sanitize learning goal input for SQL queries, improving query safety.
- Introduced `_fetch_learning_goal_library_candidate_ids` to retrieve exercise IDs matching learning goals, enhancing exercise relevance in roadmap suggestions.
- Enhanced `_match_roadmap_slot` to utilize learning goal candidates, improving the accuracy of supplemental exercise selection.
- Implemented `_pick_roadmap_rank_fallback` to provide a fallback mechanism for selecting the best exercise when strict matching fails, ensuring better exercise retrieval.
- Updated tests to validate the new learning goal retrieval and fallback logic, ensuring robustness in exercise selection processes.
2026-06-11 12:54:07 +02:00
b2fbf6b4af Refactor Roadmap Step Annotation and Slot Assignment Logic
All checks were successful
Deploy Development / deploy (push) Successful in 49s
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 33s
Test Suite / playwright-tests (push) Successful in 1m12s
- Updated `_annotate_roadmap_step` to change the condition for setting `slot_status` based on `roadmap_match_source`, improving clarity in slot assignment handling.
- Removed the `_try_reconcile_slot_assignment` function to streamline the slot assignment process, as its logic is now integrated into the main flow.
- Enhanced `_match_roadmap_slot` to conditionally preserve slot assignments based on exercise ID, ensuring better handling of existing assignments.
- Improved the handling of semantic scores in `rank_visible_library_hits` to prioritize the best semantic fit, enhancing exercise retrieval accuracy.
- Added tests to validate the new logic for title equivalence and semantic scoring, ensuring robustness in exercise selection processes.
2026-06-11 12:45:53 +02:00
ca2adbd55e Enhance Exercise Retrieval and Path Handling Logic
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m23s
- Introduced new functions for handling exercise visibility and retrieval based on progression graph context, including `fetch_exercise_rows_by_ids_for_graph`.
- Updated `_load_supplemental_exercise_rows` to incorporate graph visibility rules, improving the accuracy of exercise retrieval.
- Enhanced `_run_path_step_retrieval` to utilize preloaded supplemental exercise rows, optimizing performance and clarity in path step processing.
- Added `exercise_title_equivalent_to_stage_goal` function to improve title matching against learning goals, enhancing exercise relevance.
- Updated tests to validate new retrieval logic and title equivalence functionality, ensuring robustness in exercise selection processes.
2026-06-11 12:33:02 +02:00
ad051c015f Enhance Progression Path Suggestion with Retrieval Boost and Slot Assignment Logic
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
- Added `preserve_slot_assignments` and `retrieval_boost_exercise_ids` to `ProgressionPathSuggestRequest` for improved handling of exercise suggestions.
- Refactored `_supplemental_exercise_ids_from_body` to incorporate retrieval boost exercise IDs, ensuring they are prioritized over slot assignments.
- Updated `_build_steps_roadmap_first` to conditionally preserve slot assignments based on the new flag.
- Enhanced tests to validate the new retrieval boost logic and its integration with existing slot assignment handling.
2026-06-11 12:20:41 +02:00
b464047c3a Enhance Exercise Progression Graph Functionality and Visibility Logic
All checks were successful
Deploy Development / deploy (push) Successful in 50s
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 33s
Test Suite / playwright-tests (push) Successful in 1m16s
- Introduced new functions for handling exercise visibility in progression graphs, including `library_content_visibility_for_progression_graph_sql` to manage visibility based on graph context.
- Added `_supplemental_exercise_ids_from_body` to extract exercise IDs from request bodies, improving data handling in path suggestions.
- Implemented visibility promotion candidate retrieval in the API, allowing for the identification of private exercises that need visibility adjustments when promoting graph visibility.
- Enhanced existing SQL queries and retrieval functions to incorporate new visibility logic, ensuring accurate exercise visibility based on user roles and graph settings.
- Updated frontend components to support visibility promotion workflows, including user prompts for managing private exercises during graph visibility changes.
- Added tests to validate new visibility logic and ensure robustness in exercise retrieval and promotion processes.
2026-06-11 12:10:46 +02:00
7203c871fc Add Slot Assignments and Enhance Path Handling Logic
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
- Introduced `slot_assignments` to `ProgressionPathSuggestRequest` for improved handling of existing slot assignments in path building.
- Implemented `_slot_assignments_by_major_index` and `_path_step_from_slot_assignment` functions to facilitate the integration of slot assignments into the path generation process.
- Updated `_build_steps_roadmap_first` to utilize slot assignments, enhancing the accuracy of path steps based on existing exercise slots.
- Enhanced `detect_path_gaps` to skip empty slots, preventing unnecessary errors during gap detection.
- Added tests to validate the new slot assignment handling and ensure robustness in path generation logic.
2026-06-11 12:02:04 +02:00
480890d0c6 Update Dockerfile and requirements for improved dependency management
All checks were successful
Deploy Development / deploy (push) Successful in 42s
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 1m18s
- Added `tzdata` installation in the Dockerfile to support time zone handling in Linux environments.
- Increased `PIP_DEFAULT_TIMEOUT` and added retry logic for pip installations to enhance reliability during dependency installation.
- Updated `requirements.txt` to conditionally include `tzdata` for Windows platforms, ensuring compatibility across different operating systems.
2026-06-11 11:48:25 +02:00
8f1dad53ab Enhance Progression Path Suggestion Logic and UI Feedback
All checks were successful
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
Deploy Development / deploy (push) Successful in 44s
- Updated `suggest_progression_path` to include AI-generated gap fill offers when exercises are missing, improving the relevance of suggested paths.
- Introduced a match summary to provide insights on library matches and gap fill offers, enhancing user feedback in the `ProgressionGraphEditor`.
- Refined the `pick_best_path_hit` function to ensure proper handling of roadmap stage matches based on primary topics.
- Added tests to validate the new gap fill offer logic and match summary functionality, ensuring robustness in path suggestion features.
2026-06-11 11:17:53 +02:00
044ce2ee60 Implement Primary Topic Resolution in Path Logic
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s
- Introduced `resolve_path_primary_topic` function to enhance the determination of primary topics from goal queries and semantic briefs, improving exercise relevance.
- Updated `_match_roadmap_slot` and `detect_off_topic_steps` functions to utilize the new primary topic resolution logic, ensuring accurate topic identification.
- Enhanced tests to validate the functionality of primary topic resolution and its impact on exercise selection and off-topic detection.
- Improved handling of primary topics in the `ExerciseProgressionPathBuilder` and related components for better integration with the overall path-building process.
2026-06-11 11:06:38 +02:00
f63b09fc9c Refine Technique Path Scope Logic and Enhance Test Coverage
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m24s
- Updated the `exercise_passes_technique_path_scope` function to clarify the requirements for technique inclusion, ensuring that the primary technique must appear in the exercise text.
- Enhanced the logic to allow for relaxed matching based on parts of the primary topic, improving flexibility in exercise validation.
- Added new tests to validate the rejection of off-topic exercises, specifically addressing cases where only stage goals mention the primary technique.
- Improved the selection logic in `pick_best_path_hit` to ensure proper handling of roadmap stage matches.
2026-06-11 10:48:36 +02:00
713a344d17 Enhance Roadmap Step Handling and Off-Topic Logic
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 52s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 41s
Test Suite / playwright-tests (push) Successful in 1m51s
- Improved off-topic step handling by incorporating roadmap major step indices for better indexing and detection.
- Refactored `collect_gap_fill_specs` to streamline the insertion logic for off-topic steps, ensuring correct placement based on major step indices.
- Introduced `_normalize_roadmap_steps_coverage` function to standardize roadmap steps coverage, enhancing the handling of missing slots.
- Added `prune_stripped_after_rematch` function to clean up stripped off-topic steps after rematching, improving the overall rematching process.
- Updated tests to validate new rematching and off-topic handling features, ensuring robustness against edge cases.
- Incremented application version to reflect these updates.
2026-06-11 10:40:25 +02:00
1d94c2ebf1 Enhance Roadmap Slot Matching and Off-Topic Detection
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 36s
Test Suite / playwright-tests (push) Successful in 1m23s
- Introduced `auto_rematch_after_qa` parameter in `ProgressionPathSuggestRequest` to enable automatic rematching after quality assurance checks.
- Refactored roadmap slot matching logic to improve clarity and functionality, renaming `_build_steps_roadmap_first` to `_match_roadmap_slot`.
- Added `_with_roadmap_major_index` utility to streamline off-topic step detection by incorporating roadmap major step indices.
- Enhanced off-topic detection logic to utilize the new utility for improved clarity in identifying mismatches and exclusions.
- Incremented application version to reflect these updates.
2026-06-11 10:30:48 +02:00
a152218c45 Enhance Path QA and Stage Matching Logic
All checks were successful
Deploy Development / deploy (push) Successful in 43s
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 1m14s
- Introduced multistage path quality assurance (QA) functionality to improve exercise relevance and feedback through structured tiers and optimization hints.
- Updated stage specifications to include `start_state` and `target_state` for better contextualization in roadmap matching.
- Enhanced semantic brief construction with technique sibling exclusions to refine exercise selection based on primary topics.
- Improved path retrieval logic to incorporate new parameters for nuanced matching against learning goals.
- Incremented application version to reflect these updates.
2026-06-11 10:19:58 +02:00
4ef3f00e6b Enhance Planning Intent Context and Stage Specification Finalization
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
- Introduced `intent_context` and `semantic_brief` parameters in `try_llm_stage_specs` to improve context handling for stage specifications.
- Updated `build_goal_analysis` to extract explicit exclusions from goal queries, enhancing constraint management.
- Enhanced `roadmap_context_from_override` to enrich semantic briefs with path constraints and finalize stage specifications with intent context.
- Incremented application version to reflect these updates.
2026-06-11 08:47:26 +02:00
3c12363b8f Enhance Path Exclusion Logic and Semantic Brief Enrichment
Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Has been cancelled
- Introduced `resolve_path_anti_patterns` to improve handling of path exclusions based on explicit negations and semantic briefs.
- Updated `enrich_brief_with_path_constraints` to incorporate path-specific exclusions into semantic briefs, enhancing exercise relevance.
- Modified roadmap step annotation to allow for anti-pattern overrides, improving flexibility in exercise selection.
- Enhanced tests to validate new path exclusion features and ensure correct functionality against learning goals.
- Incremented application version to reflect these updates.
2026-06-11 08:43:59 +02:00
07e147bc76 Enhance Stage Matching and Retrieval Logic in Planning Exercise
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m16s
- Introduced `build_stage_match_brief` to create stage-specific semantic briefs, improving roadmap matching accuracy.
- Updated path retrieval logic to differentiate between general and stage-specific semantic weights, enhancing exercise relevance.
- Added support for anti-patterns and success criteria in stage matching, allowing for more nuanced exercise selection.
- Enhanced tests to validate new stage matching features and ensure correct functionality against learning goals.
- Incremented application version to reflect these updates.
2026-06-10 17:02:21 +02:00
18547613ea Implement Stage Learning Goal Features in Planning Exercise
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
- Added `semantic_brief_for_stage` function to enhance semantic briefs with stage learning goals for improved roadmap matching.
- Introduced `exercise_passes_stage_learning_goal_gate` to validate exercises against stage learning goals, enhancing relevance checks.
- Updated path retrieval and scoring logic to incorporate stage learning goals, allowing for more nuanced exercise selection.
- Enhanced UI to indicate weak matches with stage learning goals, improving user feedback on exercise relevance.
- Incremented application version to reflect these updates.
2026-06-10 16:39:17 +02:00
48d51c07c5 Enhance Exercise Progression Graph Panel and Editor with New Features
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m26s
- Refactored `ExerciseProgressionGraphPanel` to support a create dialog for new progression graphs, improving user experience.
- Integrated `ProgressionGraphListCard` for better visualization of existing graphs and streamlined management.
- Updated `ProgressionGraphEditor` to handle start/target analysis and improved draft hydration with AI suggestions.
- Added utility functions for managing structured responses from AI, enhancing the planning process.
- Incremented application version to reflect these updates.
2026-06-10 16:17:40 +02:00
3b483346de Enhance Progression Graph Editor with Skills Catalog and AI Draft Handling
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m21s
- Introduced skills catalog management in the `ProgressionGraphEditor`, allowing for improved context in AI suggestions.
- Updated the loading mechanism to fetch both focus areas and skills catalog concurrently, enhancing performance.
- Implemented `ensureQuickCreateDraftFromAiSuggestion` utility to streamline the creation of drafts from AI suggestions.
- Enhanced slot management by integrating AI context into the gap fill preparation process, improving user experience.
- Incremented application version to reflect these updates.
2026-06-10 16:04:15 +02:00
e0ddfa6ce5 Add AI Suggestion Handling for Roadmap Gaps and Enhance Progression Graph Components
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
- Implemented functions to resolve neighboring steps based on major indices and build AI context for unfilled roadmap stages.
- Enhanced `try_suggest_ai_stage_step` to generate AI proposals for empty roadmap stages, improving user experience in gap filling.
- Updated `build_gap_fill_offer` to utilize major step neighbors for better context in offers related to unfilled slots.
- Added tests to ensure correct functionality of AI suggestion handling in the context of roadmap gaps.
- Incremented application version to reflect these updates.
2026-06-10 15:56:30 +02:00
ee22b22970 Refactor Progression Graph Components and Consolidate UI
All checks were successful
Deploy Development / deploy (push) Successful in 47s
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 1m15s
- Updated the `ProgressionGraphSlotEditorSpec.md` to reflect UI consolidation, removing separate editors and integrating functionalities into `ExerciseProgressionGraphPanel`.
- Refactored `ExerciseProgressionGraphPanel` to streamline the editing experience, removing unused state and logic for better performance.
- Enhanced `ProgressionGraphEditor` to support embedded usage and trigger callbacks on save, improving integration with other components.
- Simplified `ProgressionGraphEditPage` to redirect users to the exercises list with deep-linking support for selected graphs.
- Incremented application version to reflect these updates.
2026-06-10 15:42:29 +02:00
c1bf9279ad Add Gap Offer Handling and UI Enhancements in Progression Graph Components
All checks were successful
Deploy Development / deploy (push) Successful in 46s
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 1m18s
- Implemented `_build_evaluate_empty_slot_gap_specs` function to generate gap offer specifications for unfilled roadmap slots in evaluate-only mode.
- Enhanced `ProgressionFindingsPanel` to display AI offers for empty slots and gaps, improving user interaction and clarity.
- Updated `ProgressionGraphEditor` and `ProgressionSlotCard` components to support new functionalities for managing slots and offers.
- Refactored utility functions in `progressionGraphDraft.js` to streamline slot management and offer handling.
- Incremented application version to reflect these updates.
2026-06-10 15:34:37 +02:00
97efe66306 Implement EvaluateStepPayload and SlotContentEntry for Enhanced Planning Features
Some checks failed
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Failing after 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m21s
- Introduced EvaluateStepPayload class to facilitate evaluation of exercise steps with optional attributes for AI proposals and roadmap details.
- Added SlotContentEntry and SlotExerciseContent classes to manage exercise content within the progression graph planning artifact.
- Updated GraphPlanningRoadmapArtifact to include new slot contents and last findings attributes for improved data handling.
- Enhanced Exercise Progression Graph Panel with links to the new Slot Editor for streamlined editing of progression graphs.
- Incremented application version to reflect these updates.
2026-06-10 13:05:49 +02:00
8d5f0b533c Enhance Exercise Progression Graph Panel and Path Builder with New Features
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m31s
- Introduced a primary chain selection in the Exercise Progression Graph Panel to streamline exercise path management.
- Updated the ProgressionChainEditor to support single path mode, allowing users to manage a single progression path more effectively.
- Enhanced the ExerciseProgressionPathBuilder with improved logic for merging graph nodes into path steps and filtering gap offers.
- Updated UI elements for better clarity and user experience, including new notifications and styling adjustments.
- Incremented application version to reflect these updates.
2026-06-10 11:17:05 +02:00
800189ff8f Enhance Exercise Progression Graph Panel and Path Builder with New Features
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m16s
- Added `ProgressionChainEditor` to the Exercise Progression Graph Panel for improved management of exercise chains.
- Refactored state management to utilize `useRef` for chain editor references and removed unused sequence step logic.
- Introduced a path insert notice in the Exercise Progression Path Builder to inform users about unsaved changes.
- Updated UI elements to enhance clarity regarding the status of paths before saving.
- Incremented application version to reflect these updates.
2026-06-10 07:55:51 +02:00
3be7606d90 Update documentation and enhance planning features in Progression Graph
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m25s
- Updated the `CLAUDE.md` to reflect changes in the Progression Graph, including the new Ist-Stand and roadmap specifications.
- Enhanced `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` with detailed descriptions of the current state and features of the planning exercise.
- Revised `PLANNING_PROGRESSION_ROADMAP_SPEC.md` to document the implementation status of various phases and their corresponding migrations.
- Incremented application version to 0.8.217 to incorporate recent updates and improvements in the planning context and roadmap functionalities.
2026-06-10 07:50:29 +02:00
ca3a9c6fa4 Enhance Exercise Progression Path Builder with Planning Wizard Stepper
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
- Introduced a new Planning Wizard Stepper component to guide users through the exercise planning process in four steps.
- Implemented logic to compute the maximum reachable step based on user input and current progress.
- Updated state management to track the current wizard step and ensure it aligns with user interactions.
- Enhanced the user interface to improve clarity and navigation through the planning stages.
- Incremented application version to reflect these changes.
2026-06-10 07:30:01 +02:00
5692931d07 Update version to 0.8.217 and enhance Exercise Progression Path Builder with planning roadmap features
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s
- Incremented application version to 0.8.217 to reflect recent changes.
- Added support for a planning roadmap in the Exercise Progression Path Builder, allowing users to save and load structured planning artifacts.
- Enhanced the persistence logic for the planning roadmap, ensuring updates are correctly handled during graph modifications.
- Improved the user interface to display saved planning hints, enriching the user experience and interaction with the progression graphs.
2026-06-10 07:25:57 +02:00
98b279fa89 Update version to 0.8.216 and enhance Exercise Progression Path Builder
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
- Incremented application version to 0.8.216 to reflect recent changes.
- Added skill expectations handling in the Exercise Progression Path Builder, improving the integration of expected skills into the roadmap steps.
- Enhanced the mapping of major steps to include load profiles, success criteria, anti-patterns, and exercise types, enriching the user experience and functionality.
2026-06-10 07:14:18 +02:00
1e7941f57b Enhance Gap Fill Goal Text and Skill Expectations Integration
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m22s
- Updated `build_gap_fill_goal_text` to include expected skills in the generated text, improving clarity for users.
- Enhanced `_roadmap_gap_snapshot_for_spec` to incorporate skill expectations from the progression stage, enriching the roadmap context.
- Modified `_annotate_roadmap_step` to append skill expectations to the step reasons, providing additional insights.
- Updated tests to verify the inclusion of expected skills in the gap fill goal text.
- Incremented application version to 0.8.215 to reflect these changes.
2026-06-10 07:09:46 +02:00
0adf20c9e1 Enhance Gap Planning Context with Stage Overrides and Trainer Supplements
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m25s
- Added `stage_learning_goal_override` and `gap_trainer_supplements` parameters to `build_progression_path_gap_planning_context`, allowing for customized learning goals and additional trainer notes.
- Updated `gapOfferContextDisplayLines` to include trainer supplements in the context display.
- Enhanced `ExerciseProgressionPathBuilder` to utilize new parameters for improved gap fill offer handling.
- Incremented application version to 0.8.214 to reflect these changes.
2026-06-10 06:54:49 +02:00
55 changed files with 11914 additions and 1298 deletions

View File

@ -493,23 +493,37 @@ Nach Pfad-Bildung:
---
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204+) 🔄
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204217) ✅
**Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung.
**Ist-Stand (vollständig):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
**Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md`
| Teil | Modul / API |
|------|-------------|
| Pipeline | `planning_progression_roadmap.py` (Workflow-lite) |
| API | `include_roadmap_preview`, `include_llm_roadmap`, `roadmap_first` auf `progression-path-suggest` |
| Prompts | Migration **078/079** — Slugs in `ai_prompts` (Admin), **kein** Template im Python-Code |
| UI | `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) |
| Match | `planning_exercise_path_builder.py``roadmap_first`, `roadmap_override` |
| Skills | `planning_skill_expectations.py` — pro Stufe + Pfad |
| Gap-KI | `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py` |
| Persistenz | `planning_roadmap` JSONB (Migration **088**) |
| API | `progression-path-suggest`, `PUT` Graph, `POST …/edges/sequence` |
| Prompts | **078/079/087** — Slugs nur in `ai_prompts` |
| UI | `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal` |
**F3 (0.8.206):** `roadmap_first=true` (Default im UI) — Retrieval pro `stage_spec`/Major Step; `roadmap_unfilled` Gap-Angebote. Ohne Flag: retrieval-first wie bisher, Roadmap nur Preview.
**Graph-Bias:** `progression_graph_id` bevorzugt **bestehende Nachfolger** ab Schritt 2 (Gewicht ~410 %), baut aber **keinen** Pfad aus vorhandenen Knoten — siehe Ist-Doku §5.
**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung.
---
## 25. Backlog (offen)
Siehe priorisierte Liste in **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** §10:
1. UI-Wizard (Progressionsgraph) — separater Chat
2. Graph-Erweiterungsmodus (Start ab Knoten)
3. Trainingsplanung Phase G (Gruppenkontext, `planning_skill_expectations`)
4. Kontext auf allen Pfad-Schritten in der UI
5. Enrichment / Prompt-Feintuning
6. Mitai Workflow-Engine (langfristig)

View File

@ -2,9 +2,11 @@
**Version:** 0.1
**Datum:** 2026-06-07
**Status:** VERBINDLICHE ZIELARCHITEKTUR — Umsetzung gestartet (0.8.204+)
**Status:** VERBINDLICHE ZIELARCHITEKTUR — **F0F9 umgesetzt** (0.8.217)
**Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse
**Ist-Stand (Module, API, Graph-Verhalten, Persistenz):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
**Bezüge:**
`working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` · `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `technical/AI_PROMPT_TARGET_ARCHITECTURE.md` · `docs/architecture/PLANNING_KI_ROADMAP.md` · `docs/HANDOVER.md`
@ -152,6 +154,7 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-
| Slug | Phase | Migration |
|------|-------|-----------|
| `planning_progression_start_target` | Start/Ziel | **087** |
| `planning_progression_goal_analysis` | A | **078** |
| `planning_progression_roadmap` | B | **078** |
| `planning_progression_stage_spec` | C | **079** |
@ -184,15 +187,23 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-
| ID | Inhalt | Status |
|----|--------|--------|
| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | 🔄 0.8.204 |
| **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 |
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 |
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 |
| **F4** | UI Roadmap-Review | ✅ 0.8.207 |
| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 |
| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | ✅ 0.8.204 |
| **F1** | `include_roadmap_preview` in API + deterministische A/B | ✅ 0.8.204 |
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | ✅ 0.8.205 |
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206209 |
| **F4** | UI Roadmap-Review + `roadmap_override` | ✅ 0.8.207 |
| **F5** | Start/Ziel strukturiert + Prompt **087** + Zwei-Schritt-UI | ✅ 0.8.210214 |
| **F6** | Gap-Prep + `planning_context` an Übungs-KI | ✅ 0.8.212214 |
| **F7** | `planning_skill_expectations` | ✅ 0.8.215216 |
| **F8** | Editierbare `stage_specs` in UI | ✅ 0.8.216 |
| **F9** | `planning_roadmap` JSONB (Migration **088**) | ✅ 0.8.217 |
| **G** | Trainingsplanung: eigene Pipeline + Workflow-Engine | 🔲 |
Details: `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
---
## 9. Changelog
- **2026-05-22:** Ist-Stand F5F9 dokumentiert; Verweis auf `PLANNING_PROGRESSION_GRAPH_KI.md`.
- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite.

View File

@ -0,0 +1,81 @@
# Progressionsgraph — Slot-Editor (Phase B)
**Stand:** 2026-06-10 · **Status:** In Umsetzung
## Ziel
Ein Progressionsgraph = **ein linearer Hauptpfad** (Roadmap = strukturgebend). Jeder **Major Step** ist ein **Slot** mit:
- **primary** — Hauptübung des Slots (Pfadknoten)
- **siblings** — 0..n Schwestern (gleiche Stufe, `edge_type: sibling`)
KI-Entwürfe und Bibliotheksübungen leben **im selben Slot-Modell**, ohne sofortige Übungsanlage.
## Slot-Zustände (`kind`)
| kind | Bedeutung |
|------|-----------|
| `empty` | Noch keine Übung |
| `library` | `exercise_id` (+ optional `variant_id`) |
| `proposal` | KI-Entwurf (`ai_suggestion`, kein `exercise_id`) |
## Kanten
- `primary(n) → primary(n+1)``next_exercise` (nur befüllte Primärkette, lückenlos verbunden)
- `primary ↔ sibling``sibling` (pro Slot)
Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgenden befüllten Primär-Slots.
## Editor-Zustand (`ProgressionGraphDraft`)
```ts
{
goalQuery, startSituation, targetState, roadmapNotes, maxSteps,
majorSteps: MajorStep[],
slots: Slot[], // index = major_step_index
pathSkillExpectations?,
lastFindings?, // path_qa-Snapshot
dirty: boolean,
}
```
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`.
## Findings-Panel
Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …).
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
Persistenz: `planning_roadmap.last_findings`.
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
Zusätzlich optional:
- `slot_contents[]``{ major_step_index, primary, siblings[] }`
- `last_findings` — letzter `path_qa`-Snapshot
## UI (konsolidiert)
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
## Ersetzt (Legacy, nicht mehr im Panel)
- `ExerciseProgressionPathBuilder` · `ProgressionChainEditor` — Code bleibt vorerst, nicht eingebunden
## Implementierungsreihenfolge
| ID | Inhalt |
|----|--------|
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten |
| B.1 | Slot-Karten, Bibliothek + Entwurf |
| B.2 | Findings-Panel + `evaluate_only` |
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ |
| B.4 | Route + Panel vereinfachen |
| B.5 | `last_findings` + Phase-C-Vorbereitung |

View File

@ -18,7 +18,7 @@
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
> | Planungs-KI Progressions-Roadmap (Phase F) | **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
> | Planungs-KI Progressionsgraph (Ist-Stand) | **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** · Spec **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
## Projekt-Übersicht

View File

@ -2,14 +2,16 @@ FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
# Install system dependencies (tzdata für zoneinfo/ZoneInfo unter Linux)
RUN apt-get update && apt-get install -y \
postgresql-client \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ENV PIP_DEFAULT_TIMEOUT=120
RUN pip install --no-cache-dir --retries 5 -r requirements.txt
# Copy application code
COPY . .

View File

@ -0,0 +1,8 @@
-- Migration 088: Planungs-Roadmap-Artefakt am Progressionsgraph (JSONB, optional).
-- Speichert Ziel, Start/Ziel, progression_roadmap + stage_specs für Wiederaufnahme der KI-Planung.
ALTER TABLE exercise_progression_graphs
ADD COLUMN IF NOT EXISTS planning_roadmap JSONB;
COMMENT ON COLUMN exercise_progression_graphs.planning_roadmap IS
'Optionales Planungs-Artefakt (goal_query, resolved_structured, progression_roadmap, stage_specs) — Schema v1 im App-Code.';

View File

@ -0,0 +1,67 @@
-- Migration 089: Planungs-Intent — Zielanalyse + Stufenspecs (anti_patterns, success_criteria)
UPDATE ai_prompts SET
description = 'Phase A: Ist-/Soll, Erfolgskriterien und explizite Ausschlüsse (ohne Gruppenkontext).',
template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Wichtig:
- Keine Gruppenanalyse nur didaktischer Pfad für die Technik/das Thema.
- Explizite Negationen aus der Anfrage (ohne/kein/nicht ) in constraints.excluded_themes übernehmen nicht raten.
- success_criteria: messbar, für späteres Übungs-Matching (Titel + Kurzbeschreibung + Übungsziel).
Antworte NUR mit JSON:
{
"primary_topic": "Hauptthema",
"start_assumption": "Voraussetzungen für den Einstieg",
"target_state": "Konkreter Zielzustand der Progression",
"success_criteria": ["messbare Kriterien entlang des Pfads"],
"constraints": {
"partner_required": false,
"excluded_themes": ["wörtliche Negationen, z. B. keine Kumite-Anwendung"],
"trainer_notes": "optional: Fokus aus Ergänzungen"
}
}$t$,
default_template = template
WHERE slug = 'planning_progression_goal_analysis';
UPDATE ai_prompts SET
description = 'Phase C: Belastung, Übungstyp, Erfolgskriterien und anti_patterns je Major Step — für Retrieval-Matching.',
template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Major Steps: {{major_steps_json}}
Planungs-Intent (Pfadweite Regeln): {{intent_context_json}}
Semantic Brief: {{semantic_brief_json}}
Aufgabe je Major Step Felder für automatisches Übungs-Matching (nicht nur Titel):
- learning_goal: messbares Stufen-Lernziel (was die Übung bringen soll)
- load_profile: z. B. koordination, präzision, kraft, athletik
- exercise_type: kihon_einzel | partner_drill | kombination | kraft_auxiliary
- success_criteria: 24 prüfbare Kriterien an Kurzbeschreibung + Übungsziel (nicht nur Technikname im Titel)
- anti_patterns: 25 Dinge, die für diese Stufe unpassend sind
Regeln:
1. Jede explicit_exclusions / excluded_themes aus intent_context und Zielanalyse MUSS in anti_patterns jeder Stufe vorkommen (umformuliert ok).
2. Keine neuen Ausschlüsse erfinden, die nicht in Anfrage/Intent/Zielanalyse stehen.
3. success_criteria Pfad-weit + stufenspezifisch kombinieren.
4. partner_drill nur wenn Partner/Kumite nicht ausgeschlossen ist.
Antworte NUR mit JSON:
{
"stage_specs": [
{
"major_step_index": 0,
"learning_goal": "",
"load_profile": ["koordination"],
"exercise_type": "kihon_einzel",
"success_criteria": [""],
"anti_patterns": [""]
}
]
}$t$,
default_template = template
WHERE slug = 'planning_progression_stage_spec';

View File

@ -0,0 +1,43 @@
-- Migration 090: Stufenspecs — start_state / target_state pro Major Step (Soll-Verkettung)
UPDATE ai_prompts SET
description = 'Phase C: Stufenspezifikation inkl. Soll-Start und Stufen-Ziel je Major Step.',
template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Major Steps: {{major_steps_json}}
Planungs-Intent (Pfadweite Regeln): {{intent_context_json}}
Semantic Brief: {{semantic_brief_json}}
Jede Stufe ist ein Übergang im Gesamtpfad:
- start_state: Soll-Zustand zu Beginn (= Ziel der vorherigen Stufe; Stufe 0 = Pfad-Start)
- target_state: Zielzustand nach dieser Stufe (= Soll für die nächste Stufe)
- learning_goal: messbares Lernziel der Übungssuche (was die Übung bringen soll)
Felder je Major Step:
- load_profile, exercise_type, success_criteria, anti_patterns (wie bisher)
Regeln:
1. start_state/target_state aus Zielanalyse und Major Steps ableiten konsistente Kette.
2. explicit_exclusions aus intent_context in anti_patterns jeder Stufe.
3. success_criteria: prüfbar an Kurzbeschreibung + Übungsziel.
4. Keine erfundenen Ausschlüsse.
Antworte NUR mit JSON:
{
"stage_specs": [
{
"major_step_index": 0,
"start_state": "",
"target_state": "",
"learning_goal": "",
"load_profile": ["koordination"],
"exercise_type": "kihon_einzel",
"success_criteria": [""],
"anti_patterns": [""]
}
]
}$t$,
default_template = template
WHERE slug = 'planning_progression_stage_spec';

View File

@ -6,7 +6,7 @@ Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instruct
from __future__ import annotations
import json
from typing import Any, Dict, List, Mapping, Optional
from typing import Any, Dict, List, Mapping, Optional, Sequence
_MAX_JSON_CHARS = 6000
_MAX_STRING = 800
@ -85,6 +85,163 @@ def planning_context_prompt_variables(
}
def _major_index_from_step(step: Mapping[str, Any]) -> Optional[int]:
for key in ("roadmap_major_step_index", "major_step_index"):
raw = step.get(key)
if raw is None:
continue
try:
return int(raw)
except (TypeError, ValueError):
continue
return None
def prior_path_steps_before_major(
steps: Sequence[Mapping[str, Any]],
major_idx: int,
) -> List[Dict[str, Any]]:
"""Pfadschritte mit kleinerem roadmap_major_step_index, sortiert."""
prior: List[Dict[str, Any]] = []
for step in steps:
mi = _major_index_from_step(step)
if mi is not None and mi < major_idx:
prior.append(dict(step))
prior.sort(key=lambda s: _major_index_from_step(s) or 0)
return prior
def _step_display_fields(step: Mapping[str, Any]) -> Dict[str, Any]:
title = _trim_str(
step.get("title") or step.get("exercise_title"),
limit=200,
)
learning_goal = _trim_str(
step.get("roadmap_learning_goal") or step.get("learning_goal"),
limit=500,
)
summary = _trim_str(step.get("summary"), limit=400)
start_state = _trim_str(step.get("roadmap_start_state") or step.get("start_state"))
target_state = _trim_str(step.get("roadmap_target_state") or step.get("target_state"))
phase = _trim_str(step.get("roadmap_phase") or step.get("phase"))
criteria_raw = step.get("stage_success_criteria") or step.get("success_criteria") or []
criteria = [
t
for x in criteria_raw
if (t := _trim_str(x, limit=200))
][:4]
out: Dict[str, Any] = {
"title": title,
"learning_goal": learning_goal,
"summary": summary,
"start_state": start_state,
"target_state": target_state,
"phase": phase,
"success_criteria": criteria or None,
"major_step_index": _major_index_from_step(step),
}
return {k: v for k, v in out.items() if v is not None and v != "" and v != []}
def build_progression_entry_state(
*,
major_step_index: Optional[int] = None,
prior_steps: Sequence[Mapping[str, Any]] = (),
start_situation: Optional[str] = None,
current_stage_start: Optional[str] = None,
) -> Dict[str, Any]:
"""
Eingangszustand für eine Roadmap-Stufe: erreichte Voraussetzungen aus Vorstufen.
"""
prior_compact = [_step_display_fields(s) for s in prior_steps]
prior_compact = [
p
for p in prior_compact
if any(p.get(k) for k in ("title", "learning_goal", "summary", "success_criteria"))
]
achievements: List[str] = []
detail_lines: List[str] = []
for p in prior_compact:
if p.get("success_criteria"):
achievements.extend(p["success_criteria"])
elif p.get("learning_goal"):
achievements.append(p["learning_goal"])
label_parts: List[str] = []
if p.get("major_step_index") is not None:
label_parts.append(f"Stufe {int(p['major_step_index']) + 1}")
if p.get("phase"):
label_parts.append(f"({p['phase']})")
if p.get("title"):
label_parts.append(f"{p['title']}\"")
prefix = " ".join(label_parts) if label_parts else "Vorstufe"
achieved = ""
if p.get("target_state"):
achieved = p["target_state"]
elif p.get("success_criteria"):
achieved = "; ".join(p["success_criteria"])
elif p.get("learning_goal"):
achieved = p["learning_goal"]
elif p.get("summary"):
achieved = p["summary"]
if achieved:
detail_lines.append(f"{prefix}: erreicht — {achieved}")
immediate_entry: Optional[str] = _trim_str(current_stage_start)
if not immediate_entry and prior_compact:
immediate = prior_compact[-1]
if immediate.get("target_state"):
immediate_entry = immediate["target_state"]
elif immediate.get("success_criteria"):
immediate_entry = "; ".join(immediate["success_criteria"])
elif immediate.get("learning_goal"):
immediate_entry = immediate["learning_goal"]
elif immediate.get("summary"):
immediate_entry = immediate["summary"]
elif not immediate_entry and start_situation:
immediate_entry = start_situation
entry_state = immediate_entry or start_situation
if prior_compact and start_situation and not immediate_entry:
detail_lines.insert(0, f"Ausgangsbasis Pfad: {start_situation}")
out: Dict[str, Any] = {}
if entry_state:
out["entry_state"] = _trim_str(entry_state, limit=1200)
if detail_lines:
out["entry_state_detail"] = _trim_str("\n".join(detail_lines), limit=2000)
if prior_compact:
out["prior_steps"] = prior_compact[:6]
if achievements:
out["prior_achievements"] = list(dict.fromkeys(achievements))[:8]
return out
def enrich_gap_snapshot_with_entry_state(
snapshot: Mapping[str, Any],
*,
steps: Sequence[Mapping[str, Any]],
major_step_index: Optional[int],
) -> Dict[str, Any]:
snap = dict(snapshot)
if major_step_index is None:
return snap
try:
mi = int(major_step_index)
except (TypeError, ValueError):
return snap
prior = prior_path_steps_before_major(steps, mi)
entry = build_progression_entry_state(
major_step_index=mi,
prior_steps=prior,
start_situation=snap.get("start_situation"),
current_stage_start=snap.get("stage_start_state"),
)
snap.update(entry)
return snap
def build_progression_gap_snapshot(
*,
goal_analysis: Optional[Mapping[str, Any]] = None,
@ -141,6 +298,8 @@ def build_progression_gap_snapshot(
"stage_learning_goal": _trim_str(
spec.get("learning_goal"), limit=1200
),
"stage_start_state": _trim_str(spec.get("start_state")),
"stage_target_state": _trim_str(spec.get("target_state")),
"stage_phase": _trim_str(spec.get("phase")),
"stage_exercise_type": _trim_str(spec.get("exercise_type")),
"stage_load_profile": load_profile or None,
@ -160,6 +319,7 @@ def build_progression_path_gap_planning_context(
offer: Optional[Mapping[str, Any]] = None,
neighbor_before: Optional[Mapping[str, Any]] = None,
neighbor_after: Optional[Mapping[str, Any]] = None,
prior_path_steps: Optional[Sequence[Mapping[str, Any]]] = None,
path_step_count: int = 0,
major_step_count: Optional[int] = None,
roadmap_phase: Optional[str] = None,
@ -168,6 +328,8 @@ def build_progression_path_gap_planning_context(
resolved_structured: Optional[Mapping[str, Any]] = None,
stage_spec: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[Mapping[str, Any]] = None,
stage_learning_goal_override: Optional[str] = None,
gap_trainer_supplements: Optional[str] = None,
) -> Dict[str, Any]:
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
offer = offer or {}
@ -205,12 +367,28 @@ def build_progression_path_gap_planning_context(
semantic_brief=semantic_brief,
)
ctx.update(snap)
if major_idx is not None and prior_path_steps:
ctx.update(
build_progression_entry_state(
major_step_index=major_idx,
prior_steps=list(prior_path_steps),
start_situation=ctx.get("start_situation"),
)
)
if stage_learning_goal_override and stage_learning_goal_override.strip():
ctx["stage_learning_goal"] = _trim_str(stage_learning_goal_override, limit=1200)
ctx["roadmap_learning_goal"] = ctx["stage_learning_goal"]
if gap_trainer_supplements and gap_trainer_supplements.strip():
ctx["gap_trainer_supplements"] = _trim_str(gap_trainer_supplements, limit=2000)
return sanitize_planning_context_for_ai(ctx)
__all__ = [
"build_progression_entry_state",
"build_progression_gap_snapshot",
"build_progression_path_gap_planning_context",
"enrich_gap_snapshot_with_entry_state",
"prior_path_steps_before_major",
"compact_planning_context_json",
"planning_context_prompt_variables",
"sanitize_planning_context_for_ai",

View File

@ -12,12 +12,174 @@ from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_path_qa import find_step_pair_index
from planning_exercise_form_context import build_progression_gap_snapshot
from planning_exercise_form_context import (
build_progression_entry_state,
build_progression_gap_snapshot,
enrich_gap_snapshot_with_entry_state,
prior_path_steps_before_major,
)
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
def _resolve_neighbor_steps_by_major_index(
steps: Sequence[Mapping[str, Any]],
major_idx: int,
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
"""Nachbarn im Pfad anhand roadmap_major_step_index (nicht Array-Position)."""
step_before: Optional[Mapping[str, Any]] = None
step_after: Optional[Mapping[str, Any]] = None
for step in steps:
raw = step.get("roadmap_major_step_index")
if raw is None:
continue
try:
mi = int(raw)
except (TypeError, ValueError):
continue
if mi < major_idx:
step_before = step
elif mi > major_idx and step_after is None:
step_after = step
return step_before, step_after
def _build_stage_ai_context(
*,
goal_query: str,
brief: PlanningSemanticBrief,
spec: Mapping[str, Any],
step_before: Optional[Mapping[str, Any]] = None,
step_after: Optional[Mapping[str, Any]] = None,
prior_steps: Optional[Sequence[Mapping[str, Any]]] = None,
start_situation: Optional[str] = None,
) -> ExerciseFormAiPromptContext:
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
gap = dict(spec.get("gap") or {})
phase = spec.get("phase") or gap.get("expected_phase") or "vertiefung"
topic = (brief.primary_topic or "Technik").strip()
learning_goal = (
gap.get("learning_goal")
or spec.get("title_hint")
or spec.get("sketch")
or ""
).strip()
title = (spec.get("title_hint") or f"{topic}{phase}").strip()[:280]
major_idx = spec.get("roadmap_major_step_index")
entry: Dict[str, Any] = {}
if prior_steps is not None and major_idx is not None:
entry = build_progression_entry_state(
major_step_index=major_idx,
prior_steps=prior_steps,
start_situation=start_situation,
)
goal_parts = [
f"Planungsziel: {goal_query}",
f"Roadmap-Stufe ({phase}): {learning_goal}",
"Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.",
]
if entry.get("entry_state"):
goal_parts.append(
f"Eingangszustand (erreichte Voraussetzungen): {entry['entry_state']}"
)
if entry.get("entry_state_detail") and entry.get("entry_state_detail") != entry.get("entry_state"):
goal_parts.append(f"Bisheriger Pfad:\n{entry['entry_state_detail']}")
if step_before:
goal_parts.append(
f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}"
)
if step_after:
goal_parts.append(
f"Nächste Stufe im Pfad: „{(step_after.get('title') or '').strip()}"
)
sketch = (spec.get("sketch") or "").strip()
if sketch and sketch != learning_goal:
goal_parts.extend(["", f"Kontext: {sketch}"])
goal = "\n".join(goal_parts)
focus_hint = topic if brief.topic_type == "technique" else None
if brief.must_phrases:
focus_hint = ", ".join(brief.must_phrases[:2])
return ExerciseFormAiPromptContext(
title=title[:280],
goal=goal[:8000],
execution=None,
focus_hint=focus_hint,
)
def try_suggest_ai_stage_step(
cur,
*,
goal_query: str,
brief: PlanningSemanticBrief,
spec: Mapping[str, Any],
steps: Sequence[Mapping[str, Any]],
) -> Optional[Dict[str, Any]]:
"""KI-Vorschlag für leere Roadmap-Stufe."""
major_idx = spec.get("roadmap_major_step_index")
if major_idx is None:
return None
try:
mi = int(major_idx)
except (TypeError, ValueError):
return None
step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi)
prior_steps = prior_path_steps_before_major(steps, mi)
gap = dict(spec.get("gap") or {})
if not gap.get("expected_phase"):
gap["expected_phase"] = spec.get("phase") or "vertiefung"
gap["roadmap_major_step_index"] = mi
if not gap.get("learning_goal"):
gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
ctx = _build_stage_ai_context(
goal_query=goal_query,
brief=brief,
spec=spec,
step_before=step_before,
step_after=step_after,
prior_steps=prior_steps,
)
try:
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
except Exception:
_logger.exception("roadmap_unfilled AI suggest failed")
return None
if not ai_payload:
return None
summary_text = ""
summary_obj = ai_payload.get("summary")
if isinstance(summary_obj, dict):
summary_text = str(summary_obj.get("text") or "").strip()
elif isinstance(summary_obj, str):
summary_text = summary_obj.strip()
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
title = (ctx.title or spec.get("title_hint") or "KI-Vorschlag").strip()
return {
"exercise_id": None,
"proposal_key": proposal_key,
"variant_id": None,
"title": title,
"summary": summary_text or None,
"score": None,
"semantic_score": None,
"reasons": ["KI-Neuanlage für Roadmap-Stufe ohne Bibliothekstreffer"],
"variants": [],
"is_bridge": False,
"is_ai_proposal": True,
"ai_suggestion": dict(ai_payload),
"roadmap_major_step_index": mi,
"roadmap_phase": gap.get("expected_phase"),
"roadmap_learning_goal": gap.get("learning_goal"),
}
def _build_gap_ai_context(
*,
goal_query: str,
@ -175,6 +337,18 @@ def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
)
def _step_neighbors_at_index(
steps: Sequence[Mapping[str, Any]],
idx: int,
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
"""Vorheriger/nächster Pfadschritt ohne IndexError (Rand-Slots, leere Stufen)."""
if idx < 0 or idx >= len(steps):
return None, None
step_a = steps[idx - 1] if idx > 0 else None
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
return step_a, step_b
def collect_gap_fill_specs(
*,
steps: Sequence[Mapping[str, Any]],
@ -202,8 +376,10 @@ def collect_gap_fill_specs(
int(gap["from_exercise_id"]),
int(gap["to_exercise_id"]),
)
if idx is None:
if idx is None or idx + 1 >= len(steps):
continue
step_a = steps[idx]
step_b = steps[idx + 1]
phase = gap.get("expected_phase") or "vertiefung"
add(
{
@ -215,25 +391,46 @@ def collect_gap_fill_specs(
"sketch": _default_sketch(
goal_query=goal_query,
brief=brief,
step_a=steps[idx],
step_b=steps[idx + 1],
step_a=step_a,
step_b=step_b,
phase=str(phase),
rationale="Bibliothek enthält keine passende Brücke.",
),
"rationale": "Lücke zwischen benachbarten Schritten — keine passende Bibliotheks-Übung.",
"rationale": "Lücke zwischen benachbaren Schritten — keine passende Bibliotheks-Übung.",
}
)
for ot in off_topic_steps:
idx = int(ot.get("step_index") or 0)
if idx <= 0 or idx >= len(steps) - 1:
major_idx = ot.get("roadmap_major_step_index")
idx: Optional[int] = None
if major_idx is not None:
try:
mi = int(major_idx)
except (TypeError, ValueError):
mi = None
if mi is not None:
idx = next(
(
i
for i, s in enumerate(steps)
if s.get("roadmap_major_step_index") is not None
and int(s["roadmap_major_step_index"]) == mi
),
None,
)
if idx is None:
idx = int(ot.get("step_index") or 0)
if idx < 0 or idx >= len(steps):
continue
step_a, step_b = _step_neighbors_at_index(steps, idx)
phase = ot.get("expected_phase") or "vertiefung"
insert_after = max(idx - 1, -1)
add(
{
"source": "off_topic",
"insert_after_index": idx - 1,
"insert_after_index": insert_after,
"replace_step_index": idx,
"roadmap_major_step_index": major_idx,
"gap": {
"expected_phase": phase,
"off_topic_title": ot.get("title"),
@ -244,8 +441,8 @@ def collect_gap_fill_specs(
"sketch": _default_sketch(
goal_query=goal_query,
brief=brief,
step_a=steps[idx - 1],
step_b=steps[idx + 1],
step_a=step_a,
step_b=step_b,
phase=str(phase),
rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.",
),
@ -282,8 +479,16 @@ def build_gap_fill_goal_text(
f"Planungsziel (gesamter Pfad): {goal_query}",
f"Hauptthema: {snap.get('primary_topic') or topic}",
]
if snap.get("start_situation"):
if snap.get("entry_state"):
parts.append(
f"Eingangszustand (erreichte Voraussetzungen aus Vorstufen): {snap['entry_state']}"
)
if snap.get("entry_state_detail") and snap.get("entry_state_detail") != snap.get("entry_state"):
parts.append(f"Bisheriger Pfad:\n{snap['entry_state_detail']}")
if snap.get("start_situation") and not snap.get("entry_state"):
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
elif snap.get("start_situation") and snap.get("prior_steps"):
parts.append(f"Ausgangsbasis des gesamten Pfads: {snap['start_situation']}")
if snap.get("target_state"):
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
if snap.get("roadmap_notes"):
@ -291,13 +496,20 @@ def build_gap_fill_goal_text(
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
if stage_goal:
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
parts.extend(
[
f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}",
f"Erwarteter Entwicklungsbogen: {arc}",
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
]
)
parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}")
parts.append(f"Erwarteter Entwicklungsbogen: {arc}")
if spec.get("source") == "roadmap_unfilled":
parts.append(
"Einordnung: Übung für diese Roadmap-Stufe — das Stufen-Lernziel steht im Vordergrund."
)
if step_a:
parts.append(f"Vorherige Stufe: „{from_title}")
if step_b:
parts.append(f"Nächste Stufe: „{to_title}")
else:
parts.append(
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“."
)
if snap.get("stage_load_profile"):
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
if snap.get("stage_success_criteria"):
@ -314,6 +526,17 @@ def build_gap_fill_goal_text(
"Fähigkeiten-/Fokus-Hinweise: "
+ "; ".join(str(x) for x in snap["skill_hints"][:4])
)
expected = snap.get("expected_skills") or []
if expected:
names = [
str(s.get("skill_name") or "").strip()
for s in expected[:5]
if str(s.get("skill_name") or "").strip()
]
if names:
parts.append(
"Erwartete Fähigkeiten (Scoring): " + ", ".join(names)
)
if spec.get("rationale"):
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
if spec.get("sketch"):
@ -335,10 +558,28 @@ def build_gap_fill_offer(
proposal: Optional[Mapping[str, Any]] = None,
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
source = spec.get("source")
idx = int(spec.get("insert_after_index") or 0)
major_idx = spec.get("roadmap_major_step_index")
if source == "roadmap_unfilled" and major_idx is not None:
try:
mi = int(major_idx)
except (TypeError, ValueError):
mi = idx
step_a, step_b = _resolve_neighbor_steps_by_major_index(steps, mi)
idx = mi
else:
step_a = steps[idx] if idx < len(steps) else None
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
step_a = steps[idx] if idx < len(steps) else None
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
enriched_snapshot = dict(roadmap_snapshot) if roadmap_snapshot else {}
major_raw = spec.get("roadmap_major_step_index")
if major_raw is not None:
enriched_snapshot = enrich_gap_snapshot_with_entry_state(
enriched_snapshot,
steps=steps,
major_step_index=major_raw,
)
goal_for_ai = ""
if brief and goal_query:
goal_for_ai = build_gap_fill_goal_text(
@ -347,9 +588,9 @@ def build_gap_fill_offer(
spec=spec,
step_a=step_a,
step_b=step_b,
roadmap_snapshot=roadmap_snapshot,
roadmap_snapshot=enriched_snapshot or None,
)
ctx_preview = dict(roadmap_snapshot) if roadmap_snapshot else None
ctx_preview = enriched_snapshot or None
offer: Dict[str, Any] = {
"offer_id": offer_id,
"source": spec.get("source"),
@ -400,6 +641,38 @@ def apply_gap_fill_after_qa(
offers: List[Dict[str, Any]] = []
for spec in specs:
source = spec.get("source")
if source == "roadmap_unfilled":
proposal: Optional[Dict[str, Any]] = None
if include_ai_calls and len(proposals) < max_ai_proposals:
proposal = try_suggest_ai_stage_step(
cur,
goal_query=goal_query,
brief=brief,
spec=spec,
steps=out,
)
offer = build_gap_fill_offer(
spec=spec,
steps=out,
goal_query=goal_query,
brief=brief,
proposal=proposal,
roadmap_snapshot=roadmap_snapshot,
)
offers.append(offer)
if proposal and auto_insert_proposals:
proposals.append(
{
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
"proposal_key": proposal.get("proposal_key"),
"proposal_title": proposal.get("title"),
"offer_id": offer.get("offer_id"),
}
)
continue
idx = int(spec.get("insert_after_index") or 0)
if idx < 0 or idx >= len(out) - 1:
continue
@ -421,7 +694,7 @@ def apply_gap_fill_after_qa(
if not gap.get("expected_phase"):
gap["expected_phase"] = spec.get("phase") or "vertiefung"
proposal: Optional[Dict[str, Any]] = None
proposal = None
if include_ai_calls and len(proposals) < max_ai_proposals:
proposal = try_suggest_ai_bridge_step(
cur,
@ -497,4 +770,5 @@ __all__ = [
"collect_gap_fill_specs",
"insert_ai_proposals_for_gaps",
"try_suggest_ai_bridge_step",
"try_suggest_ai_stage_step",
]

File diff suppressed because it is too large Load Diff

View File

@ -18,10 +18,18 @@ from openrouter_chat import (
from planning_exercise_semantics import (
PlanningSemanticBrief,
_blob_from_fields,
_blob_matches_stage_excludes,
brief_to_summary_dict,
exercise_passes_path_semantic_gate,
exercise_passes_stage_learning_goal_gate,
exercise_passes_technique_path_scope,
resolve_path_anti_patterns,
resolve_path_primary_topic,
score_exercise_semantic_relevance,
semantic_brief_for_stage,
step_phase_for_index,
technique_sibling_excludes,
)
_logger = logging.getLogger("shinkan.planning_exercise_path_qa")
@ -174,6 +182,8 @@ def detect_path_gaps(
for i in range(total_segments):
step_a = steps[i]
step_b = steps[i + 1]
if step_a.get("exercise_id") is None or step_b.get("exercise_id") is None:
continue
if roadmap_first and is_roadmap_planned_neighbor_pair(step_a, step_b):
continue
gap = measure_step_transition_gap(
@ -391,52 +401,165 @@ def apply_llm_path_reorder(
_OFF_TOPIC_SEMANTIC_MAX = 0.10
def _with_roadmap_major_index(
step: Mapping[str, Any],
entry: Dict[str, Any],
) -> Dict[str, Any]:
midx = step.get("roadmap_major_step_index")
if midx is not None:
entry["roadmap_major_step_index"] = int(midx)
return entry
def detect_off_topic_steps(
cur,
steps: Sequence[Mapping[str, Any]],
*,
brief: PlanningSemanticBrief,
goal_query: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri)."""
if brief.semantic_strength < 0.55 or len(steps) < 2:
if len(steps) < 2:
return []
roadmap_stage_steps = any(
(step.get("roadmap_match_source") == "stage_spec")
or (step.get("roadmap_learning_goal") or "").strip()
for step in steps
)
if brief.semantic_strength < 0.55 and not roadmap_stage_steps:
return []
path_anti = resolve_path_anti_patterns(goal_query or "", semantic_brief=brief)
off_topic: List[Dict[str, Any]] = []
total = len(steps)
for idx, step in enumerate(steps):
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
continue
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
phase = step_phase_for_index(brief, idx, total)
blob = _blob_from_fields(
bundle["title"],
bundle["summary"],
bundle["goal"],
bundle["variant_names"],
)
step_anti = list(step.get("roadmap_anti_patterns") or []) + path_anti
if step_anti and _blob_matches_stage_excludes(blob, step_anti):
off_topic.append(
_with_roadmap_major_index(
step,
{
"step_index": idx,
"exercise_id": int(step["exercise_id"]),
"title": step.get("title") or bundle["title"],
"semantic_score": 0.0,
"expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
"issue": "path_exclude",
"reasons": ["Widerspricht Pfad-Ausschlüssen (z. B. Kumite)"],
},
)
)
continue
stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
primary = (
resolve_path_primary_topic(
goal_query or "",
brief,
stage_learning_goal=stage_goal_pre or None,
)
or ""
).strip()
if primary:
siblings = technique_sibling_excludes(primary)
if not exercise_passes_technique_path_scope(
primary_topic=primary,
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
learning_goal=stage_goal_pre,
sibling_excludes=siblings,
relaxed=False,
):
off_topic.append(
_with_roadmap_major_index(
step,
{
"step_index": idx,
"exercise_id": int(step["exercise_id"]),
"title": step.get("title") or bundle["title"],
"semantic_score": 0.0,
"expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
"issue": "technique_scope",
"reasons": [f"Passt nicht zur Haupttechnik „{primary}"],
},
)
)
continue
stage_goal = (step.get("roadmap_learning_goal") or "").strip()
phase = (step.get("roadmap_phase") or "").strip().lower() or step_phase_for_index(
brief, idx, total
)
step_brief = (
semantic_brief_for_stage(brief, learning_goal=stage_goal, phase=phase or None)
if stage_goal
else brief
)
sem, sem_reasons = score_exercise_semantic_relevance(
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
variant_names=bundle["variant_names"],
brief=brief,
brief=step_brief,
step_phase=phase,
)
stage_anti = list(step.get("roadmap_anti_patterns") or [])
if stage_goal and not exercise_passes_stage_learning_goal_gate(
learning_goal=stage_goal,
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
semantic_score=sem,
anti_patterns=stage_anti or None,
):
off_topic.append(
_with_roadmap_major_index(
step,
{
"step_index": idx,
"exercise_id": int(step["exercise_id"]),
"title": step.get("title") or bundle["title"],
"semantic_score": round(sem, 4),
"expected_phase": phase,
"issue": "stage_mismatch",
"roadmap_learning_goal": stage_goal,
"reasons": sem_reasons[:3],
},
)
)
continue
if exercise_passes_path_semantic_gate(
semantic_score=sem,
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
brief=brief,
brief=step_brief,
strict=True,
):
continue
if sem > _OFF_TOPIC_SEMANTIC_MAX:
continue
off_topic.append(
{
"step_index": idx,
"exercise_id": int(step["exercise_id"]),
"title": step.get("title") or bundle["title"],
"semantic_score": round(sem, 4),
"expected_phase": phase,
"issue": "off_topic",
"reasons": sem_reasons[:3],
}
_with_roadmap_major_index(
step,
{
"step_index": idx,
"exercise_id": int(step["exercise_id"]),
"title": step.get("title") or bundle["title"],
"semantic_score": round(sem, 4),
"expected_phase": phase,
"issue": "off_topic",
"reasons": sem_reasons[:3],
},
)
)
return off_topic
@ -497,9 +620,10 @@ def strip_off_topic_steps_from_path(
return steps, []
by_index = {int(o["step_index"]): dict(o) for o in off_topic_steps if o.get("step_index") is not None}
indices = sorted(by_index.keys(), reverse=True)
if len(steps) - len(indices) < min_remaining:
max_remove = max(0, len(steps) - min_remaining)
if max_remove <= 0:
return steps, []
indices = sorted(by_index.keys(), reverse=True)[:max_remove]
out = list(steps)
removed: List[Dict[str, Any]] = []
@ -541,6 +665,7 @@ def build_path_qa_summary(
reorder_applied: bool = False,
reorder_notes: Optional[Sequence[str]] = None,
roadmap_qa_mode: Optional[str] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
offers = list(gap_fill_offers or [])
off_topic = list(off_topic_steps or [])
@ -561,6 +686,10 @@ def build_path_qa_summary(
"reorder_notes": list(reorder_notes or []),
"roadmap_qa_mode": roadmap_qa_mode,
}
if multistage_qa:
summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or [])
summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or [])
summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0)
if llm_qa:
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
summary["quality_score"] = llm_qa.get("quality_score")

View File

@ -14,10 +14,14 @@ from planning_exercise_profiles import (
load_exercise_match_profiles_bulk,
score_exercise_against_target,
)
from exercise_ai import strip_html_to_plain
from planning_exercise_semantics import (
PlanningSemanticBrief,
build_stage_match_brief,
exercise_passes_path_semantic_gate,
exercise_passes_stage_fit,
score_exercise_semantic_relevance,
score_exercise_stage_fit,
)
_MAX_LIBRARY_ROWS = 8000
@ -54,6 +58,119 @@ def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> L
return out
_EXERCISE_ROW_SELECT = """
SELECT e.id, e.title, e.summary, e.method_archetype,
e.visibility, e.club_id, e.created_by,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS primary_focus_name,
0.0::float AS ft_rank
FROM exercises e
"""
def fetch_exercise_rows_by_ids(
cur,
exercise_ids: Sequence[int],
*,
vis_sql: str,
vis_params: Sequence[Any],
) -> List[Dict[str, Any]]:
"""Lädt konkrete Übungen nach, wenn sie im Graph/Slot verankert sind (Pin-Sicherheit)."""
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
if not ids:
return []
ph = ",".join(["%s"] * len(ids))
sql = f"""
{_EXERCISE_ROW_SELECT.strip()}
WHERE e.id IN ({ph})
AND ({vis_sql})
AND COALESCE(e.status, '') <> %s
"""
params: List[Any] = list(ids) + list(vis_params) + ["archived"]
cur.execute(sql, params)
return [dict(r) for r in cur.fetchall()]
def fetch_exercise_rows_by_ids_for_graph(
cur,
exercise_ids: Sequence[int],
*,
graph_visibility: str,
graph_club_id: Optional[int],
profile_id: int,
role: str,
exercise_allowed_fn,
) -> List[Dict[str, Any]]:
"""
Lädt Übungen nach ID mit Graph-Sichtbarkeitsregeln (nicht Library-vis_sql).
Ermöglicht Re-Match für im Graph verankerte private Übungen auf Club-Graphen
(eigene private) bzw. alle graph-konformen Übungen.
"""
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
if not ids:
return []
ph = ",".join(["%s"] * len(ids))
sql = f"""
{_EXERCISE_ROW_SELECT.strip()}
WHERE e.id IN ({ph})
AND COALESCE(e.status, '') <> %s
"""
cur.execute(sql, [*ids, "archived"])
out: List[Dict[str, Any]] = []
for row in cur.fetchall() or []:
if exercise_allowed_fn(
row,
graph_visibility=graph_visibility,
graph_club_id=graph_club_id,
profile_id=profile_id,
role=role,
):
out.append(dict(row))
return out
def trim_hits_preserving_priority_ids(
hits: Sequence[Mapping[str, Any]],
priority_ids: Optional[Sequence[int]],
*,
limit: int = 48,
) -> List[Dict[str, Any]]:
"""Behält priorisierte Graph-/Slot-Übungen im Kandidatenpool (vor pick_best_path_hit)."""
priority_set = {int(x) for x in (priority_ids or []) if int(x) > 0}
if not priority_set:
return list(hits)[:limit]
by_id: Dict[int, Dict[str, Any]] = {}
for hit in hits:
try:
by_id[int(hit["id"])] = dict(hit)
except (TypeError, ValueError, KeyError):
continue
priority_hits = [by_id[eid] for eid in sorted(priority_set) if eid in by_id]
rest = [dict(h) for h in hits if int(h.get("id") or 0) not in priority_set]
merged = priority_hits + rest
return merged[: max(limit, len(priority_hits))]
def merge_supplemental_exercise_rows(
rows: Sequence[Dict[str, Any]],
supplemental: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
seen = {int(r["id"]) for r in rows if r.get("id") is not None}
out = list(rows)
for row in supplemental:
rid = int(row["id"])
if rid not in seen:
seen.add(rid)
out.append(dict(row))
return out
def fetch_all_visible_exercise_rows(
cur,
*,
@ -148,7 +265,7 @@ def _load_exercise_goals_chunked(cur, exercise_ids: Sequence[int], *, batch: int
ph = ",".join(["%s"] * len(chunk))
cur.execute(f"SELECT id, goal FROM exercises WHERE id IN ({ph})", chunk)
for row in cur.fetchall():
out[int(row["id"])] = str(row.get("goal") or "")
out[int(row["id"])] = strip_html_to_plain(row.get("goal"), max_len=1200)
return out
@ -200,6 +317,21 @@ def rank_visible_library_hits(
semantic_brief = semantic_brief_raw
step_phase = pack.get("path_step_phase")
path_mode = pack.get("context_mode") == "progression_path"
stage_learning_goal = (pack.get("stage_learning_goal") or "").strip()
roadmap_stage_match = bool(pack.get("roadmap_stage_match"))
stage_match_brief_raw = pack.get("stage_match_brief")
stage_match_brief: Optional[PlanningSemanticBrief] = None
if isinstance(stage_match_brief_raw, PlanningSemanticBrief):
stage_match_brief = stage_match_brief_raw
elif roadmap_stage_match and stage_learning_goal:
stage_match_brief = build_stage_match_brief(
learning_goal=stage_learning_goal,
anti_patterns=pack.get("stage_anti_patterns"),
success_criteria=pack.get("stage_success_criteria"),
load_profile=pack.get("stage_load_profile"),
phase=step_phase,
path_context_note=pack.get("path_context_note"),
)
last_planned_skills: Set[int] = set()
planned_ids = pack.get("planned_exercise_ids") or []
@ -226,7 +358,11 @@ def rank_visible_library_hits(
skills_by_ex = _load_skill_sets_chunked(cur, cand_ids)
goals_by_ex: Dict[int, str] = {}
variants_by_ex: Dict[int, List[str]] = {}
if semantic_brief and semantic_brief.semantic_strength > 0.05:
need_exercise_semantic_text = (
(semantic_brief and semantic_brief.semantic_strength > 0.05)
or (stage_match_brief and stage_match_brief.semantic_strength > 0.05)
)
if need_exercise_semantic_text:
goals_by_ex = _load_exercise_goals_chunked(cur, cand_ids)
variants_by_ex = _load_variant_names_chunked(cur, cand_ids)
@ -267,37 +403,99 @@ def rank_visible_library_hits(
emp, target, intent=intent
)
title_s = str(row.get("title") or "")
summary_s = str(row.get("summary") or "")
goal_s = goals_by_ex.get(eid, "")
semantic_score = 0.0
semantic_reasons: List[str] = []
if semantic_brief and semantic_brief.semantic_strength > 0.05:
semantic_score, semantic_reasons = score_exercise_semantic_relevance(
title=str(row.get("title") or ""),
summary=str(row.get("summary") or ""),
goal=goals_by_ex.get(eid, ""),
title=title_s,
summary=summary_s,
goal=goal_s,
variant_names=variants_by_ex.get(eid, []),
brief=semantic_brief,
step_phase=step_phase,
)
stage_semantic_score = 0.0
stage_semantic_reasons: List[str] = []
if stage_match_brief and stage_match_brief.semantic_strength > 0.05:
stage_semantic_score, stage_semantic_reasons = score_exercise_stage_fit(
title=title_s,
summary=summary_s,
goal=goal_s,
variant_names=variants_by_ex.get(eid, []),
stage_brief=stage_match_brief,
step_phase=step_phase,
)
rank_stage_sem = stage_semantic_score
stage_lg = (stage_learning_goal or "").strip()
if roadmap_stage_match and stage_lg:
raw_brief = build_stage_match_brief(
learning_goal=stage_lg,
anti_patterns=pack.get("stage_anti_patterns"),
phase=step_phase,
)
raw_sem, raw_reasons = score_exercise_stage_fit(
title=title_s,
summary=summary_s,
goal=goal_s,
variant_names=variants_by_ex.get(eid, []),
stage_brief=raw_brief,
step_phase=step_phase,
)
rank_stage_sem = max(stage_semantic_score, raw_sem)
if raw_sem > stage_semantic_score and raw_reasons:
for rr in raw_reasons:
if rr not in stage_semantic_reasons:
stage_semantic_reasons.append(rr)
effective_semantic = (
rank_stage_sem
if roadmap_stage_match and stage_match_brief
else semantic_score
)
score_penalty = 0.0
stage_match_reason: Optional[str] = None
if (
path_mode
and not roadmap_stage_match
and semantic_brief
and semantic_brief.semantic_strength >= 0.55
and not exercise_passes_path_semantic_gate(
semantic_score=semantic_score,
title=str(row.get("title") or ""),
summary=str(row.get("summary") or ""),
goal=goals_by_ex.get(eid, ""),
title=title_s,
summary=summary_s,
goal=goal_s,
brief=semantic_brief,
strict=True,
)
):
score_penalty = 0.42
else:
score_penalty = 0.0
if roadmap_stage_match and stage_learning_goal:
if exercise_passes_stage_fit(
learning_goal=stage_learning_goal,
title=title_s,
summary=summary_s,
goal=goal_s,
stage_brief=stage_match_brief,
stage_semantic_score=rank_stage_sem,
anti_patterns=pack.get("stage_anti_patterns"),
step_phase=step_phase,
path_primary_topic=pack.get("path_primary_topic"),
path_technique_excludes=pack.get("path_technique_excludes"),
):
score_penalty = max(0.0, score_penalty - 0.10)
stage_match_reason = "Passt zum Stufen-Lernziel"
else:
score_penalty += 0.48
score = (
weights.get("semantic", 0.0) * semantic_score
weights.get("semantic", 0.0) * effective_semantic
+ weights["fulltext"] * ft_norm
+ weights["progression"] * prog_hit
+ weights["skill"] * skill_sim
@ -309,7 +507,13 @@ def rank_visible_library_hits(
)
reasons: List[str] = []
if semantic_score >= 0.35 and semantic_reasons:
if stage_match_reason:
reasons.append(stage_match_reason)
if roadmap_stage_match and stage_semantic_score >= 0.30 and stage_semantic_reasons:
for sr in stage_semantic_reasons:
if sr not in reasons:
reasons.append(sr)
elif semantic_score >= 0.35 and semantic_reasons:
for sr in semantic_reasons:
if sr not in reasons:
reasons.append(sr)
@ -345,6 +549,9 @@ def rank_visible_library_hits(
"score": round(max(0.0, min(1.0, score)), 4),
"reasons": reasons,
"semantic_score": round(semantic_score, 4),
"stage_semantic_score": round(stage_semantic_score, 4),
"stage_rank_semantic": round(rank_stage_sem, 4),
"goal": goal_s,
}
)
succ_variants = pack.get("progression_successor_variants") or {}
@ -367,6 +574,8 @@ def run_multistage_planning_retrieval(
intent: str,
intent_weights: Mapping[str, float],
pack: Mapping[str, Any],
supplemental_exercise_ids: Optional[Sequence[int]] = None,
supplemental_rows_preloaded: Optional[Sequence[Dict[str, Any]]] = None,
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]:
"""Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking)."""
rows = fetch_all_visible_exercise_rows(
@ -376,6 +585,16 @@ def run_multistage_planning_retrieval(
query=pack.get("retrieval_query") or query,
exercise_kind_any=exercise_kind_any,
)
if supplemental_rows_preloaded:
rows = merge_supplemental_exercise_rows(rows, supplemental_rows_preloaded)
elif supplemental_exercise_ids:
extra = fetch_exercise_rows_by_ids(
cur,
supplemental_exercise_ids,
vis_sql=vis_sql,
vis_params=vis_params,
)
rows = merge_supplemental_exercise_rows(rows, extra)
hits, skills_by_ex = rank_visible_library_hits(
cur,
rows,
@ -411,8 +630,10 @@ def profile_preselect_rows(
__all__ = [
"fetch_all_visible_exercise_rows",
"fetch_exercise_rows_by_ids",
"fetch_retrieval_candidate_rows",
"hybrid_score_planning_hits",
"merge_supplemental_exercise_rows",
"profile_preselect_rows",
"rank_visible_library_hits",
"run_multistage_planning_retrieval",

View File

@ -9,6 +9,7 @@ from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from pydantic import BaseModel, Field, field_validator
@ -152,6 +153,48 @@ def _normalize_phrase(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
_STAGE_TITLE_STOP = frozenset(
{"für", "fur", "und", "der", "die", "das", "mit", "im", "in", "am", "an", "zur", "zum", "den", "dem", "des"}
)
def _stage_title_tokens(text: str) -> List[str]:
return [
tok
for tok in _normalize_phrase(text).split()
if tok not in _STAGE_TITLE_STOP and len(tok) > 1
]
def exercise_title_equivalent_to_stage_goal(title: str, learning_goal: str) -> bool:
"""
Titel entspricht dem Stufen-Lernziel (wortgleich oder nahezu identisch).
Deckt Graph-Slots ab, bei denen die Übung gezielt zum Lernziel angelegt wurde,
ohne dass die Pfad-Haupttechnik im Übungstext vorkommt.
"""
t = _normalize_phrase(title)
lg = _normalize_phrase(learning_goal)
if len(t) < 3 or len(lg) < 3:
return False
if t == lg:
return True
shorter, longer = (t, lg) if len(t) <= len(lg) else (lg, t)
if shorter in longer and len(shorter) >= 8 and len(shorter) / max(len(longer), 1) >= 0.72:
return True
t_tok = _stage_title_tokens(title)
lg_tok = _stage_title_tokens(learning_goal)
if len(t_tok) >= 2 and t_tok == lg_tok:
return True
if len(t_tok) >= 2 and len(lg_tok) >= 2:
t_set = set(t_tok)
lg_set = set(lg_tok)
overlap = len(t_set & lg_set)
if overlap >= 2 and overlap / max(len(t_set), len(lg_set)) >= 0.85:
return True
return False
def _normalize_query(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip())
@ -179,6 +222,79 @@ def _find_technique_in_text(q_lower: str) -> Optional[Tuple[str, Tuple[str, ...]
return None
def resolve_path_primary_topic(
goal_query: str,
semantic_brief: Optional[PlanningSemanticBrief] = None,
*,
stage_learning_goal: Optional[str] = None,
extra_context: Optional[str] = None,
) -> Optional[str]:
"""
Haupttechnik aus Anfrage, Kontext oder Stufen-Lernziel nicht nur aus goal_query.
"""
if semantic_brief:
primary = (semantic_brief.primary_topic or "").strip()
if primary:
return primary
parts = [goal_query or "", extra_context or "", stage_learning_goal or ""]
combined = _normalize_phrase(" ".join(p for p in parts if p))
if not combined:
return None
hit = _find_technique_in_text(combined.lower())
return hit[0] if hit else None
def technique_sibling_excludes(primary_topic: str) -> List[str]:
"""Andere Techniken derselben Familie (z. B. Mae/Yoko bei Mawashi) — aus Katalog."""
topic = _normalize_phrase(primary_topic)
if not topic:
return []
hit = _find_technique_in_text(topic)
if not hit:
return []
out: List[str] = []
for raw in hit[1]:
for expanded in _expand_stage_exclude_phrase(raw):
if expanded and expanded not in out:
out.append(expanded)
return out[:16]
def exercise_passes_technique_path_scope(
*,
primary_topic: str,
title: str,
summary: str = "",
goal: str = "",
learning_goal: str = "",
sibling_excludes: Optional[Sequence[str]] = None,
relaxed: bool = False,
) -> bool:
"""
Technik-Pfad: keine Geschwister-Technik; Haupttechnik muss im Übungstext vorkommen.
Das Stufen-Lernziel allein reicht nicht sonst würden themenfremde Übungen (z. B. Kumite)
nur wegen Mawashi Geri im Lernziel durch das Gate rutschen.
"""
primary = _normalize_phrase(primary_topic)
if not primary:
return True
blob = _blob_from_fields(title, summary, goal, [])
excludes = list(sibling_excludes or technique_sibling_excludes(primary))
if excludes and _blob_matches_stage_excludes(blob, excludes):
return False
if _phrase_in_blob(primary, blob):
return True
if relaxed:
parts = [p for p in primary.split() if len(p) >= 4]
if parts and any(_phrase_in_blob(part, blob) for part in parts):
return True
return False
def _detect_development_arc(q_lower: str) -> List[str]:
found: List[str] = []
for phase, markers in _ARC_PHASES:
@ -245,6 +361,11 @@ def build_semantic_brief(query: Optional[str]) -> PlanningSemanticBrief:
if len(q) >= 24 and not technique:
strength = max(strength, 0.4)
path_constraints = parse_stage_goal_constraints(q)
for item in path_constraints.exclude_phrases:
if item not in exclude:
exclude.append(item)
return PlanningSemanticBrief(
primary_topic=primary,
topic_type=topic_type,
@ -462,7 +583,7 @@ def score_exercise_semantic_relevance(
core_hits = sum(1 for ph in core if _phrase_in_blob(ph, blob))
must_hits = sum(1 for ph in must if _phrase_in_blob(ph, blob))
exclude_hits = sum(1 for ph in exclude if _phrase_in_blob(ph, blob))
exclude_hits = sum(1 for ph in exclude if _phrase_excluded_in_blob(ph, blob))
score = 0.0
if core:
@ -604,6 +725,510 @@ def apply_path_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, floa
}
_STAGE_GOAL_STOPWORDS = _QUERY_STOPWORDS | frozenset(
{
"stufe",
"phase",
"lernziel",
"grundlage",
"vertiefung",
"anwendung",
"perfektion",
"einstieg",
"sicher",
"sauber",
"korrekt",
"technik",
"training",
}
)
_STAGE_NEGATION_PATTERNS = (
r"\bohne\s+([^,.;]+)",
r"\bkein(?:e|en|er|em)?\s+([^,.;]+)",
r"\bnicht\s+([^,.;]+)",
)
# Aus „ohne Tritttechnik“ etc. — erweiterte Treffer im Übungstext
_STAGE_EXCLUDE_ALIASES: Dict[str, Tuple[str, ...]] = {
"tritttechnik": (
"tritttechnik",
"trittpraezision",
"trittpräzision",
"tritt praesision",
"tritt-präzision",
"kicktechnik",
"tritt ausführung",
"tritt ausfuehrung",
),
"kumite": ("kumite", "partnerkampf", "freikampf", "jiyu kumite"),
"kraftuebung": ("kraftuebung", "kraftübung", "krafttraining", "kraftübungen"),
"anwendung": ("kumite anwendung", "kampfanwendung"),
}
_STAGE_FOCUS_TOKENS = frozenset(
{
"koordination",
"absprung",
"beinhebung",
"landung",
"sprung",
"sprungphase",
"balance",
"gleichgewicht",
"timing",
"vorbereitung",
"athletik",
"mobilitaet",
"mobilität",
"stabilisation",
"stabilisierung",
}
)
@dataclass
class StageGoalConstraints:
positive_tokens: List[str] = field(default_factory=list)
exclude_phrases: List[str] = field(default_factory=list)
has_negation: bool = False
strict_positive: bool = False
def _expand_stage_exclude_phrase(phrase: str) -> List[str]:
norm = _normalize_phrase(phrase)
if not norm:
return []
out: List[str] = [norm]
compact = norm.replace(" ", "")
if compact and compact not in out:
out.append(compact)
for key, aliases in _STAGE_EXCLUDE_ALIASES.items():
if key in norm or norm in key:
for alias in aliases:
a = _normalize_phrase(alias)
if a and a not in out:
out.append(a)
return out[:12]
def _significant_stage_tokens(learning_goal: str, *, strip_negated: bool = True) -> List[str]:
"""Wörter aus Stufen-Lernziel für Text-Match (ohne Füllwörter, ohne Negationssegmente)."""
text = _normalize_phrase(learning_goal)
if strip_negated:
for pat in _STAGE_NEGATION_PATTERNS:
text = re.sub(pat, " ", text)
raw = re.findall(r"[a-zäöüß]{4,}", text, flags=re.IGNORECASE)
out: List[str] = []
for w in raw:
low = w.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
if low in _STAGE_GOAL_STOPWORDS:
continue
if low not in out:
out.append(low)
return out[:10]
def parse_stage_goal_constraints(
learning_goal: str,
anti_patterns: Optional[Sequence[str]] = None,
) -> StageGoalConstraints:
"""Positiv/Negativ aus Stufen-Lernziel + anti_patterns (Roadmap-Stufe)."""
lg = (learning_goal or "").strip()
if len(lg) < 3:
return StageGoalConstraints()
norm = _normalize_phrase(lg)
exclude: List[str] = []
has_negation = False
for pat in _STAGE_NEGATION_PATTERNS:
for m in re.finditer(pat, norm):
has_negation = True
chunk = (m.group(1) or "").strip()
if chunk:
exclude.extend(_expand_stage_exclude_phrase(chunk))
for raw in anti_patterns or []:
s = _normalize_phrase(str(raw or ""))
if s:
exclude.extend(_expand_stage_exclude_phrase(s))
positive = _significant_stage_tokens(lg, strip_negated=True)
focus_hits = [t for t in positive if t in _STAGE_FOCUS_TOKENS]
strict_positive = bool(focus_hits) or has_negation
dedup_exclude: List[str] = []
for item in exclude:
if item and item not in dedup_exclude:
dedup_exclude.append(item)
return StageGoalConstraints(
positive_tokens=positive,
exclude_phrases=dedup_exclude[:16],
has_negation=has_negation,
strict_positive=strict_positive,
)
def _phrase_excluded_in_blob(phrase: str, blob: str) -> bool:
"""Treffer nur wenn das Ausschluss-Thema nicht selbst negiert beschrieben ist."""
if not phrase or not blob:
return False
if not _phrase_in_blob(phrase, blob):
return False
norm = _normalize_phrase(phrase)
for pat in _STAGE_NEGATION_PATTERNS:
for m in re.finditer(pat, blob):
chunk = _normalize_phrase(m.group(1) or "")
if not chunk:
continue
if norm in chunk or chunk in norm or _phrase_in_blob(norm, chunk):
return False
return True
def _blob_matches_stage_excludes(blob: str, exclude_phrases: Sequence[str]) -> bool:
for phrase in exclude_phrases:
if _phrase_excluded_in_blob(phrase, blob):
return True
return False
def resolve_path_anti_patterns(
goal_query: str,
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
extra_context: Optional[str] = None,
) -> List[str]:
"""
Pfadweite Ausschlüsse nur aus expliziten Quellen, kein Themen-Raten.
Quellen (in dieser Reihenfolge):
1. Negationen in Anfrage/Kontext (ohne/kein/nicht ) via parse_stage_goal_constraints
2. exclude_phrases im Semantic Brief (inkl. LLM/Technik-Regeln)
3. stage_specs.anti_patterns (Roadmap-Stufe, vom Trainer oder LLM)
Keine stillen Ausschlüsse aus dem Hauptthema (z. B. Mawashi kein Kumite).
"""
parts = [str(goal_query or "").strip(), str(extra_context or "").strip()]
combined = " ".join(p for p in parts if p)
if not combined and not semantic_brief:
return []
constraints = parse_stage_goal_constraints(combined) if combined else StageGoalConstraints()
out: List[str] = []
for item in constraints.exclude_phrases:
if item and item not in out:
out.append(item)
if semantic_brief:
for raw in semantic_brief.exclude_phrases or []:
for expanded in _expand_stage_exclude_phrase(str(raw or "")):
if expanded and expanded not in out:
out.append(expanded)
return out[:24]
def enrich_brief_with_path_constraints(
brief: PlanningSemanticBrief,
goal_query: str,
*,
extra_context: Optional[str] = None,
) -> PlanningSemanticBrief:
"""Negationen/Ausschlüsse aus der Gesamtanfrage in den Semantic Brief übernehmen."""
anti = resolve_path_anti_patterns(
goal_query,
semantic_brief=brief,
extra_context=extra_context,
)
if not anti:
return brief
exclude = list(brief.exclude_phrases or [])
for item in anti:
if item not in exclude:
exclude.append(item)
return brief.model_copy(update={"exclude_phrases": exclude[:16]})
_MIN_STAGE_FIT_SEMANTIC = 0.30
_MIN_STAGE_FIT_RELAXED = 0.20
_MIN_TITLE_EQUIV_SEMANTIC = 0.15
_MIN_ROADMAP_FALLBACK_RANK = 0.15
def build_stage_match_brief(
*,
learning_goal: str,
anti_patterns: Optional[Sequence[str]] = None,
success_criteria: Optional[Sequence[str]] = None,
load_profile: Optional[Sequence[str]] = None,
phase: Optional[str] = None,
path_context_note: Optional[str] = None,
path_anti_patterns: Optional[Sequence[str]] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[Sequence[str]] = None,
stage_start_state: Optional[str] = None,
stage_target_state: Optional[str] = None,
path_target_state: Optional[str] = None,
contextualized_learning_goal: Optional[str] = None,
) -> PlanningSemanticBrief:
"""
Stufen-zentrierter Semantik-Brief unabhängig vom Gesamt-Pfad-Thema.
Primär für Roadmap-Match: Bewertung gegen Titel + Kurzbeschreibung + Übungsziel.
"""
lg = (contextualized_learning_goal or learning_goal or "").strip()
if len(lg) < 3:
return PlanningSemanticBrief(semantic_strength=0.0)
merged_anti: List[str] = []
for raw in list(anti_patterns or []) + list(path_anti_patterns or []):
s = str(raw or "").strip()
if s and s not in merged_anti:
merged_anti.append(s)
primary_path = _normalize_phrase(path_primary_topic or "")
if primary_path:
for item in technique_sibling_excludes(primary_path):
if item not in merged_anti:
merged_anti.append(item)
for raw in path_technique_excludes or []:
for expanded in _expand_stage_exclude_phrase(str(raw or "")):
if expanded and expanded not in merged_anti:
merged_anti.append(expanded)
constraints = parse_stage_goal_constraints(lg, merged_anti)
must: List[str] = []
norm_lg = _normalize_phrase(lg)
if primary_path and primary_path not in must:
must.insert(0, primary_path[:120])
for token in constraints.positive_tokens:
if token not in must:
must.append(token)
if norm_lg and norm_lg not in must:
must.append(norm_lg[:120])
for raw in success_criteria or []:
s = _normalize_phrase(str(raw or ""))
if s and s not in must:
must.append(s[:100])
for raw in load_profile or []:
s = _normalize_phrase(str(raw or ""))
if s and s not in must:
must.append(s[:60])
retrieval_parts = [norm_lg]
for raw in (stage_start_state, stage_target_state, path_target_state):
s = _normalize_phrase(str(raw or ""))[:200]
if s and s not in retrieval_parts:
retrieval_parts.append(s)
if path_context_note:
note = _normalize_phrase(path_context_note)[:200]
if note:
retrieval_parts.append(note)
arc: List[str] = []
ph = (phase or "").strip().lower()
if ph:
arc.append(ph)
return PlanningSemanticBrief(
primary_topic="",
topic_type="focus",
must_phrases=must[:12],
exclude_phrases=list(constraints.exclude_phrases)[:12],
development_arc=arc[:4],
retrieval_query=" ".join(p for p in retrieval_parts if p)[:500],
semantic_strength=0.78,
rationale="stage_match_brief",
)
def score_exercise_stage_fit(
*,
title: str,
summary: str,
goal: str,
stage_brief: PlanningSemanticBrief,
variant_names: Optional[Sequence[str]] = None,
step_phase: Optional[str] = None,
) -> Tuple[float, List[str]]:
"""Semantik-Score Übung ↔ Stufen-Lernziel (Titel + Summary + Goal)."""
score, reasons = score_exercise_semantic_relevance(
title=title,
summary=summary,
goal=goal,
variant_names=variant_names or [],
brief=stage_brief,
step_phase=step_phase,
)
blob = _blob_from_fields(title, summary, goal, variant_names or [])
focus_tokens = [
t
for t in (stage_brief.must_phrases or [])
if t and " " not in t and len(t) >= 4
][:6]
if focus_tokens:
hits = sum(1 for t in focus_tokens if _phrase_in_blob(t, blob))
ratio = hits / len(focus_tokens)
bonus = 0.28 * ratio
if bonus > 0:
score = min(1.0, score + bonus)
if hits >= max(1, len(focus_tokens) // 2):
reasons = ["Stufen-Schwerpunkte im Übungstext", *reasons]
return max(0.0, min(1.0, round(score, 4))), reasons[:4]
def exercise_passes_stage_fit(
*,
learning_goal: str,
title: str,
summary: str = "",
goal: str = "",
stage_brief: Optional[PlanningSemanticBrief] = None,
stage_semantic_score: Optional[float] = None,
anti_patterns: Optional[Sequence[str]] = None,
step_phase: Optional[str] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[Sequence[str]] = None,
min_stage_semantic: float = _MIN_STAGE_FIT_SEMANTIC,
relaxed: bool = False,
) -> bool:
"""Allgemeines Stufen-Fit-Gate: voller Übungstext vs. Stufen-Brief."""
lg = (learning_goal or "").strip()
if len(lg) < 3 and not (path_primary_topic or "").strip():
return True
blob = _blob_from_fields(title, summary, goal, [])
constraints = parse_stage_goal_constraints(lg, anti_patterns)
if constraints.exclude_phrases and _blob_matches_stage_excludes(blob, constraints.exclude_phrases):
return False
title_equiv = exercise_title_equivalent_to_stage_goal(title, learning_goal or lg)
primary_path = (path_primary_topic or "").strip()
if not primary_path and lg:
hit = _find_technique_in_text(_normalize_phrase(lg))
if hit:
primary_path = hit[0]
tech_excludes = list(path_technique_excludes or [])
if primary_path:
for item in technique_sibling_excludes(primary_path):
if item not in tech_excludes:
tech_excludes.append(item)
if primary_path and not title_equiv and not exercise_passes_technique_path_scope(
primary_topic=primary_path,
title=title,
summary=summary,
goal=goal,
learning_goal=lg,
sibling_excludes=tech_excludes,
relaxed=relaxed,
):
return False
brief = stage_brief or build_stage_match_brief(
learning_goal=lg,
anti_patterns=anti_patterns,
)
stage_sem = stage_semantic_score
if stage_sem is None:
stage_sem, _ = score_exercise_stage_fit(
title=title,
summary=summary,
goal=goal,
stage_brief=brief,
step_phase=step_phase,
)
if relaxed:
threshold = _MIN_STAGE_FIT_RELAXED
elif title_equiv:
threshold = _MIN_TITLE_EQUIV_SEMANTIC
else:
threshold = min_stage_semantic
return float(stage_sem or 0.0) >= threshold
def apply_stage_match_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, float]:
"""Roadmap-Stufe: Stufen-Semantik (Ziel/Summary/Goal) dominiert."""
return {
"semantic": 0.58,
"fulltext": 0.14,
"profile": 0.18,
"progression": 0.04,
"skill": 0.04,
"plan": 0.02,
"repeat_unit": -0.40,
"repeat_group": -0.15,
}
def semantic_brief_for_stage(
brief: PlanningSemanticBrief,
*,
learning_goal: str,
phase: Optional[str] = None,
anti_patterns: Optional[Sequence[str]] = None,
) -> PlanningSemanticBrief:
"""Legacy: globalen Brief anreichern — bevorzugt build_stage_match_brief für Roadmap-Match."""
lg = _normalize_phrase(learning_goal)
if not lg:
return brief
constraints = parse_stage_goal_constraints(learning_goal, anti_patterns)
must = list(brief.must_phrases or [])
for token in constraints.positive_tokens[:4]:
if token not in must:
must.append(token)
if lg not in must:
must.insert(0, lg[:120])
exclude = list(brief.exclude_phrases or [])
for item in constraints.exclude_phrases:
if item not in exclude:
exclude.append(item)
arc = list(brief.development_arc or [])
ph = (phase or "").strip().lower()
if ph and ph not in arc:
arc = [ph, *arc]
strength = max(float(brief.semantic_strength or 0.0), 0.58)
return brief.model_copy(
update={
"must_phrases": must[:12],
"exclude_phrases": exclude[:12],
"development_arc": arc[:8],
"semantic_strength": min(1.0, strength),
}
)
def exercise_passes_stage_learning_goal_gate(
*,
learning_goal: str,
title: str,
summary: str = "",
goal: str = "",
semantic_score: float = 0.0,
min_semantic: float = 0.20,
relaxed: bool = False,
anti_patterns: Optional[Sequence[str]] = None,
stage_brief: Optional[PlanningSemanticBrief] = None,
stage_semantic_score: Optional[float] = None,
step_phase: Optional[str] = None,
) -> bool:
"""Roadmap-Stufe: delegiert an exercise_passes_stage_fit (Titel + Summary + Goal)."""
del semantic_score, min_semantic
return exercise_passes_stage_fit(
learning_goal=learning_goal,
title=title,
summary=summary,
goal=goal,
stage_brief=stage_brief,
stage_semantic_score=stage_semantic_score,
anti_patterns=anti_patterns,
step_phase=step_phase,
relaxed=relaxed,
)
def exercise_passes_path_semantic_gate(
*,
semantic_score: float,
@ -636,16 +1261,101 @@ def exercise_passes_path_semantic_gate(
return False
def _pick_roadmap_rank_fallback(
hits: List[Dict[str, Any]],
used_exercise_ids: Set[int],
*,
stage_learning_goal: str,
stage_anti_patterns: Optional[Sequence[str]] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, Any]]:
"""
Roadmap-Notfall: bester Treffer nach Stufen-Ranking, wenn striktes Gate leer läuft.
Filtert weiterhin Ausschlüsse und Technik-Scope (Kumite etc.), aber ohne
Mindest-Semantik-Schwelle so finden auch wortnahe Bibliotheks-Übungen den Slot.
"""
stage_goal = (stage_learning_goal or "").strip()
if not stage_goal or not hits:
return None
best: Optional[Dict[str, Any]] = None
best_key: Tuple[float, float] = (-1.0, -1.0)
for hit in hits:
try:
eid = int(hit["id"])
except (TypeError, ValueError, KeyError):
continue
if eid in used_exercise_ids:
continue
title = str(hit.get("title") or "")
summary = str(hit.get("summary") or "")
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
blob = _blob_from_fields(title, summary, goal_text, [])
constraints = parse_stage_goal_constraints(stage_goal, stage_anti_patterns)
if constraints.exclude_phrases and _blob_matches_stage_excludes(
blob, constraints.exclude_phrases
):
continue
title_equiv = exercise_title_equivalent_to_stage_goal(title, stage_goal)
primary = (path_primary_topic or "").strip()
if primary and not title_equiv:
tech_excludes = list(path_technique_excludes or [])
for item in technique_sibling_excludes(primary):
if item not in tech_excludes:
tech_excludes.append(item)
if not exercise_passes_technique_path_scope(
primary_topic=primary,
title=title,
summary=summary,
goal=goal_text,
learning_goal=stage_goal,
sibling_excludes=tech_excludes,
relaxed=True,
):
continue
rank_sem = float(
hit.get("stage_rank_semantic")
or hit.get("stage_semantic_score")
or hit.get("semantic_score")
or 0.0
)
score = float(hit.get("score") or 0.0)
key = (rank_sem, score)
if key > best_key:
best_key = key
best = hit
if best is None or best_key[0] < _MIN_ROADMAP_FALLBACK_RANK:
return None
return best
def pick_best_path_hit(
hits: List[Dict[str, Any]],
used_exercise_ids: Set[int],
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
stage_learning_goal: Optional[str] = None,
stage_anti_patterns: Optional[Sequence[str]] = None,
roadmap_stage_match: bool = False,
stage_match_brief: Optional[PlanningSemanticBrief] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, Any]]:
"""Gestufte Auswahl: strikt → relaxed → bester Semantik-Score."""
"""Gestufte Auswahl: strikt → relaxed → optional Notfall-Fallback."""
if not hits:
return None
stage_goal = (stage_learning_goal or "").strip()
stage_brief: Optional[PlanningSemanticBrief] = stage_match_brief
if roadmap_stage_match and stage_goal and stage_brief is None:
stage_brief = build_stage_match_brief(
learning_goal=stage_goal,
anti_patterns=stage_anti_patterns,
)
def _scan(*, strict: bool) -> Optional[Dict[str, Any]]:
best: Optional[Dict[str, Any]] = None
best_key: Tuple[float, float] = (-1.0, -1.0)
@ -653,18 +1363,44 @@ def pick_best_path_hit(
eid = int(hit["id"])
if eid in used_exercise_ids:
continue
title = str(hit.get("title") or "")
summary = str(hit.get("summary") or "")
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
sem = float(hit.get("semantic_score") or 0.0)
if semantic_brief and not exercise_passes_path_semantic_gate(
semantic_score=sem,
title=str(hit.get("title") or ""),
summary=str(hit.get("summary") or ""),
goal="",
brief=semantic_brief,
strict=strict,
):
continue
stage_sem = float(
hit.get("stage_rank_semantic")
or hit.get("stage_semantic_score")
or sem
)
if roadmap_stage_match and stage_goal:
if not exercise_passes_stage_fit(
learning_goal=stage_goal,
title=title,
summary=summary,
goal=goal_text,
stage_brief=stage_brief,
stage_semantic_score=stage_sem,
anti_patterns=stage_anti_patterns,
path_primary_topic=path_primary_topic,
path_technique_excludes=path_technique_excludes,
relaxed=not strict,
):
continue
else:
if semantic_brief and not exercise_passes_path_semantic_gate(
semantic_score=sem,
title=title,
summary=summary,
goal=goal_text,
brief=semantic_brief,
strict=strict,
):
continue
score = float(hit.get("score") or 0.0)
key = (sem, score)
rank_sem = stage_sem if roadmap_stage_match and stage_goal else sem
key = (rank_sem, score)
if key > best_key:
best_key = key
best = hit
@ -673,11 +1409,25 @@ def pick_best_path_hit(
chosen = _scan(strict=True)
if chosen:
return chosen
if roadmap_stage_match:
chosen = _scan(strict=False)
if chosen:
return chosen
return _pick_roadmap_rank_fallback(
hits,
used_exercise_ids,
stage_learning_goal=stage_goal,
stage_anti_patterns=stage_anti_patterns,
path_primary_topic=path_primary_topic,
path_technique_excludes=path_technique_excludes,
)
chosen = _scan(strict=False)
if chosen:
return chosen
# Notfall: bester verbleibender Treffer mit Semantik > 0 (Thema trotzdem priorisieren)
# Notfall (nur retrieval-first / Brücken): bester verbleibender Treffer
fallback: Optional[Dict[str, Any]] = None
fallback_key: Tuple[float, float] = (-1.0, -1.0)
for hit in hits:
@ -706,8 +1456,22 @@ __all__ = [
"build_semantic_brief",
"enrich_target_with_semantic_expectations",
"exercise_passes_path_semantic_gate",
"StageGoalConstraints",
"apply_stage_match_retrieval_weights",
"build_stage_match_brief",
"enrich_brief_with_path_constraints",
"exercise_passes_stage_fit",
"exercise_title_equivalent_to_stage_goal",
"resolve_path_primary_topic",
"resolve_path_anti_patterns",
"exercise_passes_stage_learning_goal_gate",
"merge_semantic_brief_llm",
"parse_stage_goal_constraints",
"pick_best_path_hit",
"exercise_passes_technique_path_scope",
"score_exercise_stage_fit",
"semantic_brief_for_stage",
"technique_sibling_excludes",
"resolve_semantic_skill_weights",
"score_exercise_semantic_relevance",
"semantic_core_phrases",

View File

@ -0,0 +1,248 @@
"""
Gemeinsame Intent-Anreicherung für Planungs-Retrieval.
Progressionsgraph (Roadmap stage_specs) und später Trainingsplanung
(Abschnitt/Slot) nutzen dieselben Bausteine:
Intent-Kontext bauen Specs finalisieren Matching-Gates.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence
from planning_exercise_semantics import (
PlanningSemanticBrief,
resolve_path_anti_patterns,
technique_sibling_excludes,
)
_NEGATION_CLAUSE_RE = re.compile(
r"\b(?:ohne|kein(?:e|en|er|em)?|nicht)\s+[^,.;\n]+",
flags=re.IGNORECASE,
)
def extract_explicit_exclusions(*texts: Optional[str]) -> List[str]:
"""Lesbare Negationsklauseln aus Freitext (ohne Themen-Raten)."""
out: List[str] = []
for raw in texts:
s = (raw or "").strip()
if not s:
continue
for m in _NEGATION_CLAUSE_RE.finditer(s):
clause = m.group(0).strip().rstrip(".,;")
if clause and clause.lower() not in {x.lower() for x in out}:
out.append(clause[:220])
return out[:12]
@dataclass
class PlanningIntentContext:
"""Pfad-/Abschnittsweiter Planungs-Intent — domänenneutral."""
source_query: str = ""
primary_topic: str = ""
path_anti_patterns: List[str] = field(default_factory=list)
path_success_criteria: List[str] = field(default_factory=list)
explicit_exclusions: List[str] = field(default_factory=list)
context_notes: str = ""
topic_type: str = "general"
technique_sibling_excludes: List[str] = field(default_factory=list)
def to_api_dict(self) -> Dict[str, Any]:
return {
"source_query": self.source_query,
"primary_topic": self.primary_topic,
"topic_type": self.topic_type,
"path_anti_patterns": self.path_anti_patterns[:16],
"path_success_criteria": self.path_success_criteria[:10],
"explicit_exclusions": self.explicit_exclusions[:10],
"technique_sibling_excludes": self.technique_sibling_excludes[:16],
"context_notes": self.context_notes[:1200] or None,
}
def build_planning_intent_context(
goal_query: str,
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
goal_analysis: Optional[Mapping[str, Any]] = None,
extra_context: Optional[str] = None,
primary_topic: Optional[str] = None,
) -> PlanningIntentContext:
"""Intent aus Anfrage, Zielanalyse und optionalem Kontext — ohne Sonderregeln pro Thema."""
ga = dict(goal_analysis or {})
notes_parts = [extra_context or ""]
constraints = ga.get("constraints") if isinstance(ga.get("constraints"), dict) else {}
if isinstance(constraints, dict):
trainer_notes = str(constraints.get("trainer_notes") or "").strip()
if trainer_notes:
notes_parts.append(trainer_notes)
combined_notes = " ".join(p.strip() for p in notes_parts if p and p.strip())
explicit = extract_explicit_exclusions(goal_query, combined_notes or None)
ga_excluded = constraints.get("excluded_themes") if isinstance(constraints, dict) else None
if isinstance(ga_excluded, list):
for item in ga_excluded:
s = str(item or "").strip()
if s and s.lower() not in {x.lower() for x in explicit}:
explicit.append(s[:220])
path_anti = resolve_path_anti_patterns(
goal_query,
semantic_brief=semantic_brief,
extra_context=combined_notes or None,
)
path_success: List[str] = []
for item in ga.get("success_criteria") or []:
s = str(item or "").strip()
if s and s not in path_success:
path_success.append(s[:240])
target = str(ga.get("target_state") or "").strip()
if target and len(target) >= 8:
line = f"Zielzustand erreichbar: {target[:200]}"
if line not in path_success:
path_success.append(line)
topic = (primary_topic or ga.get("primary_topic") or "").strip()
topic_type = "general"
siblings: List[str] = []
if semantic_brief:
if not topic:
topic = (semantic_brief.primary_topic or "").strip()
topic_type = (semantic_brief.topic_type or "general").strip().lower()
if topic_type == "technique" and topic:
siblings = technique_sibling_excludes(topic)
for raw in semantic_brief.exclude_phrases or []:
s = str(raw or "").strip()
if s and s.lower() not in {x.lower() for x in siblings}:
siblings.append(s[:120])
if topic_type == "technique" and topic:
line = f"Haupttechnik {topic} in Kurzbeschreibung oder Übungsziel erkennbar"
if line not in path_success:
path_success.insert(0, line)
return PlanningIntentContext(
source_query=(goal_query or "").strip(),
primary_topic=topic,
topic_type=topic_type,
path_anti_patterns=path_anti,
path_success_criteria=path_success,
explicit_exclusions=explicit,
technique_sibling_excludes=siblings[:16],
context_notes=combined_notes[:1200],
)
def _dedupe_preserve(items: Sequence[str], *, limit: int = 14) -> List[str]:
out: List[str] = []
seen: set[str] = set()
for raw in items:
s = str(raw or "").strip()
if not s:
continue
key = s.lower()
if key in seen:
continue
seen.add(key)
out.append(s[:240])
if len(out) >= limit:
break
return out
def finalize_stage_spec_artifact(
spec: "StageSpecArtifact",
*,
major_step: Optional["MajorStep"] = None,
intent: PlanningIntentContext,
) -> "StageSpecArtifact":
"""Pfad-Intent in eine Stufenspezifikation mergen (LLM oder heuristisch)."""
from planning_progression_roadmap import MajorStep, StageSpecArtifact
learning_goal = (spec.learning_goal or (major_step.learning_goal if major_step else "")).strip()
phase = (major_step.phase if major_step else "").strip().lower()
anti = _dedupe_preserve(
[
*(spec.anti_patterns or []),
*intent.explicit_exclusions,
*intent.path_anti_patterns,
*intent.technique_sibling_excludes,
(
f"andere Technik als {intent.primary_topic}"
if intent.topic_type == "technique" and intent.primary_topic
else ""
),
],
limit=14,
)
stage_start = (spec.start_state or "").strip()
stage_target = (spec.target_state or "").strip()
success = _dedupe_preserve(
[
*(spec.success_criteria or []),
*intent.path_success_criteria,
(f"Soll-Start der Stufe erreichbar: {stage_start[:180]}" if stage_start else ""),
(f"Stufen-Ziel erreichbar: {stage_target[:180]}" if stage_target else ""),
(
f"Übung liefert messbar: {learning_goal[:160]}"
if learning_goal
else ""
),
(
f"Kurzbeschreibung und Übungsziel passen zur Phase {phase}"
if phase
else "Kurzbeschreibung und Übungsziel passen zum Stufen-Lernziel"
),
],
limit=8,
)
idx = spec.major_step_index
if major_step is not None:
idx = major_step.index
return StageSpecArtifact(
major_step_index=idx,
learning_goal=learning_goal,
load_profile=list(spec.load_profile or []),
exercise_type=(spec.exercise_type or "").strip(),
success_criteria=success,
anti_patterns=anti,
)
def finalize_stage_specs_with_intent(
specs: Sequence["StageSpecArtifact"],
major_steps: Sequence["MajorStep"],
*,
intent: PlanningIntentContext,
fallback_specs: Optional[Sequence["StageSpecArtifact"]] = None,
) -> List["StageSpecArtifact"]:
"""Alle Stufen mit gleichem Pfad-Intent anreichern; fehlende Indizes aus Fallback."""
from planning_progression_roadmap import MajorStep, StageSpecArtifact
by_idx = {int(s.major_step_index): s for s in specs}
fallback_by_idx = {int(s.major_step_index): s for s in (fallback_specs or [])}
out: List[StageSpecArtifact] = []
for major in major_steps:
raw = by_idx.get(major.index) or fallback_by_idx.get(major.index)
if raw is None:
raw = StageSpecArtifact(
major_step_index=major.index,
learning_goal=major.learning_goal,
)
out.append(finalize_stage_spec_artifact(raw, major_step=major, intent=intent))
return out
__all__ = [
"PlanningIntentContext",
"build_planning_intent_context",
"extract_explicit_exclusions",
"finalize_stage_spec_artifact",
"finalize_stage_specs_with_intent",
]

View File

@ -0,0 +1,176 @@
"""
Mehrstufige Pfad-QS Findings pro Stufe, daraus Optimierungspotenziale ableiten.
Stufen (allgemein, domänenneutral):
1. deterministische Gates (Technik-Scope, Ausschlüsse, Stufen-Fit)
2. Übergangs-Kohärenz (Lücken zwischen Schritten)
3. LLM-Ganzpfad-Bewertung (Empfehlungen, keine Auto-Patches)
"""
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Sequence
_ACTION_BY_ISSUE: Dict[str, str] = {
"technique_scope": "rematch_slot",
"path_exclude": "rematch_slot",
"stage_mismatch": "refine_stage_spec",
"off_topic": "rematch_slot",
"gap": "bridge_or_gap_fill",
"large_gap": "bridge_or_gap_fill",
"roadmap_unfilled": "rematch_slot",
}
def _action_for_finding(finding: Mapping[str, Any]) -> str:
issue = str(finding.get("issue") or finding.get("type") or "").strip().lower()
if finding.get("is_large_gap"):
return "bridge_or_gap_fill"
return _ACTION_BY_ISSUE.get(issue, "review")
def _hint_from_finding(finding: Mapping[str, Any], *, tier: str) -> Dict[str, Any]:
step_index = finding.get("step_index")
if step_index is None:
step_index = finding.get("major_step_index")
issue = str(finding.get("issue") or finding.get("type") or tier)
action = _action_for_finding(finding)
title = str(finding.get("title") or finding.get("removed_title") or "").strip()
reasons = finding.get("reasons") or []
reason = reasons[0] if reasons else str(finding.get("rationale") or finding.get("detail") or "")
hint: Dict[str, Any] = {
"tier": tier,
"action": action,
"issue": issue,
"step_index": step_index,
"title": title or None,
"reason": (reason or "")[:400] or None,
}
if finding.get("roadmap_learning_goal"):
hint["roadmap_learning_goal"] = finding.get("roadmap_learning_goal")
if finding.get("roadmap_major_step_index") is not None:
hint["roadmap_major_step_index"] = finding.get("roadmap_major_step_index")
return {k: v for k, v in hint.items() if v is not None and v != ""}
def derive_optimization_hints(
tiers: Sequence[Mapping[str, Any]],
) -> List[Dict[str, Any]]:
"""Aus QS-Stufen konkrete Optimierungsaktionen (ohne anfrage-spezifische Heuristiken)."""
hints: List[Dict[str, Any]] = []
seen: set[tuple] = set()
for tier in tiers:
tier_id = str(tier.get("id") or "")
for finding in tier.get("findings") or []:
if not isinstance(finding, dict):
continue
hint = _hint_from_finding(finding, tier=tier_id)
key = (
hint.get("tier"),
hint.get("action"),
hint.get("step_index"),
hint.get("issue"),
)
if key in seen:
continue
seen.add(key)
hints.append(hint)
return hints[:24]
def run_multistage_path_qa(
*,
off_topic_steps: Sequence[Mapping[str, Any]],
stripped_off_topic: Sequence[Mapping[str, Any]],
gaps: Sequence[Mapping[str, Any]],
llm_qa: Optional[Mapping[str, Any]] = None,
llm_applied: bool = False,
roadmap_unfilled: Optional[Sequence[Mapping[str, Any]]] = None,
) -> Dict[str, Any]:
"""Orchestriert QS-Stufen und leitet Optimierungspotenziale ab."""
tier1_findings: List[Dict[str, Any]] = []
for item in stripped_off_topic or off_topic_steps or []:
if isinstance(item, dict):
tier1_findings.append(dict(item))
tier2_findings: List[Dict[str, Any]] = [dict(g) for g in gaps if isinstance(g, dict)]
tier3_findings: List[Dict[str, Any]] = []
llm_recommendations: List[str] = []
if llm_applied and llm_qa:
q_score = llm_qa.get("quality_score")
tier3_findings.append(
{
"issue": "llm_assessment",
"quality_score": q_score,
"overall_ok": llm_qa.get("overall_ok"),
"detail": llm_qa.get("summary") or llm_qa.get("assessment"),
}
)
for raw in llm_qa.get("recommendations") or llm_qa.get("suggestions") or []:
s = str(raw or "").strip()
if s:
llm_recommendations.append(s[:500])
unfilled = list(roadmap_unfilled or [])
if unfilled:
for item in unfilled:
if isinstance(item, (list, tuple)) and len(item) >= 2:
idx, spec = item[0], item[1]
tier1_findings.append(
{
"issue": "roadmap_unfilled",
"step_index": int(idx),
"roadmap_major_step_index": getattr(spec, "major_step_index", idx),
"roadmap_learning_goal": getattr(spec, "learning_goal", None),
"reasons": ["Keine passende Übung für Roadmap-Stufe"],
}
)
elif isinstance(item, dict):
tier1_findings.append({**item, "issue": item.get("issue") or "roadmap_unfilled"})
tiers: List[Dict[str, Any]] = [
{
"id": "tier1_deterministic",
"label": "Deterministische Gates",
"finding_count": len(tier1_findings),
"findings": tier1_findings[:16],
},
{
"id": "tier2_transitions",
"label": "Übergangs-Kohärenz",
"finding_count": len(tier2_findings),
"findings": tier2_findings[:12],
},
{
"id": "tier3_llm_holistic",
"label": "LLM-Ganzpfad",
"finding_count": len(tier3_findings),
"findings": tier3_findings,
"recommendations": llm_recommendations[:8],
"applied": bool(llm_applied),
},
]
optimization_hints = derive_optimization_hints(tiers)
for rec in llm_recommendations[:5]:
optimization_hints.append(
{
"tier": "tier3_llm_holistic",
"action": "review_roadmap",
"issue": "llm_recommendation",
"reason": rec,
}
)
return {
"qa_tiers": tiers,
"optimization_hints": optimization_hints[:28],
"optimization_hint_count": len(optimization_hints),
}
__all__ = [
"derive_optimization_hints",
"run_multistage_path_qa",
]

View File

@ -0,0 +1,245 @@
"""
Auto-Rematch nach Pfad-QS betroffene Roadmap-Slots erneut matchen (Phase A/B).
"""
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
def collect_rematch_slot_indices(
*,
stripped_off_topic: Sequence[Mapping[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]],
optimization_hints: Sequence[Mapping[str, Any]],
stage_specs: Sequence[StageSpecArtifact],
roadmap_unfilled: Optional[Sequence[Any]] = None,
) -> Tuple[Set[int], Dict[int, str]]:
"""Major-Step-Indizes für rematch_slot + Begründung pro Slot."""
spec_by_pos = list(stage_specs)
indices: Set[int] = set()
reasons: Dict[int, str] = {}
def _register(midx: int, reason: str) -> None:
indices.add(int(midx))
if midx not in reasons and reason:
reasons[int(midx)] = reason[:400]
def _resolve_major(item: Mapping[str, Any]) -> Optional[int]:
raw = item.get("roadmap_major_step_index")
if raw is not None:
return int(raw)
si = item.get("step_index")
if si is not None:
pos = int(si)
if 0 <= pos < len(spec_by_pos):
return int(spec_by_pos[pos].major_step_index)
return None
for item in stripped_off_topic or []:
if not isinstance(item, dict):
continue
midx = _resolve_major(item)
if midx is not None:
issue = str(item.get("issue") or "stripped_off_topic")
r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
_register(midx, str(r))
for item in off_topic_steps or []:
if not isinstance(item, dict):
continue
midx = _resolve_major(item)
if midx is None:
continue
issue = str(item.get("issue") or "off_topic")
r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
_register(midx, str(r))
for hint in optimization_hints or []:
if not isinstance(hint, dict):
continue
if str(hint.get("action") or "") != "rematch_slot":
continue
midx = _resolve_major(hint)
if midx is not None:
_register(midx, str(hint.get("reason") or hint.get("issue") or "rematch_slot"))
for item in roadmap_unfilled or []:
if isinstance(item, (list, tuple)) and len(item) >= 2:
idx, spec = item[0], item[1]
midx = getattr(spec, "major_step_index", idx)
_register(int(midx), "Keine passende Übung für Roadmap-Stufe")
elif isinstance(item, dict):
midx = _resolve_major(item)
if midx is not None:
issue = str(item.get("issue") or "roadmap_unfilled")
r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
_register(midx, str(r))
return indices, reasons
def _context_before_major(
steps_by_major: Mapping[int, Mapping[str, Any]],
target_major: int,
) -> Tuple[List[int], Optional[int], Optional[int]]:
planned: List[int] = []
anchor: Optional[int] = None
anchor_vid: Optional[int] = None
for midx in sorted(steps_by_major):
if midx >= target_major:
break
step = steps_by_major[midx]
eid = step.get("exercise_id")
if eid is not None:
planned.append(int(eid))
anchor = int(eid)
vid = step.get("variant_id")
anchor_vid = int(vid) if vid is not None else None
return planned, anchor, anchor_vid
def rematch_roadmap_slots(
cur,
*,
tenant,
body,
goal_query: str,
max_steps: int,
semantic_brief,
path_target_profile,
path_intent: str,
roadmap_ctx: ProgressionRoadmapContext,
steps: Sequence[Mapping[str, Any]],
slot_indices: Set[int],
rematch_reasons: Mapping[int, str],
match_slot_fn,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
"""
Ersetzt nur betroffene Slots; andere Schritte und used-Set bleiben konsistent.
match_slot_fn: _match_roadmap_slot aus path_builder (Injection gegen Zirkularität).
"""
stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
if not stage_specs or not slot_indices:
return list(steps), [], []
spec_by_major = {int(s.major_step_index): s for s in stage_specs}
steps_by_major: Dict[int, Dict[str, Any]] = {}
for raw in steps:
step = dict(raw)
midx = step.get("roadmap_major_step_index")
if midx is not None:
steps_by_major[int(midx)] = step
rematch_log: List[Dict[str, Any]] = []
new_unfilled: List[Tuple[int, StageSpecArtifact]] = []
for major_idx in sorted(slot_indices):
stage_spec = spec_by_major.get(int(major_idx))
if stage_spec is None:
continue
step_index = next(
(i for i, sp in enumerate(stage_specs) if int(sp.major_step_index) == int(major_idx)),
major_idx,
)
old = steps_by_major.pop(int(major_idx), None)
used = {
int(s["exercise_id"])
for m, s in steps_by_major.items()
if s.get("exercise_id") is not None
}
if old and old.get("exercise_id") is not None:
used.add(int(old["exercise_id"]))
planned_ids, anchor_id, anchor_variant_id = _context_before_major(
steps_by_major, int(major_idx)
)
new_step, unfilled_spec = match_slot_fn(
cur,
tenant=tenant,
body=body,
goal_query=goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
roadmap_ctx=roadmap_ctx,
stage_spec=stage_spec,
step_index=step_index,
stage_count=len(stage_specs),
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
used=used,
)
reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot")
if new_step:
steps_by_major[int(major_idx)] = new_step
rematch_log.append(
{
"roadmap_major_step_index": int(major_idx),
"action": "replaced",
"reason": reason,
"replaced_exercise_id": old.get("exercise_id") if old else None,
"replaced_title": old.get("title") if old else None,
"new_exercise_id": new_step.get("exercise_id"),
"new_title": new_step.get("title"),
}
)
else:
if unfilled_spec is not None:
new_unfilled.append((step_index, unfilled_spec))
rematch_log.append(
{
"roadmap_major_step_index": int(major_idx),
"action": "rematch_unfilled",
"reason": reason,
"replaced_exercise_id": old.get("exercise_id") if old else None,
"replaced_title": old.get("title") if old else None,
}
)
ordered: List[Dict[str, Any]] = []
for spec in sorted(stage_specs, key=lambda s: s.major_step_index):
midx = int(spec.major_step_index)
if midx in steps_by_major:
ordered.append(steps_by_major[midx])
return ordered, rematch_log, new_unfilled
def prune_stripped_after_rematch(
stripped_off_topic: Sequence[Mapping[str, Any]],
rematch_log: Sequence[Mapping[str, Any]],
) -> List[Dict[str, Any]]:
"""Entfernt aus stripped_off_topic Slots, die per Rematch ersetzt wurden."""
replaced: Set[int] = set()
for entry in rematch_log or []:
if not isinstance(entry, dict):
continue
if str(entry.get("action") or "") != "replaced":
continue
midx = entry.get("roadmap_major_step_index")
if midx is not None:
replaced.add(int(midx))
if not replaced:
return list(stripped_off_topic or [])
out: List[Dict[str, Any]] = []
for item in stripped_off_topic or []:
if not isinstance(item, dict):
continue
midx = item.get("roadmap_major_step_index")
if midx is not None and int(midx) in replaced:
continue
out.append(dict(item))
return out
__all__ = [
"collect_rematch_slot_indices",
"prune_stripped_after_rematch",
"rematch_roadmap_slots",
]

View File

@ -104,6 +104,10 @@ class RoadmapArtifact(BaseModel):
class StageSpecArtifact(BaseModel):
major_step_index: int = Field(ge=0)
learning_goal: str = ""
"""Soll-Start dieser Stufe (= Zielzustand der vorherigen Stufe / Pfad-Start)."""
start_state: str = ""
"""Zielzustand dieser Stufe (= Soll für den nächsten Schritt)."""
target_state: str = ""
load_profile: List[str] = Field(default_factory=list)
exercise_type: str = ""
success_criteria: List[str] = Field(default_factory=list)
@ -298,6 +302,8 @@ def try_llm_stage_specs(
goal_query: str,
goal_analysis: GoalAnalysisArtifact,
major_steps: Sequence[MajorStep],
intent_context: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> Tuple[Optional[List[StageSpecArtifact]], bool]:
obj = _run_prompt_json(
cur,
@ -306,6 +312,11 @@ def try_llm_stage_specs(
"goal_query": goal_query or "",
"goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False),
"major_steps_json": json.dumps([m.model_dump() for m in major_steps], ensure_ascii=False),
"intent_context_json": json.dumps(dict(intent_context or {}), ensure_ascii=False),
"semantic_brief_json": json.dumps(
brief_to_summary_dict(semantic_brief) if semantic_brief else {},
ensure_ascii=False,
),
},
)
if not obj:
@ -522,9 +533,14 @@ def build_goal_analysis(
if notes.strip():
criteria.append(f"Berücksichtigung: {notes.strip()[:200]}")
from planning_intent_context import extract_explicit_exclusions
constraints: Dict[str, Any] = {"partner_required": False, "group_analysis": False}
if notes.strip():
constraints["trainer_notes"] = notes.strip()[:500]
excluded = extract_explicit_exclusions(goal_query, notes or None)
if excluded:
constraints["excluded_themes"] = excluded
return GoalAnalysisArtifact(
primary_topic=topic,
@ -840,19 +856,31 @@ def build_roadmap_unfilled_gap_specs(
"roadmap_major_step_index": stage_spec.major_step_index,
}
)
return specs[:5]
return specs[:12]
def build_stage_specs(
major_steps: Sequence[MajorStep],
*,
goal_analysis: GoalAnalysisArtifact,
goal_query: str = "",
semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> List[StageSpecArtifact]:
"""Phase C — Stufenspezifikation je Major Step (heuristisch)."""
from planning_exercise_semantics import resolve_path_anti_patterns
topic = goal_analysis.primary_topic or "Technik"
path_anti = resolve_path_anti_patterns(goal_query, semantic_brief=semantic_brief)
specs: List[StageSpecArtifact] = []
for step in major_steps:
phase = (step.phase or "vertiefung").lower()
anti = [
"reine Kraftübung ohne Technikbezug",
f"andere Technik als {topic}" if topic else "themenfremde Übung",
]
for item in path_anti:
if item not in anti:
anti.append(item)
specs.append(
StageSpecArtifact(
major_step_index=step.index,
@ -863,10 +891,7 @@ def build_stage_specs(
f"Bezug zu {topic}",
f"Phase {phase} erkennbar im Übungsziel",
],
anti_patterns=[
"reine Kraftübung ohne Technikbezug",
f"andere Technik als {topic}" if topic else "themenfremde Übung",
],
anti_patterns=anti[:14],
)
)
return specs
@ -920,6 +945,8 @@ def roadmap_context_from_override(
StageSpecArtifact(
major_step_index=i,
learning_goal=(spec.learning_goal or majors[i].learning_goal).strip(),
start_state=(spec.start_state or "").strip(),
target_state=(spec.target_state or "").strip(),
load_profile=list(spec.load_profile or []),
exercise_type=(spec.exercise_type or "").strip(),
success_criteria=list(spec.success_criteria or []),
@ -927,19 +954,81 @@ def roadmap_context_from_override(
)
)
if not all(s.exercise_type for s in stage_specs):
rebuilt = build_stage_specs(majors, goal_analysis=goal_analysis)
rebuilt = build_stage_specs(
majors,
goal_analysis=goal_analysis,
goal_query=goal_query.strip(),
semantic_brief=semantic_brief,
)
for i, spec in enumerate(stage_specs):
if not spec.exercise_type:
spec.exercise_type = rebuilt[i].exercise_type
if not spec.load_profile:
spec.load_profile = list(rebuilt[i].load_profile)
else:
stage_specs = build_stage_specs(majors, goal_analysis=goal_analysis)
stage_specs = build_stage_specs(
majors,
goal_analysis=goal_analysis,
goal_query=goal_query.strip(),
semantic_brief=semantic_brief,
)
from planning_exercise_semantics import enrich_brief_with_path_constraints
from planning_intent_context import (
build_planning_intent_context,
finalize_stage_specs_with_intent,
)
enriched_brief = enrich_brief_with_path_constraints(
semantic_brief,
goal_query.strip(),
extra_context=_merge_roadmap_notes(
structured.roadmap_notes if structured else None,
structured.start_situation if structured else None,
structured.target_state if structured else None,
),
)
intent = build_planning_intent_context(
goal_query.strip(),
semantic_brief=enriched_brief,
goal_analysis=goal_analysis.model_dump(),
extra_context=_merge_roadmap_notes(
structured.roadmap_notes if structured else None,
structured.start_situation if structured else None,
structured.target_state if structured else None,
),
primary_topic=goal_analysis.primary_topic,
)
stage_specs = finalize_stage_specs_with_intent(
stage_specs,
majors,
intent=intent,
fallback_specs=build_stage_specs(
majors,
goal_analysis=goal_analysis,
goal_query=goal_query.strip(),
semantic_brief=enriched_brief,
),
)
from planning_stage_context import derive_stage_specs_transition_states, resolve_path_start_target
path_start, path_target = resolve_path_start_target(
structured=structured,
goal_analysis=goal_analysis,
)
stage_specs = derive_stage_specs_transition_states(
stage_specs,
majors,
path_start=path_start,
path_target=path_target,
goal_analysis=goal_analysis,
)
return ProgressionRoadmapContext(
goal_query=goal_query.strip(),
max_steps=effective_max,
semantic_brief=brief_to_summary_dict(semantic_brief),
resolved_structured=structured,
goal_analysis=goal_analysis,
roadmap=RoadmapArtifact(major_steps=majors),
stage_specs=stage_specs,
@ -1103,19 +1192,72 @@ def run_progression_roadmap_pipeline(
)
ctx.roadmap = roadmap
stage_specs = build_stage_specs(roadmap.major_steps, goal_analysis=goal_analysis)
from planning_exercise_semantics import enrich_brief_with_path_constraints
from planning_intent_context import (
build_planning_intent_context,
finalize_stage_specs_with_intent,
)
brief = enrich_brief_with_path_constraints(
brief,
goal_query,
extra_context=_merge_roadmap_notes(
resolved.roadmap_notes,
resolved.start_situation,
resolved.target_state,
),
)
intent = build_planning_intent_context(
goal_query,
semantic_brief=brief,
goal_analysis=goal_analysis.model_dump(),
extra_context=_merge_roadmap_notes(
resolved.roadmap_notes,
resolved.start_situation,
resolved.target_state,
),
primary_topic=goal_analysis.primary_topic,
)
heuristic_specs = build_stage_specs(
roadmap.major_steps,
goal_analysis=goal_analysis,
goal_query=goal_query,
semantic_brief=brief,
)
stage_specs = list(heuristic_specs)
if include_llm_roadmap and cur is not None:
llm_specs, spec_ok = try_llm_stage_specs(
cur,
goal_query=llm_goal_query,
goal_analysis=goal_analysis,
major_steps=roadmap.major_steps,
intent_context=intent.to_api_dict(),
semantic_brief=brief,
)
if spec_ok and llm_specs:
stage_specs = llm_specs
stage_specs = list(llm_specs)
ctx.llm_stage_spec_applied = True
ctx.prompt_slugs.append(PROMPT_SLUG_STAGE_SPEC)
ctx.stage_specs = stage_specs
ctx.stage_specs = finalize_stage_specs_with_intent(
stage_specs,
roadmap.major_steps,
intent=intent,
fallback_specs=heuristic_specs,
)
from planning_stage_context import derive_stage_specs_transition_states, resolve_path_start_target
path_start, path_target = resolve_path_start_target(
structured=resolved,
goal_analysis=goal_analysis,
)
ctx.stage_specs = derive_stage_specs_transition_states(
ctx.stage_specs,
roadmap.major_steps,
path_start=path_start,
path_target=path_target,
goal_analysis=goal_analysis,
)
if ctx.llm_goal_analysis_applied or ctx.llm_roadmap_applied or ctx.llm_stage_spec_applied:
ctx.pipeline_phase = "roadmap_v1_llm"

View File

@ -0,0 +1,334 @@
"""
Wiederverwendbare Fähigkeiten-Erwartungen für Planungs-KI.
Domänen-Scopes (gleiches Input-/Output-Modell):
- ``progression_stage`` ein Major Step / stage_spec im Progressionsgraphen
- ``progression_path`` gesamter Pfad (Ziel + Start/Ziel)
- ``training_section`` Abschnitt einer Trainingseinheit (Phase G, später)
- ``framework_slot`` Rahmen-Session-Slot (Phase G, später)
Konsumenten mergen ``skill_weights`` in ``PlanningTargetProfile`` (Retrieval)
oder liefern ``expected_skills`` an Übungs-KI (planning_context).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from planning_exercise_profiles import _merge_weight_maps, _normalize_weight_map
from planning_exercise_semantics import PlanningSemanticBrief, resolve_semantic_skill_weights
from planning_exercise_text_signals import _load_skills_for_text_match, _match_skills_in_text
# Scope-Strings — stabil für API/UI und spätere Trainingsplanung
SCOPE_PROGRESSION_STAGE = "progression_stage"
SCOPE_PROGRESSION_PATH = "progression_path"
SCOPE_TRAINING_SECTION = "training_section"
SCOPE_FRAMEWORK_SLOT = "framework_slot"
_LOAD_PROFILE_SKILL_TERMS: Dict[str, Tuple[str, ...]] = {
"koordination": ("Koordination",),
"präzision": ("Präzision",),
"praezision": ("Präzision",),
"kraft": ("Kraft", "Kime"),
"geschwindigkeit": ("Geschwindigkeit", "Schnelligkeit"),
"schnelligkeit": ("Schnelligkeit", "Geschwindigkeit"),
"timing": ("Timing", "Reaktion"),
"reaktion": ("Reaktion", "Timing"),
"distanz": ("Distanz",),
"raum": ("Raum", "Distanz"),
"gleichgewicht": ("Gleichgewicht",),
"kime": ("Kime",),
"ausdauer": ("Ausdauer",),
"beweglichkeit": ("Beweglichkeit",),
}
@dataclass
class PlanningSkillExpectationInput:
scope: str = SCOPE_PROGRESSION_STAGE
primary_topic: str = ""
goal_query: str = ""
start_situation: str = ""
target_state: str = ""
stage_learning_goal: str = ""
roadmap_notes: str = ""
load_profile: List[str] = field(default_factory=list)
phase: str = ""
skill_hints: List[str] = field(default_factory=list)
@dataclass
class PlanningSkillExpectationItem:
skill_id: int
skill_name: str
weight: float
source: str
@dataclass
class PlanningSkillExpectations:
scope: str
skill_weights: Dict[int, float]
items: List[PlanningSkillExpectationItem]
sources: List[str]
def to_api_dict(self) -> Dict[str, Any]:
return {
"scope": self.scope,
"sources": list(self.sources),
"expected_skills": [
{
"skill_id": it.skill_id,
"skill_name": it.skill_name,
"weight": round(it.weight, 4),
"source": it.source,
}
for it in self.items
],
}
def _norm_load_key(s: str) -> str:
return (s or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
def _text_blob(inp: PlanningSkillExpectationInput) -> str:
parts = [
inp.primary_topic,
inp.goal_query,
inp.start_situation,
inp.target_state,
inp.stage_learning_goal,
inp.roadmap_notes,
inp.phase,
" ".join(inp.load_profile or []),
" ".join(inp.skill_hints or []),
]
return "\n".join(p for p in parts if (p or "").strip()).lower()
def _resolve_skills_by_name_terms(
cur,
terms: Sequence[str],
*,
weight: float = 0.9,
source: str,
weights: Dict[int, float],
items: Dict[int, PlanningSkillExpectationItem],
) -> bool:
found = False
for name in terms:
if not name:
continue
cur.execute(
"""
SELECT id, name FROM skills
WHERE (status IS NULL OR status = 'active')
AND LOWER(name) LIKE %s
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
LENGTH(name) ASC
LIMIT 1
""",
(f"%{name.lower()}%", name.lower(), f"{name.lower()}%"),
)
row = cur.fetchone()
if not row:
continue
sid = int(row["id"])
w = max(weights.get(sid, 0.0), weight)
weights[sid] = w
items[sid] = PlanningSkillExpectationItem(
skill_id=sid,
skill_name=str(row.get("name") or "").strip(),
weight=w,
source=source,
)
found = True
return found
def _merge_weights_into(
weights: Dict[int, float],
items: Dict[int, PlanningSkillExpectationItem],
incoming: Dict[int, float],
*,
source: str,
skill_rows: Sequence[Tuple[int, str, int]],
) -> None:
name_by_id = {sid: name for sid, name, _ in skill_rows}
for sid, w in incoming.items():
if w <= 0:
continue
merged = max(weights.get(sid, 0.0), float(w))
weights[sid] = merged
items[sid] = PlanningSkillExpectationItem(
skill_id=sid,
skill_name=name_by_id.get(sid, f"Skill #{sid}"),
weight=merged,
source=source,
)
def build_planning_skill_expectations(
cur,
inp: PlanningSkillExpectationInput,
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> PlanningSkillExpectations:
"""Deterministisch: Thema + Text + load_profile → skill_weights."""
weights: Dict[int, float] = {}
items: Dict[int, PlanningSkillExpectationItem] = {}
sources: List[str] = []
skill_rows = _load_skills_for_text_match(cur)
if semantic_brief is not None:
topic_weights = resolve_semantic_skill_weights(cur, semantic_brief)
if topic_weights:
sources.append("semantic_topic")
_merge_weights_into(
weights, items, topic_weights, source="semantic_topic", skill_rows=skill_rows
)
blob = _text_blob(inp)
if blob:
text_weights = _match_skills_in_text(blob, skill_rows)
if text_weights:
sources.append("text_match")
_merge_weights_into(
weights, items, text_weights, source="text_match", skill_rows=skill_rows
)
load_found = False
for raw in inp.load_profile or []:
key = _norm_load_key(str(raw))
terms = _LOAD_PROFILE_SKILL_TERMS.get(key, (str(raw).strip(),) if key else ())
if _resolve_skills_by_name_terms(
cur, terms, weight=0.88, source="load_profile", weights=weights, items=items
):
load_found = True
if load_found and "load_profile" not in sources:
sources.append("load_profile")
normalized = _normalize_weight_map(weights)
out_items = sorted(
[
PlanningSkillExpectationItem(
skill_id=sid,
skill_name=items[sid].skill_name,
weight=normalized[sid],
source=items[sid].source,
)
for sid in normalized
],
key=lambda x: (-x.weight, x.skill_name.lower()),
)[:8]
return PlanningSkillExpectations(
scope=inp.scope or SCOPE_PROGRESSION_STAGE,
skill_weights=normalized,
items=out_items,
sources=sources,
)
def expectation_input_from_progression_stage(
*,
goal_query: str,
goal_analysis: Optional[Mapping[str, Any]] = None,
resolved_structured: Optional[Mapping[str, Any]] = None,
stage_spec: Optional[Mapping[str, Any]] = None,
semantic_brief_summary: Optional[Mapping[str, Any]] = None,
major_step: Optional[Mapping[str, Any]] = None,
) -> PlanningSkillExpectationInput:
"""Roadmap-Stufe → PlanningSkillExpectationInput (Progressionsgraph)."""
ga = dict(goal_analysis or {})
rs = dict(resolved_structured or {})
spec = dict(stage_spec or {})
brief = dict(semantic_brief_summary or {})
major = dict(major_step or {})
skill_hints: List[str] = []
for item in (brief.get("must_phrases") or [])[:4]:
s = str(item or "").strip()
if s:
skill_hints.append(s)
return PlanningSkillExpectationInput(
scope=SCOPE_PROGRESSION_STAGE,
primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(),
goal_query=(goal_query or "").strip(),
start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(),
target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(),
stage_learning_goal=str(
spec.get("learning_goal") or major.get("learning_goal") or ""
).strip(),
roadmap_notes=str(rs.get("roadmap_notes") or "").strip(),
load_profile=[str(x).strip() for x in (spec.get("load_profile") or []) if str(x).strip()],
phase=str(spec.get("phase") or major.get("phase") or "").strip(),
skill_hints=skill_hints,
)
def expectation_input_from_progression_path(
*,
goal_query: str,
goal_analysis: Optional[Mapping[str, Any]] = None,
resolved_structured: Optional[Mapping[str, Any]] = None,
semantic_brief_summary: Optional[Mapping[str, Any]] = None,
) -> PlanningSkillExpectationInput:
"""Gesamtpfad-Kontext (z. B. einmaliges Pfad-Profil)."""
ga = dict(goal_analysis or {})
rs = dict(resolved_structured or {})
brief = dict(semantic_brief_summary or {})
skill_hints: List[str] = []
for item in (brief.get("must_phrases") or [])[:4]:
s = str(item or "").strip()
if s:
skill_hints.append(s)
return PlanningSkillExpectationInput(
scope=SCOPE_PROGRESSION_PATH,
primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(),
goal_query=(goal_query or "").strip(),
start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(),
target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(),
roadmap_notes=str(rs.get("roadmap_notes") or "").strip(),
skill_hints=skill_hints,
)
def apply_expectations_to_target(target, expectations: PlanningSkillExpectations):
"""Merge Erwartungs-Skills in PlanningTargetProfile (Retrieval)."""
from planning_exercise_semantics import enrich_target_with_semantic_expectations
if not expectations.skill_weights:
return target
return enrich_target_with_semantic_expectations(
target, skill_weights=dict(expectations.skill_weights)
)
def merge_expectation_skill_weights(
base: Dict[int, float],
extra: Dict[int, float],
*,
extra_scale: float = 1.0,
) -> Dict[int, float]:
merged = _merge_weight_maps(base, extra, scale=extra_scale)
return _normalize_weight_map(merged)
__all__ = [
"SCOPE_FRAMEWORK_SLOT",
"SCOPE_PROGRESSION_PATH",
"SCOPE_PROGRESSION_STAGE",
"SCOPE_TRAINING_SECTION",
"PlanningSkillExpectationInput",
"PlanningSkillExpectationItem",
"PlanningSkillExpectations",
"apply_expectations_to_target",
"build_planning_skill_expectations",
"expectation_input_from_progression_path",
"expectation_input_from_progression_stage",
"merge_expectation_skill_weights",
]

View File

@ -0,0 +1,140 @@
"""
Stufen-Kontext im Gesamtziel Start/Ziel pro Roadmap-Stufe für Matching und QS.
Übertragbar auf Trainingsplanung: Abschnitt-Soll (= Ende Vorabschnitt), Abschnitt-Ziel.
"""
from __future__ import annotations
from typing import List, Optional, Sequence
from planning_progression_roadmap import (
GoalAnalysisArtifact,
MajorStep,
RoadmapStructuredInput,
StageSpecArtifact,
)
def build_contextualized_stage_goal(
*,
learning_goal: str,
start_state: str = "",
target_state: str = "",
path_target_state: str = "",
path_start_state: str = "",
stage_index: int = 0,
stage_count: int = 1,
) -> str:
"""Stufen-Lernziel eingebettet in Übergang und Gesamtziel (für Brief/Retrieval)."""
lg = (learning_goal or "").strip()
if not lg:
return ""
parts: List[str] = []
start = (start_state or "").strip()
target = (target_state or "").strip()
path_end = (path_target_state or "").strip()
path_begin = (path_start_state or "").strip()
if start:
parts.append(f"Soll-Start: {start[:220]}")
elif path_begin and stage_index == 0:
parts.append(f"Pfad-Start: {path_begin[:220]}")
if target:
parts.append(f"Stufen-Ziel: {target[:220]}")
parts.append(f"Lernziel: {lg[:280]}")
if path_end:
if stage_index >= max(0, stage_count - 1):
parts.append(f"Gesamtziel: {path_end[:220]}")
else:
parts.append(f"Gesamtziel (Kontext): {path_end[:180]}")
return " | ".join(parts)[:900]
def derive_stage_specs_transition_states(
stage_specs: Sequence[StageSpecArtifact],
major_steps: Sequence[MajorStep],
*,
path_start: str = "",
path_target: str = "",
goal_analysis: Optional[GoalAnalysisArtifact] = None,
) -> List[StageSpecArtifact]:
"""
Verkettete Soll-/Zielzustände je Stufe.
- Stufe 0 start = Pfad-Start
- Stufe n start = Zielzustand Stufe n-1 (Ziel des vorherigen Schritts)
- Letzte Stufe target = Pfad-Gesamtziel (falls gesetzt)
"""
start_path = (path_start or "").strip()
end_path = (path_target or "").strip()
if goal_analysis:
if not start_path:
start_path = (goal_analysis.start_assumption or "").strip()
if not end_path:
end_path = (goal_analysis.target_state or "").strip()
by_idx = {int(s.major_step_index): s for s in stage_specs}
majors = sorted(major_steps, key=lambda m: m.index)
if not majors:
return list(stage_specs)
out: List[StageSpecArtifact] = []
prev_target = start_path
last_idx = majors[-1].index
for major in majors:
spec = by_idx.get(major.index)
if spec is None:
spec = StageSpecArtifact(
major_step_index=major.index,
learning_goal=major.learning_goal,
)
explicit_start = (spec.start_state or "").strip()
explicit_target = (spec.target_state or "").strip()
stage_start = explicit_start or prev_target or start_path
if explicit_target:
stage_target = explicit_target
elif major.index == last_idx and end_path:
stage_target = end_path
else:
stage_target = (major.learning_goal or spec.learning_goal or "").strip()
prev_target = stage_target
out.append(
spec.model_copy(
update={
"start_state": (stage_start or "")[:500],
"target_state": (stage_target or "")[:500],
}
)
)
return out
def resolve_path_start_target(
*,
structured: Optional[RoadmapStructuredInput] = None,
goal_analysis: Optional[GoalAnalysisArtifact] = None,
) -> tuple[str, str]:
"""Pfadweiter Start- und Zielzustand für Stufen-Verkettung."""
start = ""
target = ""
if structured:
start = (structured.start_situation or "").strip()
target = (structured.target_state or "").strip()
if goal_analysis:
if not start:
start = (goal_analysis.start_assumption or "").strip()
if not target:
target = (goal_analysis.target_state or "").strip()
return start, target
__all__ = [
"build_contextualized_stage_goal",
"derive_stage_specs_transition_states",
"resolve_path_start_target",
]

View File

@ -0,0 +1,76 @@
"""Validierung und Normalisierung des Planungs-Artefakts am Progressionsgraph."""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
ARTIFACT_SCHEMA_VERSION = 1
_MAX_JSON_BYTES = 64_000
class SlotExerciseContent(BaseModel):
kind: str = Field(default="empty", pattern=r"^(empty|library|proposal)$")
exercise_id: Optional[int] = Field(default=None, ge=1)
variant_id: Optional[int] = Field(default=None, ge=1)
title: Optional[str] = Field(default=None, max_length=500)
variant_name: Optional[str] = Field(default=None, max_length=200)
proposal_key: Optional[str] = Field(default=None, max_length=120)
ai_suggestion: Optional[Dict[str, Any]] = None
class SlotContentEntry(BaseModel):
major_step_index: int = Field(ge=0, le=20)
primary: SlotExerciseContent = Field(default_factory=SlotExerciseContent)
siblings: List[SlotExerciseContent] = Field(default_factory=list)
class GraphPlanningRoadmapArtifact(BaseModel):
schema_version: int = Field(default=ARTIFACT_SCHEMA_VERSION, ge=1, le=1)
goal_query: str = Field(default="", max_length=2000)
start_situation: Optional[str] = Field(default=None, max_length=2000)
target_state: Optional[str] = Field(default=None, max_length=2000)
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
max_steps: int = Field(default=5, ge=2, le=10)
progression_roadmap: Optional[Dict[str, Any]] = None
path_skill_expectations: Optional[Dict[str, Any]] = None
slot_contents: Optional[List[SlotContentEntry]] = None
last_findings: Optional[Dict[str, Any]] = None
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", mode="before")
@classmethod
def _empty_dict_to_none(cls, v):
if v == {}:
return None
return v
@field_validator("slot_contents", mode="before")
@classmethod
def _empty_slot_list_to_none(cls, v):
if v == []:
return None
return v
def normalize_planning_roadmap_payload(raw: Any) -> Optional[Dict[str, Any]]:
"""None erlaubt (löschen); sonst validiertes Dict."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError("planning_roadmap muss ein JSON-Objekt sein")
artifact = GraphPlanningRoadmapArtifact.model_validate(raw)
out = artifact.model_dump(exclude_none=True)
blob = json.dumps(out, ensure_ascii=False)
if len(blob.encode("utf-8")) > _MAX_JSON_BYTES:
raise ValueError("planning_roadmap ist zu groß (max. 64 KB)")
return out
__all__ = [
"ARTIFACT_SCHEMA_VERSION",
"GraphPlanningRoadmapArtifact",
"SlotContentEntry",
"SlotExerciseContent",
"normalize_planning_roadmap_payload",
]

View File

@ -10,5 +10,5 @@ bcrypt==4.1.3
slowapi==0.1.9
psycopg2-binary==2.9.9
python-dateutil==2.9.0
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
tzdata>=2024.1; sys_platform == "win32" # ZoneInfo lokal; Linux/Docker: apt tzdata
sqlparse>=0.5.0 # Migrationen: Statements splitten (Fallback ohne psql)

View File

@ -3,13 +3,16 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032034.
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
"""
from typing import Any, List, Optional
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from psycopg2 import IntegrityError
from psycopg2.extras import Json
from db import get_db, get_cursor, r2d
from progression_graph_planning_artifact import normalize_planning_roadmap_payload
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_library_content_deletable,
@ -36,6 +39,7 @@ class ProgressionGraphUpdate(BaseModel):
description: Optional[str] = None
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
planning_roadmap: Optional[Dict[str, Any]] = None
class ProgressionEdgeCreate(BaseModel):
@ -59,6 +63,7 @@ class SequenceStep(BaseModel):
class ProgressionSequenceCreate(BaseModel):
steps: List[SequenceStep] = Field(..., min_length=2)
segment_notes: Optional[List[Optional[str]]] = None
planning_roadmap: Optional[Dict[str, Any]] = None
"""Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten."""
@model_validator(mode="after")
@ -116,6 +121,17 @@ def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
assert_library_content_editable(cur, profile_id, role, row)
def _persist_graph_planning_roadmap(cur, graph_id: int, raw: Optional[Dict[str, Any]]) -> None:
try:
normalized = normalize_planning_roadmap_payload(raw)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
cur.execute(
"UPDATE exercise_progression_graphs SET planning_roadmap = %s WHERE id = %s",
(Json(normalized) if normalized is not None else None, graph_id),
)
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
row = _graph_row(cur, graph_id)
_assert_graph_readable(cur, row, profile_id, role)
@ -241,6 +257,127 @@ def get_progression_graph(
return row
def _exercise_ids_from_planning_roadmap(artifact: Optional[Dict[str, Any]]) -> set[int]:
ids: set[int] = set()
if not artifact or not isinstance(artifact, dict):
return ids
for slot in artifact.get("slot_contents") or []:
if not isinstance(slot, dict):
continue
primary = slot.get("primary") if isinstance(slot.get("primary"), dict) else {}
if primary.get("kind") == "library" and primary.get("exercise_id") is not None:
try:
ids.add(int(primary["exercise_id"]))
except (TypeError, ValueError):
pass
for sib in slot.get("siblings") or []:
if not isinstance(sib, dict):
continue
if sib.get("kind") == "library" and sib.get("exercise_id") is not None:
try:
ids.add(int(sib["exercise_id"]))
except (TypeError, ValueError):
pass
return ids
def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]:
ids: set[int] = set()
cur.execute(
"""
SELECT from_exercise_id, to_exercise_id
FROM exercise_progression_edges
WHERE graph_id = %s
""",
(graph_id,),
)
for row in cur.fetchall():
for key in ("from_exercise_id", "to_exercise_id"):
raw = row.get(key)
if raw is not None:
ids.add(int(raw))
cur.execute(
"SELECT planning_roadmap FROM exercise_progression_graphs WHERE id = %s",
(graph_id,),
)
prow = cur.fetchone()
if prow and prow.get("planning_roadmap"):
art = prow["planning_roadmap"]
if isinstance(art, str):
try:
art = json.loads(art)
except json.JSONDecodeError:
art = None
ids |= _exercise_ids_from_planning_roadmap(art if isinstance(art, dict) else None)
return ids
@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
def list_visibility_promotion_candidates(
graph_id: int,
target_visibility: str = Query(default="club", pattern="^(club|official)$"),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
"""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role)
graph_vis = (row.get("visibility") or "private").strip().lower()
if graph_vis != "private" or target_visibility != "club":
return {
"graph_id": graph_id,
"graph_visibility": graph_vis,
"target_visibility": target_visibility,
"exercises": [],
}
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
if not ref_ids:
return {
"graph_id": graph_id,
"graph_visibility": graph_vis,
"target_visibility": target_visibility,
"exercises": [],
}
ph = ",".join(["%s"] * len(ref_ids))
cur.execute(
f"""
SELECT id, title, visibility, club_id, created_by
FROM exercises
WHERE id IN ({ph})
AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private'
ORDER BY title
""",
list(ref_ids),
)
exercises = []
for ex in cur.fetchall():
exd = r2d(ex)
if not library_content_visible_to_profile(
cur,
profile_id,
role,
exd,
):
continue
exercises.append(
{
"id": exd["id"],
"title": exd.get("title"),
"visibility": exd.get("visibility"),
}
)
return {
"graph_id": graph_id,
"graph_visibility": graph_vis,
"target_visibility": target_visibility,
"exercises": exercises,
}
@router.post("/exercise-progression-graphs", status_code=201)
def create_progression_graph(
body: ProgressionGraphCreate,
@ -353,15 +490,24 @@ def update_progression_graph(
fields.append("club_id = %s")
params.append(next_club if next_vis == "club" else None)
if not fields:
if "planning_roadmap" in original:
_persist_graph_planning_roadmap(cur, graph_id, original.get("planning_roadmap"))
if not fields and "planning_roadmap" not in original:
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
fields.append("updated_at = NOW()")
params.append(graph_id)
cur.execute(
f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s",
tuple(params),
)
if fields:
fields.append("updated_at = NOW()")
params.append(graph_id)
cur.execute(
f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s",
tuple(params),
)
elif "planning_roadmap" in original:
cur.execute(
"UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s",
(graph_id,),
)
conn.commit()
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
@ -488,6 +634,12 @@ def create_progression_sequence(
note,
)
created.append(row)
if body.planning_roadmap is not None:
_persist_graph_planning_roadmap(cur, graph_id, body.planning_roadmap)
cur.execute(
"UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s",
(graph_id,),
)
conn.commit()
except IntegrityError as e:
conn.rollback()

View File

@ -108,6 +108,57 @@ def library_content_visibility_sql(
return "(" + " OR ".join(parts) + ")", params
def library_content_visibility_for_progression_graph_sql(
*,
alias: str,
profile_id: int,
role: str,
effective_club_id: Optional[int],
graph_visibility: str,
graph_club_id: Optional[int] = None,
) -> tuple[str, List[Any]]:
"""
Übungs-Sichtbarkeit für Progressionsgraph-Match/Planung.
- private Graph: private (eigene) + Verein + offiziell volle Nutzer-Bibliothek
- club Graph: nur Verein (aktiver Graph-Verein) + offiziell
- official Graph: nur offiziell
"""
gvis = (graph_visibility or "private").strip().lower()
if gvis == "private":
return library_content_visibility_sql(
alias=alias,
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
)
if gvis == "club":
parts: List[str] = [f"{alias}.visibility = 'official'"]
params: List[Any] = []
club_id = graph_club_id if graph_club_id is not None else effective_club_id
if club_id is not None:
plat = is_platform_admin(role)
if plat:
parts.append(f"({alias}.visibility = 'club' AND {alias}.club_id = %s)")
params.append(int(club_id))
else:
parts.append(
f"""(
{alias}.visibility = 'club'
AND {alias}.club_id = %s
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = {alias}.club_id
AND cm.status = 'active'
)
)"""
)
params.extend([int(club_id), profile_id])
return "(" + " OR ".join(parts) + ")", params
return f"({alias}.visibility = 'official')", []
def club_library_visibility_sql(
*,
alias: str,

View File

@ -2,7 +2,12 @@
import pytest
from fastapi import HTTPException
from tenant_context import library_content_visibility_sql, parse_active_club_header, resolve_tenant_context
from tenant_context import (
library_content_visibility_for_progression_graph_sql,
library_content_visibility_sql,
parse_active_club_header,
resolve_tenant_context,
)
def test_library_visibility_sql_platform_admin_restricts_club_by_membership():
@ -41,6 +46,37 @@ def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branc
assert params == [42]
def test_progression_graph_visibility_sql_private_matches_library():
base_sql, base_params = library_content_visibility_sql(
alias="e", profile_id=5, role="trainer", effective_club_id=12
)
graph_sql, graph_params = library_content_visibility_for_progression_graph_sql(
alias="e",
profile_id=5,
role="trainer",
effective_club_id=12,
graph_visibility="private",
graph_club_id=None,
)
assert graph_sql == base_sql
assert graph_params == base_params
def test_progression_graph_visibility_sql_club_excludes_private():
sql, params = library_content_visibility_for_progression_graph_sql(
alias="e",
profile_id=5,
role="trainer",
effective_club_id=12,
graph_visibility="club",
graph_club_id=12,
)
assert "official" in sql
assert "visibility = 'club'" in sql
assert "visibility = 'private'" not in sql
assert 12 in params
def test_library_visibility_sql_user_with_active_club_includes_club_branch():
sql, params = library_content_visibility_sql(
alias="t",

View File

@ -1,7 +1,9 @@
"""Tests Planungs-KI Phase D — planning_context für suggestExerciseAi."""
from planning_exercise_form_context import (
build_progression_entry_state,
build_progression_gap_snapshot,
build_progression_path_gap_planning_context,
enrich_gap_snapshot_with_entry_state,
planning_context_prompt_variables,
sanitize_planning_context_for_ai,
)
@ -78,3 +80,55 @@ def test_gap_planning_context_carries_snapshot_fields():
)
assert ctx["start_situation"] == "Start"
assert ctx["stage_learning_goal"] == "Stufenziel"
def test_build_progression_entry_state_from_prior_steps():
entry = build_progression_entry_state(
major_step_index=2,
prior_steps=[
{
"roadmap_major_step_index": 0,
"title": "Schritt-Stand",
"roadmap_phase": "einstieg",
"success_criteria": ["stabile Grundstellung"],
},
{
"roadmap_major_step_index": 1,
"title": "Mawashi Vorbereitung",
"roadmap_target_state": "Hüfte dreht vor dem Knie",
"roadmap_phase": "grundlage",
},
],
start_situation="Anfänger ohne Kumite-Erfahrung",
current_stage_start="Hüfte dreht vor dem Knie, sicherer Stand",
)
assert entry["entry_state"] == "Hüfte dreht vor dem Knie, sicherer Stand"
assert "Mawashi Vorbereitung" in entry["entry_state_detail"]
assert "stabile Grundstellung" in entry["prior_achievements"][0]
def test_enrich_gap_snapshot_with_entry_state():
snap = enrich_gap_snapshot_with_entry_state(
{"start_situation": "Basis", "stage_learning_goal": "Rhythmen"},
steps=[
{
"roadmap_major_step_index": 0,
"title": "A",
"success_criteria": ["Timing erkannt"],
}
],
major_step_index=1,
)
assert snap["entry_state"] == "Timing erkannt"
assert snap["prior_steps"][0]["title"] == "A"
def test_gap_planning_context_trainer_supplements_and_stage_override():
ctx = build_progression_path_gap_planning_context(
goal_query="Kumite",
stage_spec={"learning_goal": "Original"},
stage_learning_goal_override="Angepasstes Stufenziel",
gap_trainer_supplements="Nur Partnerübung, Kindergruppe",
)
assert ctx["stage_learning_goal"] == "Angepasstes Stufenziel"
assert ctx["gap_trainer_supplements"] == "Nur Partnerübung, Kindergruppe"

View File

@ -119,6 +119,86 @@ def test_build_gap_fill_goal_text_includes_roadmap_snapshot():
assert "timing" in text
def test_build_gap_fill_goal_text_includes_expected_skills():
brief = build_semantic_brief("Kumite Beinarbeit")
text = build_gap_fill_goal_text(
goal_query="Kumite Beinarbeit",
brief=brief,
spec={"phase": "vertiefung", "title_hint": "Rhythmen"},
roadmap_snapshot={
"expected_skills": [
{"skill_name": "Timing", "weight": 0.9},
{"skill_name": "Distanz", "weight": 0.8},
],
},
)
assert "Erwartete Fähigkeiten" in text
assert "Timing" in text
def test_build_gap_fill_offer_roadmap_unfilled_uses_major_step_neighbors():
"""Leere Stufe 2 zwischen Stufe 1 und 3 — Nachbarn per roadmap_major_step_index."""
brief = build_semantic_brief("Kumite Beinarbeit")
steps = [
{
"title": "Explosive Angriffe",
"exercise_id": 10,
"roadmap_major_step_index": 0,
},
{
"title": "Kumite-Anwendung",
"exercise_id": 30,
"roadmap_major_step_index": 2,
},
]
offer = build_gap_fill_offer(
spec={
"source": "roadmap_unfilled",
"roadmap_major_step_index": 1,
"phase": "grundlage",
"title_hint": "Grundlegende Kumite-Steppbewegungen",
"gap": {"learning_goal": "Grundlegende Kumite-Steppbewegungen", "expected_phase": "grundlage"},
},
steps=steps,
goal_query="Kumite Beinarbeit",
brief=brief,
)
assert offer["roadmap_major_step_index"] == 1
assert "Explosive Angriffe" in offer["from_title"]
assert "Kumite-Anwendung" in offer["to_title"]
assert "Stufen-Lernziel" in offer["goal_for_ai"] or "Roadmap-Stufe" in offer["goal_for_ai"]
def test_build_gap_fill_offer_includes_entry_state_from_prior_steps():
brief = build_semantic_brief("Kumite Beinarbeit")
steps = [
{
"roadmap_major_step_index": 0,
"title": "Schritt A",
"roadmap_target_state": "gleichmäßige Distanz",
"success_criteria": ["Partnerabstand stabil"],
},
{"roadmap_major_step_index": 2, "title": "Schritt C"},
]
offer = build_gap_fill_offer(
spec={
"source": "roadmap_unfilled",
"phase": "vertiefung",
"title_hint": "Rhythmen",
"roadmap_major_step_index": 1,
},
steps=steps,
goal_query="Kumite Beinarbeit",
brief=brief,
roadmap_snapshot={
"start_situation": "Steppbewegung",
"stage_learning_goal": "variable Rhythmen",
},
)
assert offer["context_preview"]["entry_state"] == "gleichmäßige Distanz"
assert "Eingangszustand" in offer["goal_for_ai"]
def test_build_gap_fill_offer_exposes_context_preview():
brief = build_semantic_brief("Kumite Beinarbeit")
offer = build_gap_fill_offer(
@ -134,3 +214,55 @@ def test_build_gap_fill_offer_exposes_context_preview():
)
assert offer["context_preview"]["start_situation"] == "Steppbewegung"
assert "variable Rhythmen" in offer["goal_for_ai"]
def test_collect_gap_fill_specs_off_topic_last_step_no_crash():
"""Rand-Slot: off_topic am letzten Schritt darf keinen IndexError auslösen (500)."""
brief = build_semantic_brief("Mawashi Geri Kumite")
steps = [
{"exercise_id": 1, "title": "Stand", "roadmap_major_step_index": 0},
{"exercise_id": 2, "title": "Yoko Geri", "roadmap_major_step_index": 1},
]
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=[],
off_topic_steps=[
{
"step_index": 1,
"roadmap_major_step_index": 1,
"title": "Yoko Geri",
"expected_phase": "anwendung",
}
],
llm_specs=[],
brief=brief,
goal_query="Mawashi Geri Kumite",
)
assert len(specs) == 1
assert specs[0]["source"] == "off_topic"
assert "Stand" in specs[0]["sketch"]
def test_collect_gap_fill_specs_off_topic_first_step_uses_safe_neighbors():
brief = build_semantic_brief("Mawashi Geri")
steps = [
{"exercise_id": 1, "title": "Yoko Geri", "roadmap_major_step_index": 0},
{"exercise_id": 2, "title": "Mawashi", "roadmap_major_step_index": 1},
]
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=[],
off_topic_steps=[
{
"step_index": 0,
"roadmap_major_step_index": 0,
"title": "Yoko Geri",
}
],
llm_specs=[],
brief=brief,
goal_query="Mawashi Geri",
)
assert len(specs) == 1
assert "Mawashi" in specs[0]["sketch"]
assert "vorherigem Schritt" in specs[0]["sketch"]

View File

@ -1,12 +1,37 @@
"""Tests Planungs-KI Phase C3/E/F — Pfad-Vorschläge."""
from planning_exercise_path_builder import (
EvaluateStepPayload,
ProgressionPathSuggestRequest,
_annotate_roadmap_step,
_hit_to_path_step,
_pick_best_path_hit,
_supplemental_exercise_ids_from_body,
)
from planning_progression_roadmap import MajorStep, StageSpecArtifact
class _FakeCur:
def execute(self, *_args, **_kwargs):
return None
def fetchall(self):
return []
def test_supplemental_boost_includes_slot_assignments_and_retrieval_boost():
body = ProgressionPathSuggestRequest(
query="Mawashi Geri Progression",
slot_assignments=[
EvaluateStepPayload(exercise_id=99, roadmap_major_step_index=0),
],
retrieval_boost_exercise_ids=[42, 7],
)
ids = _supplemental_exercise_ids_from_body(_FakeCur(), body)
assert 99 in ids
assert 42 in ids
assert 7 in ids
def test_pick_next_path_hit_skips_used():
hits = [{"id": 1, "title": "A", "semantic_score": 0.2}, {"id": 2, "title": "B", "semantic_score": 0.2}, {"id": 3, "title": "C", "semantic_score": 0.2}]
assert _pick_best_path_hit(hits, {1})["id"] == 2
@ -42,3 +67,21 @@ def test_annotate_roadmap_step_adds_metadata():
assert step["roadmap_phase"] == "grundlage"
assert step["roadmap_match_source"] == "stage_spec"
assert any("Roadmap:" in r for r in step["reasons"])
def test_annotate_roadmap_step_adds_skill_expectations():
spec = StageSpecArtifact(major_step_index=0, learning_goal="Timing und Distanz")
step = _annotate_roadmap_step(
{"exercise_id": 5, "title": "Test", "reasons": []},
stage_spec=spec,
major_step=None,
skill_expectations={
"scope": "progression_stage",
"expected_skills": [
{"skill_id": 2, "skill_name": "Timing", "weight": 0.9},
{"skill_id": 3, "skill_name": "Distanz", "weight": 0.8},
],
},
)
assert step["skill_expectations"]["expected_skills"][0]["skill_name"] == "Timing"
assert any("Fähigkeiten:" in r for r in step["reasons"])

View File

@ -106,6 +106,42 @@ def test_detect_path_gaps_skips_roadmap_neighbors():
assert gaps == []
def test_detect_path_gaps_skips_empty_slots():
"""Graph-Bewertung: leere Slots dürfen keinen 500er durch Übergangs-Lücken auslösen."""
brief = build_semantic_brief("Mawashi Geri Kumite")
steps = [
{
"exercise_id": 10,
"title": "Stand",
"roadmap_major_step_index": 0,
},
{
"exercise_id": None,
"title": "(leer: Slot 2)",
"is_ai_proposal": True,
"roadmap_major_step_index": 1,
},
{
"exercise_id": 11,
"title": "Anwendung",
"roadmap_major_step_index": 2,
},
]
class _FakeCur:
def execute(self, *args, **kwargs):
return None
def fetchall(self):
return []
def fetchone(self):
return {"title": "X", "summary": "", "goal": ""}
gaps = detect_path_gaps(_FakeCur(), steps, brief=brief, roadmap_first=True)
assert isinstance(gaps, list)
def test_apply_llm_path_reorder_invalid_ignored():
steps = [{"exercise_id": 1}, {"exercise_id": 2}]
reordered, applied, _ = apply_llm_path_reorder(steps, {"ordered_step_indices": [0, 0]})

View File

@ -0,0 +1,58 @@
"""Tests gemeinsames Planungs-Intent-Modul (Progressionsgraph → Trainingsplanung)."""
from planning_intent_context import (
build_planning_intent_context,
extract_explicit_exclusions,
finalize_stage_specs_with_intent,
)
from planning_progression_roadmap import (
MajorStep,
StageSpecArtifact,
build_goal_analysis,
build_stage_specs,
run_progression_roadmap_pipeline,
)
from planning_exercise_semantics import build_semantic_brief, enrich_brief_with_path_constraints
def test_extract_explicit_exclusions_parses_negations():
q = "gesprungener Mawashi Geri, keine Kumite-Anwendung gewünscht"
out = extract_explicit_exclusions(q)
assert any("kumite" in x.lower() for x in out)
def test_build_planning_intent_context_includes_path_anti():
q = "Mawashi Geri Sprungkraft, keine Kumite-Anwendung"
brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
ga = build_goal_analysis(q, brief)
intent = build_planning_intent_context(q, semantic_brief=brief, goal_analysis=ga.model_dump())
assert intent.explicit_exclusions
assert any("kumite" in a for a in intent.path_anti_patterns)
def test_finalize_stage_specs_merges_intent_into_each_stage():
q = "gesprungener Mawashi Geri, keine Kumite-Anwendung"
brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
ga = build_goal_analysis(q, brief)
intent = build_planning_intent_context(q, semantic_brief=brief, goal_analysis=ga.model_dump())
majors = [
MajorStep(index=0, phase="grundlage", learning_goal="Grundtechnik Mawashi", consolidates=["m1"]),
MajorStep(index=1, phase="vertiefung", learning_goal="Sprungkoordination", consolidates=["m2"]),
]
raw_specs = [
StageSpecArtifact(major_step_index=0, learning_goal=majors[0].learning_goal),
StageSpecArtifact(major_step_index=1, learning_goal=majors[1].learning_goal),
]
finalized = finalize_stage_specs_with_intent(raw_specs, majors, intent=intent)
assert len(finalized) == 2
for spec in finalized:
assert spec.success_criteria
assert any("kumite" in a.lower() for a in spec.anti_patterns)
assert any("messbar" in c.lower() or "übungsziel" in c.lower() for c in spec.success_criteria)
def test_pipeline_stage_specs_carry_exclusions_without_llm():
q = "gesprungener Mawashi Geri Sprungphase, keine Kumite-Anwendung"
ctx = run_progression_roadmap_pipeline(q, max_steps=3, include_llm_roadmap=False)
assert len(ctx.stage_specs) == 3
for spec in ctx.stage_specs:
assert any("kumite" in a.lower() for a in (spec.anti_patterns or []))

View File

@ -0,0 +1,185 @@
"""Tests Auto-Rematch nach Pfad-QS (Phase A)."""
from planning_path_rematch import collect_rematch_slot_indices, rematch_roadmap_slots
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
def _stage_specs():
return [
StageSpecArtifact(major_step_index=0, learning_goal="Grundlage"),
StageSpecArtifact(major_step_index=1, learning_goal="Vertiefung"),
StageSpecArtifact(major_step_index=2, learning_goal="Anwendung"),
]
def test_collect_rematch_slot_indices_from_stripped_with_major_index():
specs = _stage_specs()
stripped = [
{
"step_index": 1,
"roadmap_major_step_index": 1,
"issue": "technique_scope",
"reasons": ["Passt nicht zur Haupttechnik"],
}
]
indices, reasons = collect_rematch_slot_indices(
stripped_off_topic=stripped,
off_topic_steps=[],
optimization_hints=[],
stage_specs=specs,
)
assert indices == {1}
assert "Haupttechnik" in reasons[1]
def test_collect_rematch_slot_indices_resolves_step_index_to_major():
specs = _stage_specs()
off_topic = [
{
"step_index": 2,
"issue": "stage_mismatch",
"reasons": ["Ziel passt nicht"],
}
]
indices, reasons = collect_rematch_slot_indices(
stripped_off_topic=[],
off_topic_steps=off_topic,
optimization_hints=[],
stage_specs=specs,
)
assert indices == {2}
assert reasons[2] == "Ziel passt nicht"
def test_collect_rematch_slot_indices_from_optimization_hints():
specs = _stage_specs()
hints = [
{
"action": "rematch_slot",
"roadmap_major_step_index": 0,
"reason": "QS-Tier-1",
}
]
indices, _ = collect_rematch_slot_indices(
stripped_off_topic=[],
off_topic_steps=[],
optimization_hints=hints,
stage_specs=specs,
)
assert indices == {0}
def test_collect_rematch_slot_indices_from_roadmap_unfilled():
specs = _stage_specs()
indices, reasons = collect_rematch_slot_indices(
stripped_off_topic=[],
off_topic_steps=[],
optimization_hints=[],
stage_specs=specs,
roadmap_unfilled=[(1, specs[1])],
)
assert indices == {1}
assert "Roadmap-Stufe" in reasons[1]
def test_rematch_roadmap_slots_replaces_only_target_slot():
specs = _stage_specs()
ctx = ProgressionRoadmapContext(
goal_query="Mawashi Geri",
max_steps=3,
stage_specs=specs,
)
steps = [
{
"exercise_id": 10,
"title": "Slot 0 OK",
"roadmap_major_step_index": 0,
},
{
"exercise_id": 20,
"title": "Mae Geri falsch",
"roadmap_major_step_index": 1,
},
{
"exercise_id": 30,
"title": "Slot 2 OK",
"roadmap_major_step_index": 2,
},
]
def _fake_match(cur, *, stage_spec, used, **kwargs):
assert stage_spec.major_step_index == 1
assert 20 in used
assert 10 in used
assert 30 in used
return (
{
"exercise_id": 21,
"title": "Sprungkraft Mawashi",
"roadmap_major_step_index": 1,
},
None,
)
ordered, log, unfilled = rematch_roadmap_slots(
None,
tenant=None,
body=None,
goal_query="Mawashi Geri",
max_steps=3,
semantic_brief=None,
path_target_profile=None,
path_intent="",
roadmap_ctx=ctx,
steps=steps,
slot_indices={1},
rematch_reasons={1: "technique_scope"},
match_slot_fn=_fake_match,
)
assert len(ordered) == 3
assert ordered[0]["exercise_id"] == 10
assert ordered[1]["exercise_id"] == 21
assert ordered[2]["exercise_id"] == 30
assert len(log) == 1
assert log[0]["action"] == "replaced"
assert log[0]["replaced_exercise_id"] == 20
assert log[0]["new_exercise_id"] == 21
assert not unfilled
def test_rematch_excludes_replaced_exercise_from_used():
specs = _stage_specs()
ctx = ProgressionRoadmapContext(
goal_query="Mawashi Geri",
max_steps=3,
stage_specs=specs,
)
steps = [
{"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
{"exercise_id": 99, "title": "Mae Geri", "roadmap_major_step_index": 1},
]
seen_used = []
def _fake_match(cur, *, used, stage_spec, **kwargs):
seen_used.append(set(used))
return (
{"exercise_id": 42, "title": "Neu", "roadmap_major_step_index": stage_spec.major_step_index},
None,
)
rematch_roadmap_slots(
None,
tenant=None,
body=None,
goal_query="Mawashi",
max_steps=3,
semantic_brief=None,
path_target_profile=None,
path_intent="",
roadmap_ctx=ctx,
steps=steps,
slot_indices={1},
rematch_reasons={1: "technique_scope"},
match_slot_fn=_fake_match,
)
assert 99 in seen_used[0]

View File

@ -0,0 +1,572 @@
"""Tests Roadmap-Stufen-Match — Gate gegen themenfremde Übungen."""
from planning_exercise_semantics import (
build_semantic_brief,
build_stage_match_brief,
enrich_brief_with_path_constraints,
exercise_passes_stage_learning_goal_gate,
exercise_passes_stage_fit,
exercise_passes_technique_path_scope,
pick_best_path_hit,
resolve_path_anti_patterns,
resolve_path_primary_topic,
score_exercise_stage_fit,
semantic_brief_for_stage,
technique_sibling_excludes,
)
from planning_exercise_path_qa import strip_off_topic_steps_from_path
def test_stage_gate_accepts_learning_goal_in_title():
assert exercise_passes_stage_learning_goal_gate(
learning_goal="variable Rhythmen und Hüftmobilität für Mae Geri",
title="Mae Geri — variable Rhythmen",
summary="",
semantic_score=0.1,
)
def test_stage_gate_rejects_unrelated_kumite():
assert not exercise_passes_stage_learning_goal_gate(
learning_goal="variable Rhythmen und Hüftmobilität für Mae Geri",
title="Kumite Grundstellungen",
summary="Partnerarbeit Distanz",
semantic_score=0.05,
)
def test_semantic_brief_for_stage_adds_learning_goal():
brief = build_semantic_brief("Mae Geri Perfektion")
stage = semantic_brief_for_stage(
brief,
learning_goal="Hüftmobilität und Kammerhaltung",
phase="grundlage",
)
assert "hüftmobilität und kammerhaltung" in stage.must_phrases[0]
def test_build_stage_match_brief_uses_stage_tokens_not_global_topic():
brief = build_stage_match_brief(
learning_goal="Koordination von Absprung und Beinhebung ohne Tritttechnik",
phase="vertiefung",
)
must_blob = " ".join(brief.must_phrases or []).lower()
assert "mawashi" not in must_blob
assert "absprung" in must_blob
assert not (brief.primary_topic or "").strip()
def test_stage_fit_prefers_goal_over_misleading_title():
stage_goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik"
stage_brief = build_stage_match_brief(learning_goal=stage_goal)
kick_score, _ = score_exercise_stage_fit(
title="Mawashi Geri Trittpräzision",
summary="Kicktechnik",
goal="Präzision im Tritt und Hüftarbeit",
stage_brief=stage_brief,
)
coord_score, _ = score_exercise_stage_fit(
title="Allgemeines Sprungtraining",
summary="Athletik",
goal="Absprung, Beinhebung und Landung koordinieren — ohne Trittausführung",
stage_brief=stage_brief,
)
assert coord_score > kick_score
def test_pick_best_path_hit_roadmap_stage_no_weak_fallback():
stage_brief = build_stage_match_brief(
learning_goal="Hüftmobilität für Mae Geri",
phase="grundlage",
)
hits = [
{
"id": 1,
"title": "Kumite Stellungen",
"summary": "Partner Distanz",
"score": 0.92,
"semantic_score": 0.08,
},
{
"id": 2,
"title": "Kraft-Ausdauer Zirkel",
"summary": "allgemeine Fitness",
"score": 0.88,
"semantic_score": 0.02,
},
]
chosen = pick_best_path_hit(
hits,
set(),
semantic_brief=stage_brief,
stage_learning_goal="Hüftmobilität für Mae Geri",
roadmap_stage_match=True,
)
assert chosen is None
def test_pick_best_path_hit_roadmap_stage_picks_relevant():
stage_brief = build_stage_match_brief(
learning_goal="Hüftmobilität für Mae Geri",
phase="grundlage",
)
hits = [
{"id": 1, "title": "Kumite", "score": 0.9, "semantic_score": 0.1},
{
"id": 2,
"title": "Mae Geri Hüftmobilität",
"summary": "Kammerhaltung und Hüfte",
"score": 0.7,
"semantic_score": 0.55,
},
]
chosen = pick_best_path_hit(
hits,
set(),
semantic_brief=stage_brief,
stage_learning_goal="Hüftmobilität für Mae Geri",
roadmap_stage_match=True,
)
assert chosen is not None
assert int(chosen["id"]) == 2
def test_stage_gate_rejects_tritt_when_goal_says_ohne_tritttechnik():
"""Regression: gesprungener Mawashi — Slot Koordination ohne Tritttechnik."""
goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik"
assert not exercise_passes_stage_learning_goal_gate(
learning_goal=goal,
title="Verbesserung der Trittpräzision des Mawashi Geri und der Hüftbewegung",
summary="Präzision und Hüftarbeit im Stand",
semantic_score=0.72,
)
def test_stage_gate_accepts_absprung_drill_not_kick_focus():
goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik"
assert exercise_passes_stage_learning_goal_gate(
learning_goal=goal,
title="Sprungkoordination — Absprung und Beinhebung",
summary="Ohne Trittausführung, Fokus Gleichgewicht und Timing",
semantic_score=0.35,
)
def test_pick_best_rejects_mawashi_tritt_precision_for_coordination_slot():
stage_goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik"
stage_brief = build_stage_match_brief(learning_goal=stage_goal, phase="vertiefung")
hits = [
{
"id": 99,
"title": "Verbesserung der Trittpräzision des Mawashi Geri und der Hüftbewegung",
"summary": "Tritttechnik und Hüfte im Stand",
"score": 0.91,
"semantic_score": 0.68,
},
{
"id": 100,
"title": "Absprung und Beinhebung — Koordination ohne Kick",
"summary": "Sprungvorbereitung, kein Tritt",
"score": 0.62,
"semantic_score": 0.41,
},
]
chosen = pick_best_path_hit(
hits,
set(),
semantic_brief=stage_brief,
stage_learning_goal=stage_goal,
roadmap_stage_match=True,
)
assert chosen is not None
assert int(chosen["id"]) == 100
def test_path_anti_patterns_from_keine_kumite_anwendung():
q = "gesprungener Mawashi Geri Sprungphase, keine Kumite-Anwendung gewünscht"
brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
anti = resolve_path_anti_patterns(q, semantic_brief=brief)
assert any("kumite" in a for a in anti)
def test_stage_fit_rejects_kumite_when_path_excludes_kumite():
q = "gesprungener Mawashi Geri, keine Kumite-Anwendung"
brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
path_anti = resolve_path_anti_patterns(q, semantic_brief=brief)
stage_goal = "Sprungkraft und Koordination für gesprungenen Mawashi Geri"
stage_brief = build_stage_match_brief(
learning_goal=stage_goal,
anti_patterns=path_anti,
path_anti_patterns=path_anti,
)
assert not exercise_passes_stage_fit(
learning_goal=stage_goal,
title="Kumite Distanztraining Mawashi",
summary="Partner-Kumite mit Trittanwendung",
goal="Anwendung im freien Kampf",
stage_brief=stage_brief,
anti_patterns=path_anti,
)
def test_pick_best_skips_kumite_for_mawashi_athletic_path():
q = "gesprungener Mawashi Geri Sprungkraft, keine Kumite-Anwendung"
brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
path_anti = resolve_path_anti_patterns(q, semantic_brief=brief)
stage_goal = "Athletisches Sprungtraining für Mawashi Geri"
stage_brief = build_stage_match_brief(
learning_goal=stage_goal,
anti_patterns=path_anti,
path_anti_patterns=path_anti,
)
hits = [
{
"id": 1,
"title": "Kumite Mawashi Anwendung",
"summary": "Partner Kumite",
"goal": "Kampfanwendung",
"score": 0.95,
"semantic_score": 0.55,
"stage_semantic_score": 0.55,
},
{
"id": 2,
"title": "Sprungkraft Plyometrie",
"summary": "Absprung und Landung",
"goal": "Sprungkraft für Mawashi Geri Vorbereitung",
"score": 0.62,
"semantic_score": 0.38,
"stage_semantic_score": 0.38,
},
]
primary = resolve_path_primary_topic(q, brief, stage_learning_goal=stage_goal)
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=stage_goal,
stage_anti_patterns=path_anti,
roadmap_stage_match=True,
stage_match_brief=stage_brief,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"),
)
assert chosen is not None
assert int(chosen["id"]) == 2
def test_resolve_path_primary_topic_from_stage_learning_goal():
brief = build_semantic_brief("Trainingsprogression gesprungener Tritt")
primary = resolve_path_primary_topic(
"Trainingsprogression gesprungener Tritt",
brief,
stage_learning_goal="Perfektionierung der statischen Mawashi Geri Technik",
)
assert primary and "mawashi" in primary
def test_pick_roadmap_relaxed_for_non_technique_stage():
stage_goal = "Progression Hüftflexibilität und Adduktoren dehnen"
stage_brief = build_stage_match_brief(learning_goal=stage_goal)
hits = [
{
"id": 11,
"title": "Adduktoren Dehnung am Boden",
"summary": "Flexibilität Hüfte",
"goal": "Mobilität",
"score": 0.68,
"semantic_score": 0.22,
"stage_semantic_score": 0.22,
},
]
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=stage_goal,
roadmap_stage_match=True,
stage_match_brief=stage_brief,
path_primary_topic=None,
)
assert chosen is not None
assert int(chosen["id"]) == 11
def test_pick_rejects_kumite_when_primary_only_in_stage_goal():
brief = build_semantic_brief("Trainingsprogression")
stage_goal = "Perfektionierung der statischen Mawashi Geri Technik"
stage_brief = build_stage_match_brief(learning_goal=stage_goal)
primary = resolve_path_primary_topic("Trainingsprogression", brief, stage_learning_goal=stage_goal)
hits = [
{
"id": 4,
"title": "4 Kumite Reaktions Übungen",
"summary": "Partner",
"goal": "Kumite",
"score": 0.95,
"semantic_score": 0.4,
"stage_semantic_score": 0.35,
},
{
"id": 2,
"title": "Mawashi Geri Standtechnik",
"summary": "Rundtritt",
"goal": "Mawashi Geri Basis",
"score": 0.7,
"semantic_score": 0.5,
"stage_semantic_score": 0.48,
},
]
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=stage_goal,
roadmap_stage_match=True,
stage_match_brief=stage_brief,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"),
)
assert chosen is not None
assert int(chosen["id"]) == 2
def test_technique_scope_rejects_kumite_when_only_stage_goal_mentions_mawashi():
siblings = technique_sibling_excludes("mawashi geri")
assert not exercise_passes_technique_path_scope(
primary_topic="mawashi geri",
title="Kumite Grundstellungen",
summary="Partner-Distanz und freier Kampf",
goal="Kumite-Technik",
learning_goal="Sprungkraft und Koordination für Mawashi Geri",
sibling_excludes=siblings,
relaxed=True,
)
def test_title_equivalent_to_stage_goal():
from planning_exercise_semantics import exercise_title_equivalent_to_stage_goal
assert exercise_title_equivalent_to_stage_goal(
"Hüftmobilität für Mae Geri",
"Hüftmobilität für Mae Geri",
)
assert exercise_title_equivalent_to_stage_goal(
"Hüftmobilität Mae Geri",
"Hüftmobilität für Mae Geri",
)
assert not exercise_title_equivalent_to_stage_goal("Kumite", "Hüftmobilität für Mae Geri")
def test_stage_fit_passes_for_title_equivalent_with_sufficient_semantic_score():
stage_goal = "Koordination Absprung ohne Kick"
assert exercise_passes_stage_fit(
learning_goal=stage_goal,
title=stage_goal,
summary="Absprung und Landung koordinieren",
goal="",
path_primary_topic="mawashi geri",
path_technique_excludes=["kumite"],
stage_semantic_score=0.42,
)
def test_roadmap_rank_fallback_picks_best_stage_semantic():
from planning_exercise_semantics import _pick_roadmap_rank_fallback
stage_goal = "Hüftmobilität für Mawashi Geri"
hits = [
{
"id": 1,
"title": "Hüftmobilität für Mawashi Geri",
"summary": "Aufwärmen",
"goal": "",
"score": 0.9,
"stage_rank_semantic": 0.32,
},
{
"id": 2,
"title": "Mawashi Hüftdehnung",
"summary": "Adduktoren und Hüfte",
"goal": "Mobilität für Mawashi Geri",
"score": 0.7,
"stage_rank_semantic": 0.58,
},
]
chosen = _pick_roadmap_rank_fallback(
hits,
set(),
stage_learning_goal=stage_goal,
path_primary_topic="mawashi geri",
path_technique_excludes=technique_sibling_excludes("mawashi geri"),
)
assert chosen is not None
assert int(chosen["id"]) == 2
def test_pick_best_prefers_semantic_fit_over_coincidental_title():
stage_goal = "Hüftmobilität für Mawashi Geri"
stage_brief = build_stage_match_brief(learning_goal=stage_goal)
hits = [
{
"id": 1,
"title": "Hüftmobilität für Mawashi Geri",
"summary": "allgemeine Aufwärmung",
"goal": "",
"score": 0.9,
"semantic_score": 0.12,
"stage_semantic_score": 0.12,
"stage_rank_semantic": 0.35,
},
{
"id": 2,
"title": "Mawashi Hüftmobilität und Adduktoren",
"summary": "Dehnung Hüfte für Rundtritt",
"goal": "Mawashi Geri Hüftbeweglichkeit",
"score": 0.72,
"semantic_score": 0.58,
"stage_semantic_score": 0.58,
"stage_rank_semantic": 0.62,
},
]
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=stage_goal,
roadmap_stage_match=True,
stage_match_brief=stage_brief,
path_primary_topic="mawashi geri",
path_technique_excludes=technique_sibling_excludes("mawashi geri"),
)
assert chosen is not None
assert int(chosen["id"]) == 2
def test_pick_roadmap_relaxed_with_path_primary_when_strict_fails():
"""Bestehende Graph-Übungen: relaxed Gate auch bei gesetztem path_primary_topic."""
stage_goal = "Hüftmobilität für Mawashi Geri"
primary = "mawashi geri"
stage_brief = build_stage_match_brief(
learning_goal=stage_goal,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary),
)
hits = [
{
"id": 42,
"title": "Mawashi Geri Hüftmobilität — Vereinsübung",
"summary": "Dehnung und Hüfte für Rundtritt",
"goal": "Mobilität Mawashi Geri",
"score": 0.55,
"semantic_score": 0.25,
"stage_semantic_score": 0.25,
},
]
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=stage_goal,
roadmap_stage_match=True,
stage_match_brief=stage_brief,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary),
)
assert chosen is not None
assert int(chosen["id"]) == 42
def test_pick_best_roadmap_rejects_kumite_with_path_primary_topic():
q = "gesprungener Mawashi Geri Sprungphase"
brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
primary = (brief.primary_topic or "mawashi geri").strip()
stage_goal = "Sprungvorbereitung für Mawashi Geri"
stage_brief = build_stage_match_brief(
learning_goal=stage_goal,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary),
)
hits = [
{
"id": 1,
"title": "Kumite Distanztraining",
"summary": "Partnerarbeit",
"goal": "Kampfvorbereitung",
"score": 0.95,
"semantic_score": 0.4,
"stage_semantic_score": 0.35,
},
{
"id": 2,
"title": "Sprungkraft Plyometrie",
"summary": "Absprung für Tritttechnik",
"goal": "Sprungkraft Mawashi Geri Vorbereitung",
"score": 0.7,
"semantic_score": 0.45,
"stage_semantic_score": 0.42,
},
]
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=stage_goal,
roadmap_stage_match=True,
stage_match_brief=stage_brief,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary),
)
assert chosen is not None
assert int(chosen["id"]) == 2
def test_technique_scope_rejects_sibling_geri_for_mawashi_path():
siblings = technique_sibling_excludes("mawashi geri")
assert any("mae" in s for s in siblings)
assert not exercise_passes_technique_path_scope(
primary_topic="mawashi geri",
title="Mae Geri Grundtechnik",
summary="Front kick",
goal="Präzision Mae Geri",
learning_goal="Sprungvorbereitung für Mawashi Geri",
sibling_excludes=siblings,
)
assert exercise_passes_technique_path_scope(
primary_topic="mawashi geri",
title="Sprungkraft Plyometrie",
summary="Absprung",
goal="Vorbereitung gesprungener Mawashi Geri",
learning_goal="Sprungvorbereitung für Mawashi Geri",
sibling_excludes=siblings,
)
def test_stage_fit_rejects_yoko_geri_on_mawashi_roadmap_stage():
brief = build_semantic_brief("gesprungener Mawashi Geri Sprungphase")
primary = brief.primary_topic or "mawashi geri"
stage_goal = "Koordination Sprungphase Mawashi Geri"
stage_brief = build_stage_match_brief(
learning_goal=stage_goal,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary),
)
assert not exercise_passes_stage_fit(
learning_goal=stage_goal,
title="Yoko Geri seitlicher Tritt",
summary="Seitwärtskick",
goal="Yoko Geri Technik",
stage_brief=stage_brief,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary),
)
def test_strip_off_topic_removes_partial_when_most_steps_bad():
steps = [{"exercise_id": i, "title": f"E{i}"} for i in range(1, 8)]
off_topic = [{"step_index": i, "issue": "path_exclude"} for i in range(5)]
out, removed = strip_off_topic_steps_from_path(steps, off_topic, min_remaining=2)
assert len(out) == 2
assert len(removed) == 5
def test_parse_stage_goal_constraints_extracts_ohne_tritttechnik():
from planning_exercise_semantics import parse_stage_goal_constraints
c = parse_stage_goal_constraints("Koordination von Absprung und Beinhebung ohne Tritttechnik")
assert c.has_negation
assert "absprung" in c.positive_tokens
assert any("tritt" in ex for ex in c.exclude_phrases)

View File

@ -0,0 +1,121 @@
"""Tests wiederverwendbare Fähigkeiten-Erwartungen (Progressionsgraph + später Planung)."""
from unittest.mock import MagicMock
from planning_skill_expectations import (
SCOPE_PROGRESSION_PATH,
SCOPE_PROGRESSION_STAGE,
PlanningSkillExpectationInput,
apply_expectations_to_target,
build_planning_skill_expectations,
expectation_input_from_progression_path,
expectation_input_from_progression_stage,
)
class _FakeCursor:
def __init__(self, skills):
self._skills = skills
def execute(self, query, params=None):
del query
if params and len(params) >= 2:
needle = str(params[0]).strip("%").lower()
exact = str(params[1]).lower()
matches = [
s
for s in self._skills
if needle in s["name"].lower() or s["name"].lower() == exact
]
self._row = matches[0] if matches else None
else:
self._row = None
def fetchone(self):
return self._row
def _fake_skill_rows():
return [
(1, "Koordination", 0),
(2, "Timing", 0),
(3, "Distanz", 0),
(4, "Kime", 0),
]
def test_expectation_input_from_progression_stage_merges_sources(monkeypatch):
monkeypatch.setattr(
"planning_skill_expectations._load_skills_for_text_match",
lambda cur: _fake_skill_rows(),
)
inp = expectation_input_from_progression_stage(
goal_query="Kumite Beinarbeit",
goal_analysis={
"primary_topic": "Kumite",
"start_assumption": "gleichförmige Steppbewegung",
"target_state": "explosiver Angriff",
},
resolved_structured={"roadmap_notes": "Kindergruppe"},
stage_spec={
"learning_goal": "variable Rhythmen",
"load_profile": ["timing", "distanz"],
"phase": "vertiefung",
},
semantic_brief_summary={"must_phrases": ["Beinarbeit"], "primary_topic": "Kumite"},
major_step={"phase": "vertiefung", "learning_goal": "Major-Ziel"},
)
assert inp.scope == SCOPE_PROGRESSION_STAGE
assert inp.primary_topic == "Kumite"
assert inp.start_situation == "gleichförmige Steppbewegung"
assert inp.load_profile == ["timing", "distanz"]
assert "Beinarbeit" in inp.skill_hints
def test_build_planning_skill_expectations_load_profile_and_text(monkeypatch):
monkeypatch.setattr(
"planning_skill_expectations._load_skills_for_text_match",
lambda cur: _fake_skill_rows(),
)
cur = _FakeCursor(
[
{"id": 2, "name": "Timing"},
{"id": 3, "name": "Distanz"},
]
)
inp = PlanningSkillExpectationInput(
scope=SCOPE_PROGRESSION_STAGE,
primary_topic="Kumite",
goal_query="Kumite Beinarbeit mit Timing",
load_profile=["timing", "distanz"],
)
exp = build_planning_skill_expectations(cur, inp)
assert exp.scope == SCOPE_PROGRESSION_STAGE
assert "load_profile" in exp.sources or "text_match" in exp.sources
names = {it.skill_name for it in exp.items}
assert "Timing" in names or "Distanz" in names
assert exp.skill_weights
def test_expectation_input_from_progression_path_scope():
inp = expectation_input_from_progression_path(
goal_query="Mae Geri Perfektion",
goal_analysis={"primary_topic": "Mae Geri"},
resolved_structured={"start_situation": "Grundstellung", "target_state": "freier Kick"},
)
assert inp.scope == SCOPE_PROGRESSION_PATH
assert inp.start_situation == "Grundstellung"
assert inp.target_state == "freier Kick"
def test_apply_expectations_to_target_noop_without_weights():
class _Target:
skill_weights = {}
def to_summary_dict(self, cur):
return {}
target = _Target()
from planning_skill_expectations import PlanningSkillExpectations
empty = PlanningSkillExpectations(scope=SCOPE_PROGRESSION_STAGE, skill_weights={}, items=[], sources=[])
assert apply_expectations_to_target(target, empty) is target

View File

@ -0,0 +1,65 @@
"""Tests Stufen-Kontext (Start/Ziel-Verkettung) und mehrstufige QS."""
from planning_path_qa_pipeline import derive_optimization_hints, run_multistage_path_qa
from planning_progression_roadmap import MajorStep, StageSpecArtifact
from planning_stage_context import (
build_contextualized_stage_goal,
derive_stage_specs_transition_states,
)
def test_derive_stage_transition_chain():
majors = [
MajorStep(index=0, phase="grundlage", learning_goal="Stand-Mawashi", consolidates=["m1"]),
MajorStep(index=1, phase="vertiefung", learning_goal="Sprungvorbereitung", consolidates=["m2"]),
MajorStep(index=2, phase="perfektion", learning_goal="Gesprungener Mawashi", consolidates=["m3"]),
]
specs = [
StageSpecArtifact(major_step_index=0, learning_goal=majors[0].learning_goal),
StageSpecArtifact(major_step_index=1, learning_goal=majors[1].learning_goal),
StageSpecArtifact(major_step_index=2, learning_goal=majors[2].learning_goal),
]
out = derive_stage_specs_transition_states(
specs,
majors,
path_start="Anfänger mit Grundstellung",
path_target="Sauberer gesprungener Mawashi Geri",
)
assert out[0].start_state == "Anfänger mit Grundstellung"
assert out[1].start_state == out[0].target_state
assert out[2].target_state == "Sauberer gesprungener Mawashi Geri"
def test_contextualized_stage_goal_includes_path_target():
text = build_contextualized_stage_goal(
learning_goal="Sprungkoordination",
start_state="Stand-Mawashi sicher",
target_state="Explosiver Absprung",
path_target_state="Gesprungener Mawashi Geri",
stage_index=1,
stage_count=3,
)
assert "Sprungkoordination" in text
assert "Gesamtziel" in text
assert "Soll-Start" in text
def test_multistage_qa_emits_optimization_hints():
result = run_multistage_path_qa(
off_topic_steps=[],
stripped_off_topic=[
{
"step_index": 2,
"issue": "technique_scope",
"title": "Yoko Geri",
"reasons": ["Passt nicht zur Haupttechnik"],
}
],
gaps=[{"from_title": "A", "to_title": "B", "gap_score": 0.6, "is_large_gap": True}],
llm_qa={"quality_score": 0.25, "recommendations": ["Athletisches Training ergänzen"]},
llm_applied=True,
)
assert len(result["qa_tiers"]) == 3
hints = result["optimization_hints"]
assert any(h.get("action") == "rematch_slot" for h in hints)
assert any(h.get("action") == "bridge_or_gap_fill" for h in hints)
assert derive_optimization_hints(result["qa_tiers"])

View File

@ -0,0 +1,59 @@
"""Tests Planungs-Artefakt am Progressionsgraph."""
import pytest
from progression_graph_planning_artifact import (
ARTIFACT_SCHEMA_VERSION,
normalize_planning_roadmap_payload,
)
def test_normalize_planning_roadmap_minimal():
out = normalize_planning_roadmap_payload(
{
"schema_version": ARTIFACT_SCHEMA_VERSION,
"goal_query": "Mae Geri Perfektion",
"max_steps": 5,
}
)
assert out["goal_query"] == "Mae Geri Perfektion"
assert out["max_steps"] == 5
def test_normalize_planning_roadmap_with_progression_roadmap():
out = normalize_planning_roadmap_payload(
{
"goal_query": "Kumite Beinarbeit",
"progression_roadmap": {
"stage_specs": [{"major_step_index": 0, "learning_goal": "Grundstellung"}],
},
}
)
assert out["progression_roadmap"]["stage_specs"][0]["learning_goal"] == "Grundstellung"
def test_normalize_rejects_invalid_type():
with pytest.raises(ValueError, match="JSON-Objekt"):
normalize_planning_roadmap_payload("not-json")
def test_normalize_slot_contents():
out = normalize_planning_roadmap_payload(
{
"goal_query": "Gerade-Tritt",
"max_steps": 3,
"slot_contents": [
{
"major_step_index": 0,
"primary": {"kind": "library", "exercise_id": 12, "title": "Grundstellung"},
"siblings": [],
},
{
"major_step_index": 1,
"primary": {"kind": "proposal", "title": "KI-Entwurf", "proposal_key": "p1"},
"siblings": [],
},
],
}
)
assert len(out["slot_contents"]) == 2
assert out["slot_contents"][1]["primary"]["kind"] == "proposal"

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.213"
BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260607087"
APP_VERSION = "0.8.226"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -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.21.1", # start_target_only + reicher gap-fill planning_context
"planning_exercise_suggest": "0.23.1", # Phase B: Rematch-Schleife mit optimization_hints + roadmap_unfilled
"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
@ -53,6 +53,120 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.226",
"date": "2026-05-22",
"changes": [
"Progressionsgraph Phase B: Rematch-Schleife (max_rematch_rounds) mit optimization_hints und roadmap_unfilled.",
"Fix: Graph-Bewertung/Match 500 bei off-topic am Rand-Slot (collect_gap_fill_specs IndexError).",
],
},
{
"version": "0.8.225",
"date": "2026-06-07",
"changes": [
"Stufen start_state/target_state (Soll-Verkettung) + kontextualisiertes Matching.",
"Mehrstufige Pfad-QS (tier13) mit optimization_hints; Migration 090 Prompt.",
],
},
{
"version": "0.8.224",
"date": "2026-06-07",
"changes": [
"Technik-Pfad-Scope: Geschwister-Techniken (Mae/Yoko bei Mawashi) als hartes Gate in Match/QS.",
"path_primary_topic in build_stage_match_brief; Intent technique_sibling_excludes.",
],
},
{
"version": "0.8.223",
"date": "2026-06-07",
"changes": [
"planning_intent_context: gemeinsamer Intent für anti_patterns/success_criteria (Phase G-ready).",
"Migration 089: LLM-Prompts Zielanalyse + stage_spec mit Intent-Kontext; finalize nach Pipeline.",
],
},
{
"version": "0.8.222",
"date": "2026-06-07",
"changes": [
"Pfad-Ausschlüsse: athletic→Kumite-Heuristik entfernt — nur explizite Negationen und anti_patterns.",
],
},
{
"version": "0.8.221",
"date": "2026-06-07",
"changes": [
"Pfad-Ausschlüsse (keine Kumite etc.) aus Anfrage in Brief, stage_specs und Matching-Gates.",
"QS entfernt path_exclude-Schritte; partielles Strippen wenn die meisten Slots falsch sind.",
],
},
{
"version": "0.8.220",
"date": "2026-06-07",
"changes": [
"Roadmap-Stufen-Match: build_stage_match_brief + stage_semantic_score über Titel, Summary und Goal.",
"Retriever lädt Übungsziele immer bei Stufen-Match; Ranking nach Stufen-Fit statt Gesamtthema.",
],
},
{
"version": "0.8.219",
"date": "2026-06-07",
"changes": [
"Roadmap-Stufen-Gate: Negationen (ohne Tritttechnik) + Pflicht-Treffer Absprung/Beinhebung.",
"anti_patterns in Stufen-Match; Gesamt-Thema allein reicht bei strict_positive nicht mehr.",
],
},
{
"version": "0.8.218",
"date": "2026-06-07",
"changes": [
"Roadmap-Match: Stufen-Lernziel-Gate (semantic_brief_for_stage, stage_learning_goal).",
"Kein Fallback auf globale goal_query bei roadmap_first — Lücke statt falscher Übung.",
"Retrieval-Strafe/Bonus für Stufen-Passung; QS erkennt stage_mismatch.",
],
},
{
"version": "0.8.217",
"date": "2026-06-07",
"changes": [
"F9: planning_roadmap JSONB am Progressionsgraph (Migration 088).",
"PUT Graph + POST edges/sequence speichern Planungs-Artefakt; Laden beim Graph-Wechsel.",
"Roadmap-Vorschlag: include_llm_intent=true für reicheren Semantic Brief.",
"Doku: docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md als zentrale Ist-Referenz.",
],
},
{
"version": "0.8.216",
"date": "2026-06-07",
"changes": [
"F8: Editierbare stage_specs in UI (Belastung, Erfolgskriterien) → roadmap_override.",
"Erwartete Fähigkeiten als Tags pro Pfadschritt und auf Pfad-Ebene.",
],
},
{
"version": "0.8.215",
"date": "2026-06-07",
"changes": [
"F7: planning_skill_expectations — pro Stufe Retrieval, path_skill_expectations, Gap-Kontext.",
"Wiederverwendbare Scopes für spätere Trainingsplanung (training_section, framework_slot).",
],
},
{
"version": "0.8.214",
"date": "2026-06-07",
"changes": [
"F6: ExerciseGapFillPrepModal — Trainer-Ergänzungen vor KI-Entwurf.",
"context_preview und reicher goal_for_ai aus Roadmap-Snapshot.",
],
},
{
"version": "0.8.210",
"date": "2026-06-07",
"changes": [
"F5: Strukturierte Start/Ziel-Felder, Prompt 087, Zwei-Schritt-UI (start_target_only).",
"Priorität Trainer-Eingabe > KI > Regex; heuristische Start→Ziel-Roadmap.",
],
},
{
"version": "0.8.209",
"date": "2026-06-07",

View File

@ -89,38 +89,39 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
### 2.8 KI Assistenz Übungen & Planungs-KI Übungssuche (Stand **0.8.183**)
### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.217**)
**Spec / Pipeline:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md`
**Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen.
**Retrieval / Scoring:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md`
**Zielarchitektur Roadmap:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`
**Produkt-Roadmap Phase G+:** `docs/architecture/PLANNING_KI_ROADMAP.md`
| Phase | Inhalt | Status |
|-------|--------|--------|
| **P0** | Kontext-Pack, Hybrid-Score, Planungs-Picker | ✅ |
| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
| **P1** | Szenario-Pipeline + LLM Intent (`073`) + Erwartungsprofil (`074`) | ✅ |
| **P2 / B2** | LLM-Rerank (`072`) bei engem Top-Feld, max. 2 LLM-Calls | ✅ **0.8.182** |
| **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** |
| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** |
| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
| **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** |
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** |
| **E** | Semantik-Schicht (Brief, Phrasen-Score) + Pfad-QA (Lücken, Brücken, LLM-QS) | ✅ **0.8.186** |
| **E2** | Pfad-Neuordnung (LLM) + KI-Neuanlage bei unüberbrückbaren Lücken | ✅ **0.8.187** |
| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** |
| **F0F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
| **F3** | `roadmap_first` — Retrieval + QA lite (keine Brücken/Reorder) | ✅ **0.8.209** |
| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** |
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | ✅ **0.8.208** |
| **P0P2, AC2** | Übungssuche, Voll-Library, Graph-Bias, Varianten | ✅ bis **0.8.184** |
| **C3, EE3** | Pfad-Builder, Semantik, QA, Gap-Offers | ✅ bis **0.8.203** |
| **D** | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
| **F0F4** | Roadmap-Pipeline, LLM 078/079, `roadmap_first`, UI-Review | ✅ **0.8.205209** |
| **F5** | Start/Ziel strukturiert, LLM **087**, Zwei-Schritt-UI | ✅ **0.8.210214** |
| **F6** | Gap-Prep-Modal, reicher KI-Kontext (`planning_exercise_form_context`) | ✅ **0.8.212214** |
| **F7** | `planning_skill_expectations` — Retrieval + UI + Gap | ✅ **0.8.215216** |
| **F8** | Editierbare `stage_specs` (Belastung, Erfolgskriterien) | ✅ **0.8.216** |
| **F9** | `planning_roadmap` JSONB am Graph (Migration **088**) | ✅ **0.8.217** |
**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`**
**Architektur (verbindlich):** Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2, **kein** automatisches Erweitern ab letztem Knoten (siehe Ist-Doku §5). Trainingsplanung = **eigene Pipeline** (Phase G), wiederverwendet `planning_skill_expectations`.
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_path_builder.py`, **`planning_progression_roadmap.py`** · Router `POST /api/planning/exercise-suggest`, `POST /api/planning/progression-path-suggest` (`roadmap_first`, `include_roadmap_preview`)
**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py`
**Frontend:** `ExerciseProgressionPathBuilder` — Roadmap-Box + Pfad je Major Step (`roadmap_first`) · `ExercisePickerModal` (Planung)
**API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`) · `POST …/edges/sequence`
**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow
**Frontend:** `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `planningContextForExerciseAi.js`
**Offen (F4+):** Roadmap-UI editierbar; Trainingsplanung eigene Pipeline (Gruppenkontext); Enrichment
**Offen (priorisiert):**
1. UI-Wizard (Scroll-Monolith → 4 Schritte) — **separater UI-Chat**
2. Graph-Erweiterungsmodus (Start ab Knoten)
3. Trainingsplanung Phase G (Gruppenkontext)
4. Kontext-Anzeige auf allen Pfad-Schritten
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)

View File

@ -1,10 +1,11 @@
# Planungs-KI — Produkt-Roadmap
**Stand:** 2026-06-07
**App-Version:** ab **0.8.204** — maßgeblich `backend/version.py`
**Stand:** 2026-05-22
**App-Version:** **0.8.217** — maßgeblich `backend/version.py`
Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**.
**Ist-Stand Progressionsgraph (detailliert):** `PLANNING_PROGRESSION_GRAPH_KI.md`
**Leit-Spec:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`
---
@ -26,9 +27,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
| AC2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
| EE3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
| **F0F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
| **F2F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 |
| **F0F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap-first, UI Review | ✅ **0.8.204209** |
| **F5F9** | Progressionsgraph | Start/Ziel, Gap-Prep, Skill-Expectations, Persistenz | ✅ **0.8.210217** |
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
| **UX** | Progressionsgraph | Wizard/Stepper statt Scroll-UI | 🔲 |
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0S4 | 🔲 |
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog |
@ -70,6 +72,40 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
- [x] API `roadmap_only` + `roadmap_override`
### F5 — Start/Ziel (0.8.210214)
- [x] Strukturierte Felder `start_situation`, `target_state`, `roadmap_notes`
- [x] Prompt **087** `planning_progression_start_target`
- [x] Priorität: Trainer > KI > Regex (`resolve_roadmap_structured_input`)
- [x] Zwei-Schritt-UI: „Start/Ziel analysieren“ / „Roadmap vorschlagen“
### F6 — Gap-KI-Kontext (0.8.212214)
- [x] `ExerciseGapFillPrepModal` vor KI-Call
- [x] `planning_exercise_form_context.py` — Gap-Snapshot, `context_preview`
- [x] Migration **085**`planning_context` in Übungs-Prompts
### F7 — Fähigkeiten-Scoring (0.8.215216)
- [x] `planning_skill_expectations.py` (Scopes: `progression_stage`, `progression_path`)
- [x] Pro-Stufe-Retrieval + `path_skill_expectations` + UI-Tags
- [x] `expected_skills` in Gap-Fill
### F8 — Stufen-Details UI (0.8.216)
- [x] Editierbare `stage_specs` in `roadmap_override` (Belastung, Erfolgskriterien, Vermeiden)
### F9 — Persistenz (0.8.217)
- [x] Migration **088**`planning_roadmap` JSONB am Graph
- [x] Laden/Speichern über `GET/PUT` Graph + Sequenz-Endpoint
### UX — UI-Überarbeitung (offen)
- [ ] Wizard mit 4 Schritten (Ziel → Roadmap → Match → Lücken)
- [ ] Progressive disclosure — Details in Panels, nicht alles gleichzeitig
- [ ] Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §10
---
## Abhängigkeiten
@ -85,4 +121,4 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
## Pflege
Bei Abschluss einer Teilphase: diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`.
Bei Abschluss einer Teilphase: **`PLANNING_PROGRESSION_GRAPH_KI.md`** (Ist-Stand), diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`.

View File

@ -0,0 +1,368 @@
# Progressionsgraph — KI-Planung (Ist-Stand)
**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.218** · **DB:** Migration **088**
**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`)
> **Diese Datei ist die zentrale Referenz** für die KI-gestützte Planung im Progressionsgraph.
> Ältere Abschnitte in `HANDOVER.md` §2.8 und `PLANNING_KI_ROADMAP.md` verweisen hierher.
**Bezüge:**
`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` (Zielarchitektur) ·
`.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` (Retrieval/Scoring) ·
`.claude/docs/technical/SKILL_SCORING_SPEC.md` (Fähigkeiten-Scoring) ·
`docs/architecture/PLANNING_KI_ROADMAP.md` (Produkt-Roadmap Phase G+)
---
## 1. Fachliche Abgrenzung
| Thema | Progressionsgraph (dieses Feature) | Trainingsplanung (Phase G, später) |
|--------|-------------------------------------|-------------------------------------|
| Ziel | Curriculum / Technikpfad über Übungen | Konkrete Trainingseinheit für Gruppe |
| Kontext | Zieltext, Start/Ziel, Roadmap, optional Graph-Kanten | Gruppe, Historie, Termin, Rahmen |
| Pipeline | `planning_progression_roadmap.py` → Path-Builder | Eigene Pipeline (noch offen) |
| Gruppenanalyse | **Nein** | **Ja** |
| Wiederverwendung | `planning_skill_expectations.py`, `planning_exercise_form_context.py` | Gleiche Bausteine, andere Scopes |
**Shinkan-Regel:** Der Progressionsgraph ist **keine** persönliche Tracking-App und plant **nicht** für einzelne Sportler.
---
## 2. Trainer-Workflow (UI)
Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgressionGraphPanel.jsx`):
```
① Ziel eingeben (+ optional Start/Ziel-Felder manuell)
② „Start/Ziel analysieren“ (optional, start_target_only)
③ „Roadmap vorschlagen“ (roadmap_only, LLM-Roadmap)
④ Roadmap bearbeiten (Major Steps + Stufen-Details)
⑤ „Übungen matchen“ (roadmap_first + roadmap_override)
⑥ Lücken mit KI schließen (gap_fill_offers + Vorbereitungs-Dialog)
⑦ „Pfad in Graph speichern“ (Sequenz-Kanten)
```
**Bekannte UX-Schuld:** Alle Schritte liegen auf **einer langen Scroll-Seite** — Überarbeitung als Wizard/Stepper ist geplant (separater UI-Chat). Briefing-Vorlage siehe unten §10.
---
## 3. Architektur (Module)
```mermaid
flowchart TB
subgraph ui [Frontend]
EPB[ExerciseProgressionPathBuilder]
GFM[ExerciseGapFillPrepModal]
PCtx[planningContextForExerciseAi.js]
end
subgraph api [API]
PPS[POST /api/planning/progression-path-suggest]
EAI[POST /api/exercises/ai/suggest]
SEQ[POST /api/exercise-progression-graphs/:id/edges/sequence]
PUT[PUT /api/exercise-progression-graphs/:id]
end
subgraph roadmap [Roadmap-Pipeline]
PR[planning_progression_roadmap.py]
ST087[Prompt 087 start_target]
ST078[Prompts 078/079 roadmap + stage_spec]
end
subgraph match [Match + QA]
PB[planning_exercise_path_builder.py]
RET[planning_exercise_retrieval.py]
PG[planning_exercise_progression.py]
SEM[planning_exercise_semantics.py]
end
subgraph skills [Fähigkeiten — wiederverwendbar]
PSE[planning_skill_expectations.py]
PEFC[planning_exercise_form_context.py]
AIF[planning_exercise_path_ai_fill.py]
end
subgraph persist [Persistenz Graph]
G088[(planning_roadmap JSONB — Migration 088)]
EDGES[(exercise_progression_edges)]
end
EPB --> PPS
EPB --> SEQ
EPB --> PUT
GFM --> EAI
PPS --> PR
PPS --> PB
PB --> RET
PB --> PG
PB --> PSE
AIF --> PEFC
PSE --> RET
SEQ --> EDGES
PUT --> G088
SEQ --> G088
```
### 3.1 Verantwortlichkeiten
| Modul | Aufgabe |
|--------|---------|
| `planning_progression_roadmap.py` | Phasen AC: Zielanalyse, Roadmap, `stage_specs`; Start/Ziel-Auflösung (Trainer > KI > Regex) |
| `planning_exercise_path_builder.py` | `suggest_progression_path`: roadmap_first Match, QA, Gap-Offers |
| `planning_exercise_progression.py` | Graph auflösen, Nachfolger-Kanten für Retrieval-Bias |
| `planning_skill_expectations.py` | Skill-Erwartungen pro Scope (`progression_stage`, `progression_path`, später `training_section`) |
| `planning_exercise_form_context.py` | `planning_context` / Gap-Snapshot für Übungs-KI |
| `planning_exercise_path_ai_fill.py` | Gap-Fill-Angebote, `goal_for_ai`, `context_preview` |
| `progression_graph_planning_artifact.py` | Validierung `planning_roadmap` JSON (Schema v1, max. 64 KB) |
---
## 4. API — `POST /api/planning/progression-path-suggest`
### 4.1 Wichtige Request-Flags
| Feld | Typ | Bedeutung |
|------|-----|-----------|
| `query` | string | Ziel / Entwicklungsrichtung (min. 3 Zeichen) |
| `max_steps` | int | Anzahl Major Steps (210) |
| `progression_graph_id` | int? | Gewählter Graph — siehe §5 |
| `roadmap_first` | bool | Match pro `stage_spec` statt iterativem Pfad |
| `roadmap_only` | bool | Nur Roadmap, keine Übungen |
| `start_target_only` | bool | Nur Start/Ziel-Analyse |
| `roadmap_override` | object | Trainer-bearbeitete `major_steps` + `stage_specs` |
| `start_situation`, `target_state`, `roadmap_notes` | string? | Strukturierte Eingabe (Priorität vor KI) |
| `include_llm_start_target` | bool | LLM-Extraktion Start/Ziel (Prompt **087**) |
| `include_llm_roadmap` | bool | LLM Roadmap (Prompts **078/079**) |
| `include_llm_intent` | bool | LLM Intent für Semantic Brief (Roadmap-Vorschlag: **true** seit 0.8.217) |
| `include_path_qa`, `include_ai_gap_fill` | bool | QS, Lücken-Angebote |
### 4.2 Wichtige Response-Felder
| Feld | Bedeutung |
|------|-----------|
| `progression_roadmap` | Artefakte A/B/C inkl. `resolved_structured`, `stage_specs`, `prompt_slugs` |
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
| `path_qa` | QS inkl. `roadmap_qa_mode: roadmap_first_lite` |
### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding)
| Slug | Migration | Phase |
|------|-----------|-------|
| `planning_progression_start_target` | **087** | Start/Ziel-Extraktion |
| `planning_progression_goal_analysis` | **078** | Zielanalyse |
| `planning_progression_roadmap` | **078** | Roadmap (micro → major) |
| `planning_progression_stage_spec` | **079** | Stufenspezifikation |
---
## 5. Roadmap-Match — Stufen-Qualität (0.8.218)
Pro Major Step gilt:
1. **Stufen-Brief**`semantic_brief_for_stage()` ergänzt `must_phrases` um das Stufen-Lernziel.
2. **Stufen-Gate**`exercise_passes_stage_learning_goal_gate()` prüft Lernziel-Text in Titel/Summary/Ziel oder Mindest-`semantic_score`.
3. **Kein Fallback** — Bei `roadmap_first` wird **nicht** auf die globale `goal_query` zurückgefallen; passt keine Übung → **Lücke** (`roadmap_unfilled`) statt themenfremder Übung.
4. **Retrieval** — Bonus/Strafe im Hybrid-Score je nach Stufen-Passung.
5. **QS**`detect_off_topic_steps` erkennt `stage_mismatch` anhand `roadmap_learning_goal`.
Tests: `test_planning_roadmap_stage_match.py`
---
## 6. Rolle des bestehenden Graphs
**Wichtig — häufiges Missverständnis:**
| Aspekt | Verhalten |
|--------|-----------|
| **KI-Pfadvorschlag** | Baut einen **neuen** Übungspfad aus der **Bibliothek** (Roadmap-first), nicht aus vorhandenen Graph-Knoten als Start |
| **Schritt 1** | Kein Anker → **kein** Graph-Bias |
| **Ab Schritt 2** | Anker = vorherige Übung im Vorschlag → ausgehende Kanten im Graph werden geladen |
| **Scoring** | Nachfolger im Graph: Bonus `progression` (ca. **410 %** Gewicht, Semantik/Profil dominieren) |
| **Speichern** | `POST …/edges/sequence` **fügt Kanten hinzu** — ersetzt den Graph nicht |
| **Nicht implementiert** | „Ab letztem Graph-Knoten erweitern“, bestehende Sequenz als Pfad-Start |
Code: `planning_exercise_progression.py``apply_progression_context_to_pack``planning_exercise_retrieval.py` (`prog_hit`).
---
## 7. Persistenz
### 6.1 Kanten (`exercise_progression_edges`)
- Übungsfolge als gerichtete Kanten `next_exercise`
- Varianten-aware (Migration **034**)
- Kanten-Notizen aus Pfad-Begründungen oder Fallback-`segment_notes`
### 6.2 Planungs-Artefakt (`planning_roadmap` JSONB, Migration **088**)
Gespeichert am **Graph-Container** (`exercise_progression_graphs`), Schema v1:
```json
{
"schema_version": 1,
"goal_query": "…",
"start_situation": "…",
"target_state": "…",
"roadmap_notes": "…",
"max_steps": 5,
"progression_roadmap": { },
"path_skill_expectations": { }
}
```
| Aktion | API |
|--------|-----|
| Speichern (Roadmap/Match) | `PUT /api/exercise-progression-graphs/:id` |
| Speichern (mit Pfad) | `POST …/edges/sequence` (optional `planning_roadmap` im Body) |
| Laden | `GET /api/exercise-progression-graphs/:id` → Frontend stellt Workflow-Felder wieder her |
Validierung: `progression_graph_planning_artifact.py` · Tests: `test_progression_graph_planning_artifact.py`
**`planning_roadmap` ≠ Graph-Kanten:** Metadaten für Wiederaufnahme der KI-Planung, nicht der Übungsgraph selbst.
---
## 8. Planungs-Intent (gemeinsam mit Trainingsplanung)
Modul: **`planning_intent_context.py`** — domänenneutral, wiederverwendbar in Phase G.
| Baustein | Progressionsgraph heute | Trainingsplanung (Phase G) |
|----------|-------------------------|----------------------------|
| `build_planning_intent_context` | Aus `goal_query`, Zielanalyse, Notizen | Aus `section_guidance`, Slot, Einheit |
| `explicit_exclusions` | Negationen (`ohne/kein/nicht …`) | gleich |
| `path_anti_patterns` | → jede `stage_spec.anti_patterns` | → Abschnitts-/Slot-Brief |
| `path_success_criteria` | → `success_criteria` + Matching | → Slot-Erwartung |
| `finalize_stage_specs_with_intent` | Nach heuristischer/LLM-Roadmap | Analog für Sektionen |
LLM: Migration **089** — Prompts `planning_progression_goal_analysis` + `planning_progression_stage_spec` erhalten `intent_context_json`; Ausschlüsse nicht erfinden, nur aus Anfrage übernehmen.
Matching: `anti_patterns` + `success_criteria``build_stage_match_brief` → Retrieval-Gate (Titel + Summary + Goal).
### Stufen im Gesamtziel (`planning_stage_context.py`)
| Feld | Bedeutung |
|------|-----------|
| `start_state` | Soll-Start der Stufe (= `target_state` der Vorstufe / Pfad-Start) |
| `target_state` | Ziel nach dieser Stufe (= Soll für den nächsten Schritt) |
| `build_contextualized_stage_goal()` | Lernziel + Start + Stufen-Ziel + Gesamtziel → Brief/Retrieval |
Deterministisch: `derive_stage_specs_transition_states()` nach Roadmap-Pipeline; LLM kann Felder überschreiben (Prompt **090**).
### Mehrstufige Pfad-QS (`planning_path_qa_pipeline.py`)
| Stufe | Inhalt | Ableitung |
|-------|--------|-----------|
| **tier1** | Deterministische Gates (Technik-Scope, Ausschlüsse, unfilled) | `optimization_hints``rematch_slot`, `refine_stage_spec` |
| **tier2** | Übergangs-Lücken zwischen Schritten | `bridge_or_gap_fill` |
| **tier3** | LLM-Ganzpfad + Empfehlungen | `review_roadmap` |
API: `path_qa.qa_tiers`, `path_qa.optimization_hints`**kein** anfrage-spezifischer Patch, sondern strukturierte Rückkopplung. Auto-Rematch-Schleife: Backlog (QS → Aktion → erneutes Match).
Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match.
## 9. Fähigkeiten-Scoring-Anbindung
Modul: `planning_skill_expectations.py`
| Scope | Verwendung heute | Phase G (vorbereitet) |
|-------|------------------|------------------------|
| `progression_path` | Einmaliges Pfad-Profil (`path_skill_expectations`) | — |
| `progression_stage` | Pro Roadmap-Stufe vor Retrieval + in Gap-Kontext | — |
| `training_section` | — | Trainingsabschnitt |
| `framework_slot` | — | Rahmen-Slot |
Quellen: `semantic_topic`, `text_match`, `load_profile` (aus `stage_spec`).
Integration:
- Retrieval: `apply_expectations_to_target` → Hybrid-Score
- UI: Tags pro Pfadschritt + Pfad-Header; Gap-Kontext „Erwartete Fähigkeiten“
- KI-Neuanlage: `expected_skills` in `context_preview` / `goal_for_ai`
---
## 10. KI-Lücken (Gap-Fill)
Flow:
1. `roadmap_unfilled` / QA-Lücken → `gap_fill_offers`
2. Trainer: **„Vorbereiten & KI anlegen“** → `ExerciseGapFillPrepModal` (Titel, Stufen-Lernziel, Ergänzungen)
3. `POST /api/exercises/ai/suggest` mit `planning_context`
4. Vorschau → Übung anlegen → in Pfad einfügen
Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
---
## 11. Implementierungsstände (Phasen)
| Phase | Inhalt | Status | Version |
|-------|--------|--------|---------|
| F0F2 | Roadmap-Pipeline + LLM-Prompts 078/079 | ✅ | 0.8.204205 |
| F3 | `roadmap_first` Match pro Stufe | ✅ | 0.8.206209 |
| F4 | Roadmap-Review UI + `roadmap_override` | ✅ | 0.8.207 |
| F5 | Start/Ziel strukturiert + LLM **087** + Zwei-Schritt-UI | ✅ | 0.8.210214 |
| F6 | Gap-Prep-Modal + reicher `planning_context` | ✅ | 0.8.212214 |
| F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215216 |
| F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 |
| F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 |
| F10 | Stufen-Lernziel-Gate beim Match (kein goal_query-Fallback) | ✅ | 0.8.218 |
| **G** | Trainingsplanung eigene Pipeline + Graph-Referenz | 🔲 | — |
| **UX** | Wizard/Stepper statt Scroll-Monolith | 🔲 | separater Chat |
---
## 12. Offenes Backlog (priorisiert)
1. **UI-Überarbeitung** — Wizard mit 4 Schritten, progressive disclosure (Briefing unten)
2. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
3. **Kontext auf allen Pfad-Schritten** in UI (nicht nur Lücken)
4. **Trainingsplanung Phase G** — Pipeline mit Gruppenkontext, Wiederverwendung `planning_skill_expectations`
5. Enrichment / Prompt-Feintuning
6. Mitai Workflow-Engine (langfristig)
### Briefing-Vorlage UI-Chat (Copy-Paste)
Siehe Nutzer-Chat 2026-05-22 oder `HANDOVER.md` §2.8 — Abschnitt „UI-Überarbeitung“.
Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen.
---
## 13. Tests
| Datei | Abdeckung |
|-------|-----------|
| `test_planning_progression_roadmap.py` | Roadmap-Pipeline, Override, Start/Ziel |
| `test_planning_exercise_path_builder.py` | Pfad-Annotierung, Skill-Expectations auf Steps |
| `test_planning_skill_expectations.py` | Skill-Erwartungen, Scopes |
| `test_planning_intent_context.py` | Intent-Kontext, finalize stage_specs |
| `test_planning_exercise_path_ai_fill.py` | Gap-Fill, `expected_skills` in goal text |
| `test_planning_exercise_form_context.py` | `planning_context`, Gap-Snapshot |
| `test_progression_graph_planning_artifact.py` | JSONB-Artefakt-Validierung |
| `test_planning_exercise_progression.py` | Graph-Auflösung, Nachfolger |
---
## 14. Dokumenten-Index (Drift vermeiden)
| Frage | Primäre Quelle |
|-------|----------------|
| Was ist der aktuelle Ist-Stand? | **Diese Datei** |
| Zielarchitektur / JSON-Artefakte | `PLANNING_PROGRESSION_ROADMAP_SPEC.md` |
| Retrieval, Hybrid-Score, Intents | `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` |
| Produkt-Roadmap Phase G+ | `PLANNING_KI_ROADMAP.md` |
| Session-Handover / nächste Schritte | `docs/HANDOVER.md` §2.8 |
| Fähigkeiten-Scoring allgemein | `SKILL_SCORING_SPEC.md` |
| Versionen / Changelog | `backend/version.py` |
**Pflege-Regel:** Bei jeder abgeschlossenen Teilphase diese Datei + `HANDOVER.md` §2.8 + `version.py` CHANGELOG aktualisieren.
---
## 15. Changelog (Dokument)
| Datum | Änderung |
|-------|----------|
| 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5F9 |

View File

@ -14,7 +14,8 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
| [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) |
| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F Roadmap-first, Abgrenzung Trainingsplanung) |
| [PLANNING_PROGRESSION_GRAPH_KI.md](./PLANNING_PROGRESSION_GRAPH_KI.md) | **Progressionsgraph-KI Ist-Stand** (Module, API, Graph-Verhalten, Persistenz — zentrale Referenz) |
| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase FG, Abgrenzung Trainingsplanung) |
| [Progressions-Roadmap Spec](../../.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md) | Phase F: Artefakte A→B→C, API, Workflow-lite |
## Tests (E2E / Refaktor-Budget)

View File

@ -31,6 +31,7 @@ const SettingsSystemInfoPage = lazy(() => import('./pages/SettingsSystemInfoPage
const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
const ProgressionGraphEditPage = lazy(() => import('./pages/ProgressionGraphEditPage'))
const ClubsPage = lazy(() => import('./pages/ClubsPage'))
const InboxPage = lazy(() => import('./pages/InboxPage'))
const SkillsPage = lazy(() => import('./pages/SkillsPage'))
@ -244,6 +245,7 @@ const appRouter = createBrowserRouter([
{ path: 'settings/system', element: <SettingsSystemInfoPage /> },
{ path: 'settings/legal', element: <SettingsLegalPage /> },
{ path: 'media', element: <MediaLibraryPage /> },
{ path: 'progression-graphs/:id', element: <ProgressionGraphEditPage /> },
{
path: 'exercises',
children: [

View File

@ -521,6 +521,16 @@ export async function getExerciseProgressionGraph(id, { includeEdges = false } =
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
export async function getProgressionGraphVisibilityPromotionCandidates(
graphId,
{ targetVisibility = 'club' } = {},
) {
const q = new URLSearchParams({ target_visibility: targetVisibility })
return request(
`/api/exercise-progression-graphs/${graphId}/visibility-promotion-candidates?${q}`,
)
}
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,516 @@
/**
* Bearbeitbare Darstellung linearer Progressions-Reihen im Graphen.
*/
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
function emptyNode() {
return {
exerciseId: null,
exerciseTitle: '',
variantId: null,
variantName: null,
variants: [],
}
}
function chainToDraft(chain) {
return {
key: `chain-${chain.edges[0]?.id ?? 'x'}`,
edgeIds: chain.edges.map((e) => e.id),
segmentNotes: chain.edges.map((e) => e.notes || ''),
nodes: chain.nodes.map((n) => ({
exerciseId: n.exercise_id,
exerciseTitle: n.title || `Übung #${n.exercise_id}`,
variantId: n.variant_id ?? null,
variantName: n.variant_name ?? null,
variants: [],
})),
dirty: false,
isNew: false,
}
}
function newChainDraft() {
const key = `new-${Date.now()}`
return {
key,
edgeIds: [],
segmentNotes: [],
nodes: [emptyNode(), emptyNode()],
dirty: true,
isNew: true,
}
}
function formatNodeLabel(node) {
if (!node.exerciseId) return '— Übung wählen —'
return (
<>
<Link to={`/exercises/${node.exerciseId}`}>{node.exerciseTitle}</Link>
{node.variantName ? (
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${node.variantName}`}</span>
) : null}
</>
)
}
const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
{
graphId,
chains = [],
busy = false,
anchorExerciseId = null,
anchorTitle = null,
onRefresh,
onPickExercise,
loadVariantsForExercise,
singlePathMode = false,
},
ref,
) {
const [drafts, setDrafts] = useState([])
const [savingKey, setSavingKey] = useState(null)
const chainSignature = useMemo(
() =>
chains
.map((c) => c.edges.map((e) => e.id).join(','))
.join('|'),
[chains],
)
useEffect(() => {
setDrafts(chains.map(chainToDraft))
}, [chainSignature, chains])
const patchDraft = useCallback((key, patchFn) => {
setDrafts((prev) =>
prev.map((d) => {
if (d.key !== key) return d
const next = patchFn(d)
return { ...next, dirty: true }
}),
)
}, [])
const moveNode = (key, idx, dir) => {
patchDraft(key, (d) => {
const j = idx + dir
if (j < 0 || j >= d.nodes.length) return d
const nodes = [...d.nodes]
const t = nodes[idx]
nodes[idx] = nodes[j]
nodes[j] = t
return { ...d, nodes }
})
}
const removeNode = (key, idx) => {
patchDraft(key, (d) => {
if (d.nodes.length <= 2) return d
const nodes = d.nodes.filter((_, i) => i !== idx)
const segmentNotes = d.segmentNotes.slice(0, Math.max(0, nodes.length - 1))
return { ...d, nodes, segmentNotes }
})
}
const setVariant = (key, idx, variantId) => {
patchDraft(key, (d) => ({
...d,
nodes: d.nodes.map((n, i) => {
if (i !== idx) return n
const v = (n.variants || []).find((x) => Number(x.id) === Number(variantId))
return {
...n,
variantId: variantId === '' || variantId == null ? null : Number(variantId),
variantName: v?.variant_name || null,
}
}),
}))
}
const applyExerciseToNode = useCallback(async (key, idx, ex) => {
const title = ex.title || `Übung #${ex.id}`
const variants =
Array.isArray(ex.variants) && ex.variants.length
? ex.variants
: await loadVariantsForExercise(ex.id)
const variantId = ex.exercise_variant_id ?? ex.suggested_variant_id ?? null
patchDraft(key, (d) => ({
...d,
nodes: d.nodes.map((n, i) =>
i === idx
? {
exerciseId: ex.id,
exerciseTitle: title,
variantId: variantId != null ? Number(variantId) : null,
variantName:
variantId != null
? variants.find((v) => Number(v.id) === Number(variantId))?.variant_name || null
: null,
variants,
}
: n,
),
}))
}, [patchDraft, loadVariantsForExercise])
const insertNodeAfter = (key, idx) => {
patchDraft(key, (d) => {
const nodes = [...d.nodes]
nodes.splice(idx + 1, 0, emptyNode())
return { ...d, nodes }
})
onPickExercise({ kind: 'chain', draftKey: key, nodeIndex: idx + 1 })
}
const addNewChain = () => {
setDrafts((prev) => [...prev, newChainDraft()])
}
const discardDraft = (key) => {
setDrafts((prev) => {
const draft = prev.find((d) => d.key === key)
if (!draft) return prev
if (draft.isNew) return prev.filter((d) => d.key !== key)
const original = chains.find((c) => `chain-${c.edges[0]?.id}` === key)
if (!original) return prev.filter((d) => d.key !== key)
return prev.map((d) => (d.key === key ? chainToDraft(original) : d))
})
}
const deleteChain = async (draft) => {
if (!graphId) return
if (draft.isNew) {
setDrafts((prev) => prev.filter((d) => d.key !== draft.key))
return
}
if (!draft.edgeIds.length) return
if (!window.confirm(`Reihe mit ${draft.nodes.length} Schritten löschen?`)) return
setSavingKey(draft.key)
try {
await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
await onRefresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setSavingKey(null)
}
}
const saveDraft = async (draft) => {
if (!graphId) return
const steps = draft.nodes.filter((n) => n.exerciseId != null)
if (steps.length < 2) {
alert('Mindestens zwei Schritte mit gewählter Übung.')
return
}
const n = steps.length - 1
let segment_notes = draft.segmentNotes.slice(0, n)
while (segment_notes.length < n) segment_notes.push(null)
segment_notes = segment_notes.slice(0, n)
setSavingKey(draft.key)
try {
if (!draft.isNew && draft.edgeIds.length) {
await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
}
await api.createExerciseProgressionSequence(graphId, {
steps: steps.map((s) => ({
exercise_id: s.exerciseId,
variant_id: s.variantId || null,
})),
segment_notes,
})
await onRefresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setSavingKey(null)
}
}
const ensureVariantsLoaded = async (key, idx) => {
const draft = drafts.find((d) => d.key === key)
const node = draft?.nodes[idx]
if (!node?.exerciseId || (node.variants || []).length) return
const variants = await loadVariantsForExercise(node.exerciseId)
setDrafts((prev) =>
prev.map((d) => {
if (d.key !== key) return d
return {
...d,
nodes: d.nodes.map((n, i) => (i === idx ? { ...n, variants } : n)),
}
}),
)
}
useImperativeHandle(
ref,
() => ({
applyExercise: applyExerciseToNode,
}),
[applyExerciseToNode],
)
if (!graphId) return null
return (
<div style={{ marginBottom: '16px' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '12px',
}}
>
<div>
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>
{singlePathMode ? 'Manuell bearbeiten' : 'Reihen im Graph'}
</h4>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '4px 0 0', lineHeight: 1.45 }}>
Schritt 1 2 : Reihenfolge ändern, Übungen tauschen oder dazwischen einfügen, dann speichern.
</p>
</div>
{!singlePathMode || drafts.length === 0 ? (
<button type="button" className="btn btn-secondary" disabled={busy} onClick={addNewChain}>
{singlePathMode ? '+ Pfad anlegen' : '+ Neue Reihe'}
</button>
) : null}
</div>
{drafts.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0, fontSize: '13px' }}>
{singlePathMode
? 'Noch kein gespeicherter Pfad — manuell anlegen oder mit dem KI-Planer unten.'
: 'Noch keine Reihen in diesem Graph.'}
</p>
) : (
drafts.map((draft, chainIdx) => (
<div
key={draft.key}
style={{
marginBottom: '16px',
padding: '14px',
borderRadius: '10px',
border: `1px solid ${
draft.dirty
? 'color-mix(in srgb, var(--accent) 50%, var(--border))'
: 'var(--border)'
}`,
background: 'var(--surface2)',
}}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginBottom: '12px',
}}
>
<strong style={{ fontSize: '13px' }}>
{singlePathMode ? 'Gespeicherter Pfad' : `Reihe ${chainIdx + 1}`}
</strong>
{draft.dirty ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Ungespeichert
</span>
) : null}
{draft.isNew ? (
<span className="exercise-tag">Neu</span>
) : (
<span className="exercise-tag">{draft.nodes.length} Schritte</span>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
{draft.nodes.map((node, idx) => (
<React.Fragment key={`${draft.key}-node-${idx}`}>
{idx > 0 ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 0 4px 12px',
color: 'var(--accent)',
fontSize: '12px',
fontWeight: 700,
}}
aria-hidden
>
Nachfolger
</div>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '10px',
alignItems: 'end',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface)',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Schritt {idx + 1}</label>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '13px', flex: '1 1 140px' }}>
{formatNodeLabel(node)}
</span>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={busy || savingKey === draft.key}
onClick={() =>
onPickExercise({ kind: 'chain', draftKey: draft.key, nodeIndex: idx })
}
>
{node.exerciseId ? 'Tauschen…' : 'Übung…'}
</button>
{anchorExerciseId != null ? (
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={busy || savingKey === draft.key}
onClick={async () => {
const variants = await loadVariantsForExercise(anchorExerciseId)
await applyExerciseToNode(draft.key, idx, {
id: anchorExerciseId,
title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
variants,
})
}}
>
Kontext-Übung
</button>
) : null}
</div>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Variante</label>
<select
className="form-input"
disabled={!node.exerciseId || busy || savingKey === draft.key}
value={node.variantId ?? ''}
onFocus={() => ensureVariantsLoaded(draft.key, idx)}
onChange={(e) =>
setVariant(
draft.key,
idx,
e.target.value === '' ? null : parseInt(e.target.value, 10),
)
}
>
<option value="">Gesamte Übung</option>
{(node.variants || []).map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
disabled={busy || savingKey === draft.key || idx === 0}
onClick={() => moveNode(draft.key, idx, -1)}
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
disabled={busy || savingKey === draft.key || idx >= draft.nodes.length - 1}
onClick={() => moveNode(draft.key, idx, 1)}
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
disabled={busy || savingKey === draft.key}
onClick={() => insertNodeAfter(draft.key, idx)}
>
+ Einfügen
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
disabled={busy || savingKey === draft.key || draft.nodes.length <= 2}
onClick={() => removeNode(draft.key, idx)}
>
Entfernen
</button>
</div>
</div>
</React.Fragment>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '12px' }}>
<button
type="button"
className="btn btn-primary"
disabled={busy || savingKey === draft.key || !draft.dirty}
onClick={() => saveDraft(draft)}
>
{savingKey === draft.key ? 'Speichern …' : singlePathMode ? 'Pfad speichern' : 'Reihe speichern'}
</button>
{draft.dirty ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy || savingKey === draft.key}
onClick={() => discardDraft(draft.key)}
>
Verwerfen
</button>
) : null}
<button
type="button"
className="btn"
style={{
fontSize: '12px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
disabled={busy || savingKey === draft.key}
onClick={() => deleteChain(draft)}
>
{singlePathMode ? 'Pfad löschen' : 'Reihe löschen'}
</button>
</div>
</div>
))
)}
</div>
)
})
export default ProgressionChainEditor

View File

@ -0,0 +1,259 @@
/**
* Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2).
*/
import React, { useMemo, useState } from 'react'
import {
offerCanExpandSlots,
offerNeedsNewSlot,
offerSourceLabel,
resolveOfferSlotIndex,
} from '../utils/progressionGraphDraft'
function severityStyle(pathQa) {
if (!pathQa) return {}
return {
background: pathQa.overall_ok
? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))'
: 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
}
}
function GapOfferCard({
offer,
slotCount,
draft,
onApplyDraft,
onInsertSlot,
onGenerateAi,
generatingOfferId,
aiBusy,
}) {
const defaultSlot = resolveOfferSlotIndex(draft, offer)
const [slotPick, setSlotPick] = useState(
defaultSlot != null && Number.isFinite(defaultSlot) ? String(defaultSlot) : '',
)
const needsInsert = offerNeedsNewSlot(offer)
const canInsert = offerCanExpandSlots(draft, offer)
const slotOptions = useMemo(() => {
const rows = []
for (let i = 0; i < slotCount; i += 1) {
rows.push({ value: String(i), label: `Slot ${i + 1}` })
}
return rows
}, [slotCount])
const applyToSlot = () => {
const idx = slotPick !== '' ? Number(slotPick) : defaultSlot
if (!Number.isFinite(idx)) {
alert('Bitte einen Slot wählen.')
return
}
onApplyDraft(offer, idx)
}
return (
<li
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
{offerSourceLabel(offer.source)}
{offer.phase ? ` · ${offer.phase}` : ''}
{offer.has_ai_payload ? ' · KI-Entwurf bereit' : ''}
</span>
<div style={{ fontWeight: 600 }}>{offer.title_hint || offer.proposal_title || 'Übungsvorschlag'}</div>
{offer.rationale ? (
<p style={{ margin: '4px 0 0', color: 'var(--text2)' }}>{offer.rationale}</p>
) : null}
{offer.from_title && offer.to_title ? (
<p style={{ margin: '4px 0 0', color: 'var(--text3)', fontSize: '11px' }}>
Zwischen {offer.from_title} und {offer.to_title}
</p>
) : null}
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<label style={{ fontSize: '11px', color: 'var(--text3)' }}>
Ziel-Slot
<select
className="form-input"
style={{ marginLeft: '6px', padding: '4px 8px', fontSize: '12px', minWidth: '100px' }}
value={slotPick}
onChange={(e) => setSlotPick(e.target.value)}
>
<option value=""> wählen </option>
{slotOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
</div>
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{offer.has_ai_payload ? (
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => applyToSlot()}
>
Entwurf in Slot
</button>
) : (
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={aiBusy}
onClick={() => onGenerateAi(offer, slotPick !== '' ? Number(slotPick) : defaultSlot)}
>
{generatingOfferId === offer.offer_id ? 'KI erstellt…' : 'KI anlegen'}
</button>
)}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => applyToSlot()}
>
Platzhalter in Slot
</button>
{needsInsert ? (
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={!canInsert}
title={!canInsert ? `Maximal ${slotCount} Slots` : 'Neuen Slot zwischen zwei Stufen einfügen'}
onClick={() => onInsertSlot(offer)}
>
Neuen Slot einfügen
</button>
) : null}
</div>
</li>
)
}
export default function ProgressionFindingsPanel({
pathQa = null,
gapFillOffers = [],
draft = null,
slotCount = 0,
loading = false,
error = '',
onEvaluate,
onApplyGapOffer,
onInsertGapSlot,
onGenerateGapAi,
generatingOfferId = null,
aiBusy = false,
evaluateDisabled = false,
}) {
return (
<div className="card" style={{ position: 'sticky', top: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken.
</p>
<button
type="button"
className="btn btn-primary btn-full"
disabled={loading || evaluateDisabled}
onClick={onEvaluate}
style={{ marginBottom: '12px' }}
>
{loading ? 'Bewertung läuft…' : 'Graph bewerten'}
</button>
{error ? (
<p className="form-error" style={{ marginTop: 0 }}>
{error}
</p>
) : null}
{pathQa ? (
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
fontSize: '12px',
lineHeight: 1.45,
...severityStyle(pathQa),
}}
>
<strong>
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{pathQa.quality_score != null
? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)`
: ''}
</strong>
{pathQa.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
) : null}
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
) : null}
{Array.isArray(pathQa.recommendations) && pathQa.recommendations.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>Empfehlungen</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.recommendations.map((rec) => (
<li key={rec}>{rec}</li>
))}
</ul>
</>
) : null}
{Number(pathQa.off_topic_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
</p>
) : null}
</div>
) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Noch keine Bewertung. Roadmap anlegen, dann Graph bewerten oder Übungen matchen.
</p>
)}
<div style={{ marginTop: '14px' }}>
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
KI-Angebote {gapFillOffers.length > 0 ? `(${gapFillOffers.length})` : ''}
</h4>
{gapFillOffers.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Keine offenen Angebote. Nach Match oder Bewertung erscheinen Vorschläge für leere Slots und Lücken.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
{gapFillOffers.map((offer) => (
<GapOfferCard
key={offer.offer_id || `${offer.source}-${offer.title_hint}`}
offer={offer}
slotCount={slotCount}
draft={draft}
generatingOfferId={generatingOfferId}
aiBusy={aiBusy}
onApplyDraft={(o, idx) => onApplyGapOffer(o, idx)}
onInsertSlot={onInsertGapSlot}
onGenerateAi={onGenerateGapAi}
/>
))}
</ul>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,126 @@
import React from 'react'
import { GitBranch, Lock, Users, Globe, Pencil, Trash2 } from 'lucide-react'
import { graphGoalQueryFromRow, graphSlotCountFromRow } from '../utils/progressionGraphDraft'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
function visibilityLabel(v) {
return VIS_LABELS[v] || v || '—'
}
function cardClassName(graph, userId) {
const vis = graph.visibility || 'private'
const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
const mine = userId != null && Number(graph.created_by) === Number(userId)
return ['card', 'exercise-card', 'progression-graph-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
.filter(Boolean)
.join(' ')
}
function VisIcon({ visibility }) {
if (visibility === 'official') return <Globe size={14} aria-hidden="true" />
if (visibility === 'club') return <Users size={14} aria-hidden="true" />
return <Lock size={14} aria-hidden="true" />
}
export default function ProgressionGraphListCard({
graph,
userId = null,
onOpen,
onDelete,
disabled = false,
}) {
const goalQuery = graphGoalQueryFromRow(graph)
const slotCount = graphSlotCountFromRow(graph)
const edgesCount = Number(graph.edges_count) || 0
const description = (graph.description || '').trim()
return (
<article className={cardClassName(graph, userId)}>
<div className="exercise-card__body">
<div className="exercise-card-title" style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<GitBranch size={18} style={{ flexShrink: 0, marginTop: '2px', color: 'var(--accent)' }} aria-hidden="true" />
<button
type="button"
className="exercise-card__body--clickable"
style={{
border: 'none',
background: 'none',
padding: 0,
textAlign: 'left',
font: 'inherit',
color: 'inherit',
cursor: disabled ? 'default' : 'pointer',
width: '100%',
}}
disabled={disabled}
onClick={() => onOpen?.(graph)}
>
{graph.name || `Graph #${graph.id}`}
</button>
</div>
<div className="exercise-card-tags" style={{ marginTop: '8px' }}>
<span className="exercise-tag" title={EXERCISE_VISIBILITY_FIELD_LABEL}>
<VisIcon visibility={graph.visibility} />
{visibilityLabel(graph.visibility)}
</span>
{slotCount != null ? (
<span className="exercise-tag">{slotCount} Stufen</span>
) : null}
<span className="exercise-tag">
{edgesCount === 1 ? '1 Kante' : `${edgesCount} Kanten`}
</span>
</div>
{goalQuery ? (
<p className="exercise-card-summary" style={{ marginTop: '10px' }}>
<strong style={{ fontWeight: 600, color: 'var(--text2)' }}>Ziel: </strong>
{goalQuery.length > 160 ? `${goalQuery.slice(0, 160)}` : goalQuery}
</p>
) : description ? (
<p className="exercise-card-summary" style={{ marginTop: '10px' }}>
{description.length > 160 ? `${description.slice(0, 160)}` : description}
</p>
) : (
<p className="exercise-card-summary muted" style={{ marginTop: '10px' }}>
Noch kein Planungsziel hinterlegt öffnen und Roadmap anlegen.
</p>
)}
</div>
<div
className="exercise-card-layout"
style={{
padding: '10px 14px 14px',
borderTop: '1px solid var(--border)',
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', padding: '6px 12px' }}
disabled={disabled}
onClick={() => onOpen?.(graph)}
>
<Pencil size={14} style={{ marginRight: '4px', verticalAlign: '-2px' }} aria-hidden="true" />
Bearbeiten
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '6px 12px' }}
disabled={disabled}
onClick={() => onDelete?.(graph)}
>
<Trash2 size={14} style={{ marginRight: '4px', verticalAlign: '-2px' }} aria-hidden="true" />
Löschen
</button>
</div>
</article>
)
}

View File

@ -0,0 +1,242 @@
/**
* Einzelner Roadmap-Slot im Progressionsgraph-Editor.
*/
import React from 'react'
import { Link } from 'react-router-dom'
import { ROADMAP_PHASES } from '../utils/progressionGraphDraft'
function exerciseLabel(entry) {
if (!entry || entry.kind === 'empty') return '— noch leer —'
if (entry.kind === 'proposal') return entry.exerciseTitle || 'KI-Entwurf'
return entry.exerciseTitle || `Übung #${entry.exerciseId}`
}
export default function ProgressionSlotCard({
slot,
slotIndex,
slotCount = 1,
onPickPrimary,
onPickSibling,
onClearPrimary,
onRemoveSibling,
onPatchLearningGoal,
onPatchPhase,
onMoveUp,
onMoveDown,
onRemoveSlot,
onInsertAfter,
onCreateFromProposal,
disabled = false,
}) {
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
return (
<div
className="card"
style={{
marginBottom: '10px',
borderColor: primary.kind === 'empty'
? 'var(--border)'
: 'color-mix(in srgb, var(--accent) 25%, var(--border))',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px', flexWrap: 'wrap' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>Slot {slotIndex + 1}</h4>
</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', alignItems: 'center' }}>
<span
className="exercise-tag"
style={{
fontSize: '11px',
borderColor: primary.kind === 'proposal' ? 'var(--danger)' : undefined,
}}
>
{primary.kind === 'empty' ? 'leer' : primary.kind === 'proposal' ? 'KI-Entwurf' : 'Bibliothek'}
</span>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 6px' }}
disabled={disabled || slotIndex === 0}
onClick={() => onMoveUp(slotIndex)}
title="Nach oben"
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 6px' }}
disabled={disabled || slotIndex >= slotCount - 1}
onClick={() => onMoveDown(slotIndex)}
title="Nach unten"
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 6px' }}
disabled={disabled || slotCount <= 2}
onClick={() => onRemoveSlot(slotIndex)}
title="Slot entfernen"
>
</button>
</div>
</div>
<div className="form-row" style={{ marginTop: '10px', marginBottom: '8px', display: 'grid', gridTemplateColumns: '1fr 140px', gap: '8px' }}>
<div>
<label className="form-label">Lernziel (Major Step)</label>
<input
className="form-input"
value={learningGoal || ''}
disabled={disabled}
onChange={(e) => onPatchLearningGoal(slotIndex, e.target.value)}
placeholder="Was soll in dieser Stufe erreicht werden?"
/>
</div>
<div>
<label className="form-label">Phase</label>
<select
className="form-input"
value={phase || 'vertiefung'}
disabled={disabled}
onChange={(e) => onPatchPhase(slotIndex, e.target.value)}
>
{ROADMAP_PHASES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<div style={{ fontSize: '11px', color: 'var(--text3)', marginBottom: '4px' }}>Hauptpfad (primary)</div>
<div style={{ fontSize: '13px', fontWeight: 600 }}>
{primary.kind === 'library' && primary.exerciseId ? (
<Link to={`/exercises/${primary.exerciseId}`}>{exerciseLabel(primary)}</Link>
) : (
exerciseLabel(primary)
)}
{primary.variantName ? (
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${primary.variantName}`}</span>
) : null}
</div>
{primary.kind === 'proposal' && primary.aiSuggestion?.summary?.text ? (
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text2)', lineHeight: 1.4 }}>
{primary.aiSuggestion.summary.text.slice(0, 220)}
{primary.aiSuggestion.summary.text.length > 220 ? '…' : ''}
</p>
) : null}
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled}
onClick={() => onPickPrimary(slotIndex)}
>
{primary.kind === 'empty' ? 'Übung wählen' : 'Übung ändern'}
</button>
{primary.kind === 'proposal' && typeof onCreateFromProposal === 'function' ? (
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled}
onClick={() => onCreateFromProposal(slotIndex)}
>
{primary.aiSuggestion ? 'Als Übung anlegen' : 'Mit KI anlegen'}
</button>
) : null}
{primary.kind !== 'empty' ? (
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled}
onClick={() => onClearPrimary(slotIndex)}
>
Leeren
</button>
) : null}
</div>
</div>
<div style={{ marginTop: '10px' }}>
<div style={{ fontSize: '11px', color: 'var(--text3)', marginBottom: '6px' }}>Schwestern (Alternativen)</div>
{siblings.length === 0 ? (
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>Keine Schwestern.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
{siblings.map((sib, sibIdx) => (
<li
key={`${sib.exerciseId || sib.proposalKey}-${sibIdx}`}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px',
padding: '6px 8px',
borderRadius: '6px',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<span>
{sib.kind === 'library' && sib.exerciseId ? (
<Link to={`/exercises/${sib.exerciseId}`}>{exerciseLabel(sib)}</Link>
) : (
exerciseLabel(sib)
)}
</span>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={disabled}
onClick={() => onRemoveSibling(slotIndex, sibIdx)}
>
Entfernen
</button>
</li>
))}
</ul>
)}
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled || primary.kind !== 'library'}
onClick={() => onPickSibling(slotIndex)}
title={primary.kind !== 'library' ? 'Zuerst eine Hauptübung wählen' : undefined}
>
Schwester hinzufügen
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled || slotCount >= 10}
onClick={() => onInsertAfter(slotIndex)}
>
Slot darunter einfügen
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,186 @@
import React, { useEffect } from 'react'
/**
* Vorbereitung vor KI-Übungsanlage aus Pfad-Lücke: Kontext prüfen, Ergänzungen mitgeben.
*/
export default function ExerciseGapFillPrepModal({
open,
onClose,
offer = null,
contextLines = [],
title = '',
onTitleChange,
stageLearningGoal = '',
onStageLearningGoalChange,
supplements = '',
onSupplementsChange,
focusAreaId = '',
onFocusAreaChange,
focusAreas = [],
busy = false,
error = '',
onSubmit,
}) {
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key === 'Escape' && !busy) {
e.preventDefault()
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, busy, onClose])
if (!open || !offer) return null
const readOnlyLines = (contextLines || []).filter(
(line) => line.label !== 'Stufen-Lernziel',
)
return (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget && !busy) onClose()
}}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="gap-fill-prep-title"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '680px' }}
>
<div className="admin-modal-sheet__header">
<h3 id="gap-fill-prep-title" className="admin-modal-sheet__title">
Übung mit KI vorbereiten
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
disabled={busy}
onClick={onClose}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body">
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', lineHeight: 1.45 }}>
Prüfen und anpassen, was an die KI geht. Ergänzungen fließen in Ziel, Trainerhinweise und
Planungskontext ein erst danach wird der Entwurf erzeugt.
</p>
{readOnlyLines.length > 0 ? (
<div
style={{
marginBottom: '14px',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong style={{ display: 'block', marginBottom: '6px' }}>Pfad-Kontext (aus Roadmap)</strong>
<dl style={{ margin: 0, display: 'grid', gap: '6px' }}>
{readOnlyLines.map(({ label, value }) => (
<div key={label}>
<dt style={{ margin: 0, fontSize: '11px', color: 'var(--text3)' }}>{label}</dt>
<dd style={{ margin: '2px 0 0', lineHeight: 1.45, color: 'var(--text2)' }}>{value}</dd>
</div>
))}
</dl>
</div>
) : null}
<div style={{ display: 'grid', gap: '12px' }}>
<div>
<label className="form-label" htmlFor="gap-prep-title">
Titel-Vorschlag *
</label>
<input
id="gap-prep-title"
className="form-input"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
disabled={busy}
maxLength={280}
/>
</div>
<div>
<label className="form-label" htmlFor="gap-prep-stage-goal">
Stufen-Lernziel (anpassbar)
</label>
<textarea
id="gap-prep-stage-goal"
className="form-input"
rows={2}
value={stageLearningGoal}
onChange={(e) => onStageLearningGoalChange(e.target.value)}
disabled={busy}
placeholder="Was soll diese Übung in der Roadmap-Stufe leisten?"
/>
</div>
<div>
<label className="form-label" htmlFor="gap-prep-supplements">
Ergänzungen / Anpassungen für die KI
</label>
<textarea
id="gap-prep-supplements"
className="form-input"
rows={3}
value={supplements}
onChange={(e) => onSupplementsChange(e.target.value)}
disabled={busy}
placeholder="z. B. nur Partnerübung, 1012 Jahre, Fokus auf Reaktion unter Druck, keine Sprünge …"
/>
</div>
<div>
<label className="form-label" htmlFor="gap-prep-focus">
Fokusbereich *
</label>
<select
id="gap-prep-focus"
className="form-input"
value={focusAreaId}
onChange={(e) => onFocusAreaChange(e.target.value)}
disabled={busy}
>
<option value="">Bitte wählen </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={fa.id}>
{fa.name}
</option>
))}
</select>
</div>
</div>
{offer.from_title && offer.to_title ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '12px 0 0' }}>
Einordnung: zwischen {offer.from_title} und {offer.to_title}
</p>
) : null}
{error ? (
<p className="form-error" style={{ marginTop: '12px' }}>
{error}
</p>
) : null}
</div>
<div className="admin-modal-sheet__footer" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={onClose}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={onSubmit}>
{busy ? 'KI erstellt Entwurf …' : 'KI-Entwurf erstellen'}
</button>
</div>
</div>
</div>
)
}

View File

@ -95,6 +95,7 @@ function ExercisesListPageRoot() {
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const progressionPanelRef = useRef(null)
const planningKi = usePlanningExerciseSuggestSearch({
enabled: pageTab === 'list' && aiQuickCreateEnabled,
@ -653,7 +654,13 @@ function ExercisesListPageRoot() {
)}
</div>
) : (
<span aria-hidden="true" />
<button
type="button"
className="btn btn-primary"
onClick={() => progressionPanelRef.current?.openCreateDialog?.()}
>
+ Neu
</button>
)}
</div>
@ -676,7 +683,7 @@ function ExercisesListPageRoot() {
</div>
}
>
<ExerciseProgressionGraphPanel />
<ExerciseProgressionGraphPanel ref={progressionPanelRef} />
</Suspense>
) : (
<>

View File

@ -0,0 +1,15 @@
import React from 'react'
import { Navigate, useParams } from 'react-router-dom'
/** Alte Deep-Links → Übungen-Liste mit Graph-Auswahl. */
export default function ProgressionGraphEditPage() {
const { id } = useParams()
const graphId = Number(id)
return (
<Navigate
to="/exercises"
replace
state={Number.isFinite(graphId) && graphId > 0 ? { progressionGraphId: graphId } : undefined}
/>
)
}

View File

@ -166,6 +166,24 @@ export function describeAiSkillRowForPreview(row, skillsCatalog) {
}
/** KI-Vorschau → bearbeitbarer Entwurf (Rich-Text-Felder). */
/** Rohes API-ai_suggestion oder bereits bearbeiteter Entwurf → Preview-Entwurf. */
export function ensureQuickCreateDraftFromAiSuggestion(
aiSuggestion,
{ title = '', focusAreaId = '', sketchPlain = '' } = {},
) {
if (!aiSuggestion || typeof aiSuggestion !== 'object') return null
if (aiSuggestion.instructionFields) return aiSuggestion
const preview = buildQuickCreateAiPreview(aiSuggestion, { sketchPlain })
if (
!preview.hasSummaryProposal &&
!preview.hasInstructionChoices &&
!preview.hasSkillChoices
) {
return null
}
return aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain })
}
export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const instructionFields = {

View File

@ -2,6 +2,8 @@
* Planungs-KI Phase D: strukturierter Kontext für suggestExerciseAi.
*/
import { slotsAsPathStepRows } from './progressionGraphDraft.js'
export function buildPickerPlanningContextForAi({
planningContextSummary = null,
planningContext = null,
@ -30,6 +32,109 @@ export function buildPickerPlanningContextForAi({
return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null && v !== ''))
}
function majorIndexFromStep(step) {
const raw = step?.roadmap_major_step_index ?? step?.roadmapMajorStepIndex
if (raw == null || !Number.isFinite(Number(raw))) return null
return Number(raw)
}
function priorPathStepsBeforeMajor(pathSteps, majorIdx) {
if (majorIdx == null || !Number.isFinite(Number(majorIdx))) return []
const mi = Number(majorIdx)
return (pathSteps || [])
.filter((s) => {
const idx = majorIndexFromStep(s)
return idx != null && idx < mi
})
.sort((a, b) => (majorIndexFromStep(a) || 0) - (majorIndexFromStep(b) || 0))
}
function stepDisplayFields(step) {
if (!step) return null
const title = String(step.title || step.exerciseTitle || '').trim()
const learningGoal = String(
step.roadmap_learning_goal || step.roadmapLearningGoal || step.learning_goal || '',
).trim()
const phase = String(step.roadmap_phase || step.roadmapPhase || step.phase || '').trim()
const startState = String(step.roadmap_start_state || step.start_state || '').trim()
const targetState = String(step.roadmap_target_state || step.target_state || '').trim()
const criteria = Array.isArray(step.success_criteria)
? step.success_criteria.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 4)
: []
const majorStepIndex = majorIndexFromStep(step)
const out = {
title: title || null,
learning_goal: learningGoal || null,
start_state: startState || null,
target_state: targetState || null,
phase: phase || null,
success_criteria: criteria.length ? criteria : null,
major_step_index: majorStepIndex,
}
const hasData = Object.values(out).some((v) => v != null && v !== '')
return hasData ? out : null
}
export function buildProgressionEntryState({
majorStepIndex = null,
priorSteps = [],
startSituation = '',
currentStageStart = '',
} = {}) {
const priorCompact = (priorSteps || [])
.map(stepDisplayFields)
.filter(Boolean)
const achievements = []
const detailLines = []
for (const p of priorCompact) {
if (Array.isArray(p.success_criteria) && p.success_criteria.length) {
achievements.push(...p.success_criteria)
} else if (p.learning_goal) {
achievements.push(p.learning_goal)
}
const labelParts = []
if (p.major_step_index != null) labelParts.push(`Stufe ${p.major_step_index + 1}`)
if (p.phase) labelParts.push(`(${p.phase})`)
if (p.title) labelParts.push(`${p.title}"`)
const prefix = labelParts.length ? labelParts.join(' ') : 'Vorstufe'
const achieved =
p.target_state ||
(Array.isArray(p.success_criteria) && p.success_criteria.length
? p.success_criteria.join('; ')
: '') ||
p.learning_goal ||
''
if (achieved) detailLines.push(`${prefix}: erreicht — ${achieved}`)
}
let entryState = (currentStageStart || '').trim()
if (!entryState && priorCompact.length) {
const immediate = priorCompact[priorCompact.length - 1]
entryState =
immediate.target_state ||
(Array.isArray(immediate.success_criteria) && immediate.success_criteria.length
? immediate.success_criteria.join('; ')
: '') ||
immediate.learning_goal ||
''
} else if (!entryState && (startSituation || '').trim()) {
entryState = startSituation.trim()
}
if (priorCompact.length && (startSituation || '').trim() && !entryState) {
detailLines.unshift(`Ausgangsbasis Pfad: ${startSituation.trim()}`)
}
const out = {}
if (entryState) out.entry_state = entryState
if (detailLines.length) out.entry_state_detail = detailLines.join('\n')
if (priorCompact.length) out.prior_steps = priorCompact.slice(0, 6)
if (achievements.length) out.prior_achievements = [...new Set(achievements)].slice(0, 8)
return out
}
function stageSpecForMajorIndex(progressionRoadmap, majorIdx) {
if (majorIdx == null || !progressionRoadmap) return null
const specs = progressionRoadmap?.stage_specs
@ -53,15 +158,28 @@ export function buildPathGapPlanningContextForAi({
startSituation = '',
targetState = '',
roadmapNotes = '',
stageLearningGoalOverride = '',
gapTrainerSupplements = '',
} = {}) {
const afterIdx = Number(offer?.insert_after_index)
const stepA = Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx] : null
const stepB =
Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx + 1] : null
const majorIdxRaw =
offer?.roadmap_major_step_index ?? offer?.gap?.roadmap_major_step_index
const majorIdx =
majorIdxRaw != null && Number.isFinite(Number(majorIdxRaw)) ? Number(majorIdxRaw) : null
const priorSteps = majorIdx != null ? priorPathStepsBeforeMajor(pathSteps, majorIdx) : []
const afterIdx = Number(offer?.insert_after_index)
const stepA =
priorSteps.length > 0
? priorSteps[priorSteps.length - 1]
: Number.isFinite(afterIdx) && afterIdx >= 0
? pathSteps[afterIdx]
: null
const stepB =
majorIdx != null
? (pathSteps || []).find((s) => majorIndexFromStep(s) === majorIdx + 1) ||
(Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx + 1] : null)
: Number.isFinite(afterIdx) && afterIdx >= 0
? pathSteps[afterIdx + 1]
: null
const majorStep =
majorIdx != null && editableMajorSteps[majorIdx] ? editableMajorSteps[majorIdx] : null
const stageSpec = stageSpecForMajorIndex(progressionRoadmap, majorIdx)
@ -90,6 +208,13 @@ export function buildPathGapPlanningContextForAi({
)
}
const entryState = buildProgressionEntryState({
majorStepIndex: majorIdx,
priorSteps,
startSituation: start,
currentStageStart: stageSpec?.start_state || '',
})
const ctx = {
source: 'progression_path_gap_fill',
goal_query: (goalQuery || '').trim() || null,
@ -105,7 +230,11 @@ export function buildPathGapPlanningContextForAi({
start_situation: start,
target_state: target,
roadmap_notes: notes,
stage_learning_goal: stageSpec?.learning_goal || null,
stage_learning_goal:
(stageLearningGoalOverride || '').trim() || stageSpec?.learning_goal || null,
stage_start_state: stageSpec?.start_state || null,
stage_target_state: stageSpec?.target_state || null,
gap_trainer_supplements: (gapTrainerSupplements || '').trim() || null,
stage_phase: stageSpec?.phase || majorStep?.phase || null,
stage_exercise_type: stageSpec?.exercise_type || null,
stage_load_profile: Array.isArray(stageSpec?.load_profile)
@ -121,8 +250,9 @@ export function buildPathGapPlanningContextForAi({
? ga.success_criteria.slice(0, 4)
: null,
skill_hints: skillHints.length ? skillHints : null,
neighbor_before_title: stepA?.exerciseTitle || offer?.from_title || null,
neighbor_after_title: stepB?.exerciseTitle || offer?.to_title || null,
neighbor_before_title: stepA?.exerciseTitle || stepA?.title || offer?.from_title || null,
neighbor_after_title: stepB?.exerciseTitle || stepB?.title || offer?.to_title || null,
...entryState,
path_step_count: Array.isArray(pathSteps) ? pathSteps.length : 0,
major_step_count:
editableMajorSteps?.length ||
@ -144,7 +274,14 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
const v = String(value || '').trim()
if (v) lines.push({ label, value: v })
}
push('Ausgangslage (Pfad)', raw.start_situation)
push('Eingangszustand (Vorstufen)', raw.entry_state)
if (raw.entry_state_detail && raw.entry_state_detail !== raw.entry_state) {
push('Bisheriger Pfad', raw.entry_state_detail)
}
if (Array.isArray(raw.prior_achievements) && raw.prior_achievements.length) {
push('Erreichte Voraussetzungen', raw.prior_achievements.slice(0, 6).join(' · '))
}
push('Ausgangslage (gesamter Pfad)', raw.start_situation)
push('Gesamtziel (Pfad)', raw.target_state)
push('Ergänzungen', raw.roadmap_notes)
push('Stufen-Lernziel', raw.stage_learning_goal || raw.roadmap_learning_goal)
@ -158,5 +295,56 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
if (Array.isArray(raw.skill_hints) && raw.skill_hints.length) {
push('Fähigkeiten-Fokus', raw.skill_hints.slice(0, 3).join(' · '))
}
if (Array.isArray(raw.expected_skills) && raw.expected_skills.length) {
const names = raw.expected_skills
.map((s) => String(s?.skill_name || '').trim())
.filter(Boolean)
.slice(0, 5)
if (names.length) push('Erwartete Fähigkeiten', names.join(' · '))
}
push('Trainer-Ergänzungen', raw.gap_trainer_supplements)
return lines
}
/** Zieltext für KI aus Slot-Kontext (Graph-Editor ohne API-Offer). */
export function buildSlotGapGoalForAi(draft, slotIndex, { goalQuery = '' } = {}) {
const slot = draft?.slots?.[slotIndex]
if (!slot) return ''
const pathSteps = slotsAsPathStepRows(draft)
const majorIdx = slot.majorStepIndex
const priorSteps = priorPathStepsBeforeMajor(pathSteps, majorIdx)
const start = (draft.startSituation || '').trim()
const stageSpec =
majorIdx != null && draft.progressionRoadmap
? stageSpecForMajorIndex(draft.progressionRoadmap, majorIdx)
: null
const entry = buildProgressionEntryState({
majorStepIndex: majorIdx,
priorSteps,
startSituation: start,
currentStageStart: stageSpec?.start_state || '',
})
const parts = [
goalQuery ? `Planungsziel (gesamter Pfad): ${goalQuery}` : '',
entry.entry_state
? `Eingangszustand (erreichte Voraussetzungen): ${entry.entry_state}`
: start
? `Ausgangslage (Pfad): ${start}`
: '',
entry.entry_state_detail && entry.entry_state_detail !== entry.entry_state
? `Bisheriger Pfad:\n${entry.entry_state_detail}`
: '',
(slot.learning_goal || '').trim()
? `Lernziel dieser Roadmap-Stufe: ${(slot.learning_goal || '').trim()}`
: '',
(slot.phase || '').trim() ? `Entwicklungsphase: ${slot.phase}` : '',
'Die Übung baut didaktisch auf den Vorstufen auf — Voraussetzungen explizit benennen, messbares Stufenziel.',
].filter(Boolean)
return parts.join('\n\n').trim()
}
export function initialStageLearningGoalFromOffer(offer, fallbackParams = null) {
const lines = gapOfferContextDisplayLines(offer, fallbackParams)
const hit = lines.find((l) => l.label === 'Stufen-Lernziel')
return hit?.value || (offer?.title_hint || '').trim()
}

File diff suppressed because it is too large Load Diff