import logging import json _logger = logging.getLogger(__name__) class AdaptiveEngine: """4-phase adaptive learning engine. Phase 1: Module-level up/down stepping Phase 2: Micro-lesson injection Phase 3: Module skipping Phase 4: No-progress alerts """ DEFAULT_SETTINGS = { 'step_up_threshold': 0.85, 'step_down_threshold': 0.50, 'micro_lesson_trigger': 2, 'module_skip_threshold': 0.95, 'no_progress_alert_days': 3, 'max_retries': 3, } @staticmethod def get_settings(env, teacher_id=None, entity_id=None): """Get adaptive settings for teacher/entity or defaults.""" Settings = env['encoach.adaptive.settings'].sudo() settings = None if teacher_id: settings = Settings.search([('teacher_id', '=', teacher_id)], limit=1) if not settings and entity_id: settings = Settings.search([('entity_id', '=', entity_id), ('teacher_id', '=', False)], limit=1) if settings: return { 'step_up_threshold': settings.step_up_threshold, 'step_down_threshold': settings.step_down_threshold, 'micro_lesson_trigger': settings.micro_lesson_trigger, 'module_skip_threshold': settings.module_skip_threshold, 'no_progress_alert_days': settings.no_progress_alert_days, 'max_retries': settings.max_retries, } return dict(AdaptiveEngine.DEFAULT_SETTINGS) @staticmethod def process_checkpoint(env, student_id, course_id, module_id, score, settings=None): """Process a module checkpoint and make adaptive decisions.""" if not settings: settings = AdaptiveEngine.DEFAULT_SETTINGS Event = env['encoach.adaptive.event'].sudo() Module = env['encoach.course.module'].sudo() module = Module.browse(module_id) decision = None signals = [] signals.append({ 'signal_name': 'checkpoint_score', 'signal_value': score, }) if score >= settings['step_up_threshold']: decision = 'step_up' module.write({'status': 'completed'}) next_module = Module.search([ ('course_id', '=', course_id), ('sequence', '>', module.sequence), ('status', '=', 'locked'), ], limit=1, order='sequence') if next_module: next_module.write({'status': 'available'}) elif score < settings['step_down_threshold']: decision = 'step_down' else: decision = 'continue' module.write({'status': 'completed'}) next_module = Module.search([ ('course_id', '=', course_id), ('sequence', '>', module.sequence), ('status', '=', 'locked'), ], limit=1, order='sequence') if next_module: next_module.write({'status': 'available'}) if score >= settings['module_skip_threshold']: skip_modules = Module.search([ ('course_id', '=', course_id), ('sequence', '>', module.sequence), ('status', '=', 'locked'), ], limit=2, order='sequence') for sm in skip_modules: sm.write({'status': 'skipped'}) if skip_modules: decision = 'skip_ahead' signals.append({'signal_name': 'module_skip', 'signal_value': len(skip_modules)}) for sig in signals: Event.create({ 'student_id': student_id, 'course_id': course_id, 'event_type': 'signal', 'signal_name': sig['signal_name'], 'signal_value': sig['signal_value'], }) Event.create({ 'student_id': student_id, 'course_id': course_id, 'event_type': 'decision', 'decision': decision, 'context': json.dumps({'module_id': module_id, 'score': score}), }) return { 'decision': decision, 'score': score, 'signals': signals, } @staticmethod def check_no_progress(env, student_id, course_id, settings=None): """Phase 4: Check if student has stalled.""" if not settings: settings = AdaptiveEngine.DEFAULT_SETTINGS from datetime import datetime, timedelta cutoff = datetime.now() - timedelta(days=settings['no_progress_alert_days']) Event = env['encoach.adaptive.event'].sudo() recent_events = Event.search_count([ ('student_id', '=', student_id), ('course_id', '=', course_id), ('created_at', '>=', cutoff.strftime('%Y-%m-%d %H:%M:%S')), ]) if recent_events == 0: Event.create({ 'student_id': student_id, 'course_id': course_id, 'event_type': 'signal', 'signal_name': 'no_progress_alert', 'signal_value': settings['no_progress_alert_days'], }) return True return False