- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
150 lines
5.1 KiB
Python
150 lines
5.1 KiB
Python
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
|