Spaces:
Sleeping
Sleeping
| """ | |
| Path Modification Service | |
| Phase 3: Dynamic Learning Path Updates | |
| This service handles: | |
| - Modifying learning paths based on user requests | |
| - Adding/removing/updating resources | |
| - Splitting/merging milestones | |
| - Adjusting difficulty and duration | |
| - Tracking all modifications | |
| """ | |
| from typing import Dict, List, Optional, Any | |
| import json | |
| import copy | |
| from datetime import datetime | |
| from web_app import db | |
| from web_app.models import UserLearningPath, PathModification | |
| from src.ml.model_orchestrator import ModelOrchestrator | |
| class PathModifier: | |
| """ | |
| Handles dynamic modifications to learning paths. | |
| Modification Types: | |
| - add_resource: Add a new resource to a milestone | |
| - remove_resource: Remove a resource from a milestone | |
| - modify_milestone: Update milestone properties | |
| - split_milestone: Split one milestone into multiple | |
| - merge_milestones: Combine multiple milestones | |
| - adjust_difficulty: Make content easier or harder | |
| - adjust_duration: Change time estimates | |
| """ | |
| def __init__(self): | |
| """Initialize the path modifier.""" | |
| self.orchestrator = ModelOrchestrator() | |
| def modify_path( | |
| self, | |
| learning_path_id: str, | |
| user_id: int, | |
| modification_request: str, | |
| entities: Dict, | |
| chat_message_id: Optional[int] = None | |
| ) -> Dict: | |
| """ | |
| Modify a learning path based on user request. | |
| Args: | |
| learning_path_id: Learning path ID | |
| user_id: User ID | |
| modification_request: User's modification request | |
| entities: Extracted entities from intent classification | |
| chat_message_id: Optional chat message ID that triggered this | |
| Returns: | |
| Dictionary with modification result | |
| """ | |
| # Get the learning path | |
| learning_path = UserLearningPath.query.get(learning_path_id) | |
| if not learning_path or learning_path.user_id != user_id: | |
| return { | |
| 'success': False, | |
| 'error': 'Learning path not found or access denied' | |
| } | |
| # Get current path data | |
| path_data = learning_path.path_data_json | |
| # Determine modification type and generate changes | |
| modification_plan = self._generate_modification_plan( | |
| modification_request, | |
| entities, | |
| path_data | |
| ) | |
| if not modification_plan['success']: | |
| return modification_plan | |
| # Apply the modification | |
| try: | |
| modified_path = self._apply_modification( | |
| path_data, | |
| modification_plan | |
| ) | |
| # Validate the modified path | |
| if not self._validate_path(modified_path): | |
| return { | |
| 'success': False, | |
| 'error': 'Modified path failed validation' | |
| } | |
| # Save the modification | |
| old_path_data = copy.deepcopy(path_data) | |
| learning_path.path_data_json = modified_path | |
| learning_path.last_accessed_at = datetime.utcnow() | |
| # Record the modification | |
| path_modification = PathModification( | |
| learning_path_id=learning_path_id, | |
| user_id=user_id, | |
| chat_message_id=chat_message_id, | |
| modification_type=modification_plan['type'], | |
| target_path=modification_plan.get('target_path'), | |
| change_description=modification_plan['description'], | |
| old_value=old_path_data, | |
| new_value=modified_path | |
| ) | |
| db.session.add(path_modification) | |
| db.session.commit() | |
| return { | |
| 'success': True, | |
| 'modification_type': modification_plan['type'], | |
| 'description': modification_plan['description'], | |
| 'changes': modification_plan.get('changes', {}), | |
| 'modified_path': modified_path | |
| } | |
| except Exception as e: | |
| db.session.rollback() | |
| print(f"Path modification error: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return { | |
| 'success': False, | |
| 'error': f'Failed to apply modification: {str(e)}' | |
| } | |
| def _generate_modification_plan( | |
| self, | |
| request: str, | |
| entities: Dict, | |
| current_path: Dict | |
| ) -> Dict: | |
| """ | |
| Generate a modification plan using AI. | |
| Args: | |
| request: User's modification request | |
| entities: Extracted entities | |
| current_path: Current learning path data | |
| Returns: | |
| Modification plan dictionary | |
| """ | |
| # Build prompt for AI to generate modification plan | |
| prompt = f"""You are a learning path modification assistant. Generate a specific modification plan. | |
| User request: "{request}" | |
| Extracted entities: {json.dumps(entities, indent=2)} | |
| Current learning path summary: | |
| - Title: {current_path.get('title', 'Unknown')} | |
| - Total milestones: {len(current_path.get('milestones', []))} | |
| - Duration: {current_path.get('duration_weeks', 'Unknown')} weeks | |
| Milestones: | |
| {self._format_milestones_for_prompt(current_path.get('milestones', []))} | |
| Generate a modification plan that includes: | |
| 1. Modification type (add_resource, remove_resource, modify_milestone, split_milestone, adjust_difficulty, etc.) | |
| 2. Target (which milestone/resource to modify) | |
| 3. Specific changes to make | |
| 4. Human-readable description of the change | |
| Be specific and actionable.""" | |
| schema = """ | |
| { | |
| "success": true, | |
| "type": "string (modification type)", | |
| "target_path": "string (JSON path to target, e.g., 'milestones[2]')", | |
| "description": "string (human-readable description)", | |
| "changes": { | |
| "action": "string (add, remove, update, split, etc.)", | |
| "target_index": "integer or null (milestone index)", | |
| "data": "object (specific changes to apply)" | |
| } | |
| } | |
| """ | |
| try: | |
| response = self.orchestrator.generate_structured_response( | |
| prompt=prompt, | |
| output_schema=schema, | |
| temperature=0.4, | |
| use_cache=False # Don't cache modifications | |
| ) | |
| plan = json.loads(response) | |
| return plan | |
| except Exception as e: | |
| print(f"Modification plan generation error: {e}") | |
| return { | |
| 'success': False, | |
| 'error': f'Failed to generate modification plan: {str(e)}' | |
| } | |
| def _apply_modification(self, path_data: Dict, plan: Dict) -> Dict: | |
| """ | |
| Apply the modification plan to the path data. | |
| Args: | |
| path_data: Current path data | |
| plan: Modification plan | |
| Returns: | |
| Modified path data | |
| """ | |
| modified_path = copy.deepcopy(path_data) | |
| changes = plan.get('changes', {}) | |
| action = changes.get('action', '') | |
| if plan['type'] == 'add_resource': | |
| modified_path = self._add_resource(modified_path, changes) | |
| elif plan['type'] == 'remove_resource': | |
| modified_path = self._remove_resource(modified_path, changes) | |
| elif plan['type'] == 'modify_milestone': | |
| modified_path = self._modify_milestone(modified_path, changes) | |
| elif plan['type'] == 'split_milestone': | |
| modified_path = self._split_milestone(modified_path, changes) | |
| elif plan['type'] == 'adjust_difficulty': | |
| modified_path = self._adjust_difficulty(modified_path, changes) | |
| elif plan['type'] == 'adjust_duration': | |
| modified_path = self._adjust_duration(modified_path, changes) | |
| return modified_path | |
| def _add_resource(self, path_data: Dict, changes: Dict) -> Dict: | |
| """Add a resource to a milestone.""" | |
| milestone_index = changes.get('target_index') | |
| new_resources = changes.get('data', {}).get('resources', []) | |
| if milestone_index is not None and 0 <= milestone_index < len(path_data.get('milestones', [])): | |
| if 'resources' not in path_data['milestones'][milestone_index]: | |
| path_data['milestones'][milestone_index]['resources'] = [] | |
| path_data['milestones'][milestone_index]['resources'].extend(new_resources) | |
| return path_data | |
| def _remove_resource(self, path_data: Dict, changes: Dict) -> Dict: | |
| """Remove a resource from a milestone.""" | |
| milestone_index = changes.get('target_index') | |
| resource_index = changes.get('data', {}).get('resource_index') | |
| if milestone_index is not None and resource_index is not None: | |
| milestones = path_data.get('milestones', []) | |
| if 0 <= milestone_index < len(milestones): | |
| resources = milestones[milestone_index].get('resources', []) | |
| if 0 <= resource_index < len(resources): | |
| resources.pop(resource_index) | |
| return path_data | |
| def _modify_milestone(self, path_data: Dict, changes: Dict) -> Dict: | |
| """Modify milestone properties.""" | |
| milestone_index = changes.get('target_index') | |
| updates = changes.get('data', {}) | |
| if milestone_index is not None and 0 <= milestone_index < len(path_data.get('milestones', [])): | |
| milestone = path_data['milestones'][milestone_index] | |
| # Apply updates | |
| for key, value in updates.items(): | |
| if key in milestone: | |
| milestone[key] = value | |
| return path_data | |
| def _split_milestone(self, path_data: Dict, changes: Dict) -> Dict: | |
| """Split a milestone into multiple smaller milestones.""" | |
| milestone_index = changes.get('target_index') | |
| new_milestones = changes.get('data', {}).get('new_milestones', []) | |
| if milestone_index is not None and new_milestones: | |
| milestones = path_data.get('milestones', []) | |
| if 0 <= milestone_index < len(milestones): | |
| # Remove original milestone and insert new ones | |
| milestones.pop(milestone_index) | |
| for i, new_milestone in enumerate(new_milestones): | |
| milestones.insert(milestone_index + i, new_milestone) | |
| return path_data | |
| def _adjust_difficulty(self, path_data: Dict, changes: Dict) -> Dict: | |
| """Adjust difficulty of content.""" | |
| milestone_index = changes.get('target_index') | |
| difficulty_change = changes.get('data', {}).get('difficulty') # 'easier' or 'harder' | |
| if milestone_index is not None: | |
| milestone = path_data['milestones'][milestone_index] | |
| if difficulty_change == 'easier': | |
| # Reduce estimated hours, add more beginner resources | |
| current_hours = milestone.get('estimated_hours', 10) | |
| milestone['estimated_hours'] = max(2, int(current_hours * 0.7)) | |
| elif difficulty_change == 'harder': | |
| # Increase estimated hours, add advanced resources | |
| current_hours = milestone.get('estimated_hours', 10) | |
| milestone['estimated_hours'] = int(current_hours * 1.3) | |
| return path_data | |
| def _adjust_duration(self, path_data: Dict, changes: Dict) -> Dict: | |
| """Adjust overall duration.""" | |
| new_duration = changes.get('data', {}).get('duration_weeks') | |
| if new_duration: | |
| path_data['duration_weeks'] = new_duration | |
| # Recalculate total hours | |
| total_hours = sum( | |
| m.get('estimated_hours', 0) | |
| for m in path_data.get('milestones', []) | |
| ) | |
| path_data['total_hours'] = total_hours | |
| return path_data | |
| def _validate_path(self, path_data: Dict) -> bool: | |
| """ | |
| Validate that the modified path has all required fields. | |
| Args: | |
| path_data: Path data to validate | |
| Returns: | |
| True if valid, False otherwise | |
| """ | |
| required_fields = ['title', 'description', 'milestones'] | |
| for field in required_fields: | |
| if field not in path_data: | |
| return False | |
| # Validate milestones | |
| for milestone in path_data.get('milestones', []): | |
| if 'title' not in milestone or 'description' not in milestone: | |
| return False | |
| return True | |
| def _format_milestones_for_prompt(self, milestones: List[Dict]) -> str: | |
| """Format milestones for AI prompt.""" | |
| formatted = [] | |
| for i, milestone in enumerate(milestones): | |
| formatted.append( | |
| f"{i+1}. {milestone.get('title', 'Untitled')} " | |
| f"({milestone.get('estimated_hours', '?')} hours)" | |
| ) | |
| return '\n'.join(formatted) | |
| def get_modification_history( | |
| self, | |
| learning_path_id: str, | |
| limit: int = 10 | |
| ) -> List[PathModification]: | |
| """ | |
| Get modification history for a learning path. | |
| Args: | |
| learning_path_id: Learning path ID | |
| limit: Maximum number of modifications to return | |
| Returns: | |
| List of PathModification objects | |
| """ | |
| return PathModification.query.filter( | |
| PathModification.learning_path_id == learning_path_id | |
| ).order_by( | |
| PathModification.timestamp.desc() | |
| ).limit(limit).all() | |
| def undo_modification( | |
| self, | |
| modification_id: int, | |
| user_id: int | |
| ) -> Dict: | |
| """ | |
| Undo a previous modification. | |
| Args: | |
| modification_id: Modification ID to undo | |
| user_id: User ID (for authorization) | |
| Returns: | |
| Result dictionary | |
| """ | |
| modification = PathModification.query.get(modification_id) | |
| if not modification or modification.user_id != user_id: | |
| return { | |
| 'success': False, | |
| 'error': 'Modification not found or access denied' | |
| } | |
| if modification.is_reverted: | |
| return { | |
| 'success': False, | |
| 'error': 'Modification already reverted' | |
| } | |
| # Restore old value | |
| learning_path = UserLearningPath.query.get(modification.learning_path_id) | |
| if learning_path: | |
| learning_path.path_data_json = modification.old_value | |
| modification.is_reverted = True | |
| db.session.commit() | |
| return { | |
| 'success': True, | |
| 'message': 'Modification reverted successfully' | |
| } | |
| return { | |
| 'success': False, | |
| 'error': 'Learning path not found' | |
| } | |