import json import logging import math import base64 from odoo import http, fields from odoo.http import request from odoo.addons.encoach_api.controllers.base import ( jwt_required, _json_response, _error_response, _get_json_body, _paginate ) _logger = logging.getLogger(__name__) LEARNING_RATE = 0.3 SEM_THRESHOLD = 0.3 MAX_QUESTIONS = 40 THETA_CEFR = [ (-3.0, 'pre_a1'), (-2.0, 'a1'), (-1.0, 'a2'), (0.0, 'b1'), (1.0, 'b2'), (2.0, 'c1'), (3.0, 'c2'), ] def _theta_to_cefr(theta): for boundary, level in THETA_CEFR: if theta <= boundary: return level return 'c2' def _irt_probability(theta, a, b, c): """3PL IRT probability.""" exp_val = -a * (theta - b) exp_val = max(min(exp_val, 500), -500) return c + (1.0 - c) / (1.0 + math.exp(exp_val)) def _fisher_info(theta, a, b, c): """Fisher information for a 3PL item.""" p = _irt_probability(theta, a, b, c) q = 1.0 - p if (1.0 - c) == 0 or p == 0: return 0.0 numerator = (a ** 2) * ((p - c) ** 2) * q denominator = ((1.0 - c) ** 2) * p return numerator / denominator if denominator else 0.0 def _select_next(theta, available_qs, answered_ids): """Pick the item with max Fisher information from unanswered pool.""" best, best_info = None, -1.0 for q in available_qs: if q['id'] in answered_ids: continue info = _fisher_info(theta, q['irt_a'], q['irt_b'], q['irt_c']) if info > best_info: best_info = info best = q return best def _question_to_dict(rec): options = None if rec.options: try: options = json.loads(rec.options) except (json.JSONDecodeError, TypeError): options = rec.options return { 'id': rec.id, 'stem': rec.stem, 'options': options, 'question_type': rec.question_type, 'skill': rec.skill, } class EncoachPlacementController(http.Controller): # ------------------------------------------------------------------ # POST /api/placement/start # ------------------------------------------------------------------ @http.route('/api/placement/start', type='http', auth='none', methods=['POST'], csrf=False) @jwt_required def start(self, **kw): try: user = request.env.user active_session = request.env['encoach.cat.session'].sudo().search([ ('student_id', '=', user.id), ('status', '=', 'active'), ], limit=1) if active_session: active_session.write({'status': 'abandoned'}) session = request.env['encoach.cat.session'].sudo().create({ 'student_id': user.id, 'status': 'active', 'current_theta': 0.0, 'current_sem': 1.0, 'questions_answered': 0, }) questions = request.env['encoach.question'].sudo().search( [('status', '=', 'active')]) q_dicts = [{ 'id': q.id, 'irt_a': q.irt_a, 'irt_b': q.irt_b, 'irt_c': q.irt_c, } for q in questions] first = _select_next(0.0, q_dicts, set()) first_question = None if first: rec = request.env['encoach.question'].sudo().browse(first['id']) first_question = _question_to_dict(rec) return _json_response({ 'session_id': session.id, 'first_question': first_question, }) except Exception as e: _logger.exception('placement start failed') return _error_response(str(e), 500) # ------------------------------------------------------------------ # POST /api/placement/answer # ------------------------------------------------------------------ @http.route('/api/placement/answer', type='http', auth='none', methods=['POST'], csrf=False) @jwt_required def answer(self, **kw): try: body = _get_json_body() session_id = body.get('session_id') question_id = body.get('question_id') answer = body.get('answer') if not session_id or not question_id: return _error_response('session_id and question_id are required', 400) session = request.env['encoach.cat.session'].sudo().browse(int(session_id)) if not session.exists() or session.status != 'active': return _error_response('Invalid or inactive session', 400) question = request.env['encoach.question'].sudo().browse(int(question_id)) if not question.exists(): return _error_response('Question not found', 404) correct_answer = (question.correct_answer or '').strip() given_answer = str(answer or '').strip() correct = given_answer.lower() == correct_answer.lower() theta = session.current_theta a, b, c = question.irt_a, question.irt_b, question.irt_c p = _irt_probability(theta, a, b, c) theta += LEARNING_RATE * ((1.0 if correct else 0.0) - p) theta = max(-4.0, min(4.0, theta)) answered_ids = set() autosave = session.autosave_data if autosave: try: saved = json.loads(autosave) answered_ids = set(saved.get('answered_ids', [])) except (json.JSONDecodeError, TypeError): pass answered_ids.add(question_id) all_qs = request.env['encoach.question'].sudo().search( [('status', '=', 'active')]) total_info = 0.0 for q in all_qs: if q.id in answered_ids: total_info += _fisher_info(theta, q.irt_a, q.irt_b, q.irt_c) sem = 1.0 / math.sqrt(total_info) if total_info > 0 else 1.0 questions_answered = session.questions_answered + 1 completed = sem < SEM_THRESHOLD or questions_answered >= MAX_QUESTIONS write_vals = { 'current_theta': theta, 'current_sem': sem, 'questions_answered': questions_answered, 'autosave_data': json.dumps({ 'answered_ids': list(answered_ids), }), } result = { 'correct': correct, 'new_theta': round(theta, 4), 'new_sem': round(sem, 4), 'completed': completed, } if completed: cefr = _theta_to_cefr(theta) write_vals['status'] = 'completed' write_vals['completed_at'] = fields.Datetime.now() result['cefr_level'] = cefr result['next_question'] = None Ability = request.env['encoach.student.ability.model'].sudo() ability = Ability.search([ ('student_id', '=', session.student_id.id), ('skill', '=', question.skill), ], limit=1) if ability: ability.write({'theta': theta, 'sem': sem, 'last_updated': fields.Datetime.now()}) else: Ability.create({ 'student_id': session.student_id.id, 'skill': question.skill, 'theta': theta, 'sem': sem, }) else: q_dicts = [{ 'id': q.id, 'irt_a': q.irt_a, 'irt_b': q.irt_b, 'irt_c': q.irt_c, } for q in all_qs] nxt = _select_next(theta, q_dicts, answered_ids) if nxt: rec = request.env['encoach.question'].sudo().browse(nxt['id']) result['next_question'] = _question_to_dict(rec) else: result['next_question'] = None session.write(write_vals) return _json_response(result) except Exception as e: _logger.exception('placement answer failed') return _error_response(str(e), 500) # ------------------------------------------------------------------ # POST /api/placement/autosave # ------------------------------------------------------------------ @http.route('/api/placement/autosave', type='http', auth='none', methods=['POST'], csrf=False) @jwt_required def autosave(self, **kw): try: body = _get_json_body() session_id = body.get('session_id') autosave_data = body.get('autosave_data') if not session_id: return _error_response('session_id is required', 400) session = request.env['encoach.cat.session'].sudo().browse(int(session_id)) if not session.exists(): return _error_response('Session not found', 404) session.write({ 'autosave_data': json.dumps(autosave_data) if not isinstance( autosave_data, str) else autosave_data, }) return _json_response({'saved': True}) except Exception as e: _logger.exception('placement autosave failed') return _error_response(str(e), 500) # ------------------------------------------------------------------ # POST /api/placement/speaking-upload # ------------------------------------------------------------------ @http.route('/api/placement/speaking-upload', type='http', auth='none', methods=['POST'], csrf=False) @jwt_required def speaking_upload(self, **kw): try: audio_file = request.httprequest.files.get('audio') if not audio_file: return _error_response('No audio file provided', 400) file_data = audio_file.read() attachment = request.env['ir.attachment'].sudo().create({ 'name': audio_file.filename or 'speaking_upload.webm', 'type': 'binary', 'datas': base64.b64encode(file_data), 'res_model': 'encoach.cat.session', 'mimetype': audio_file.content_type or 'audio/webm', }) return _json_response({ 'upload_id': attachment.id, 'status': 'processing', }) except Exception as e: _logger.exception('speaking upload failed') return _error_response(str(e), 500) # ------------------------------------------------------------------ # GET /api/placement/speaking-status # ------------------------------------------------------------------ @http.route('/api/placement/speaking-status', type='http', auth='none', methods=['GET'], csrf=False) @jwt_required def speaking_status(self, **kw): try: session_id = kw.get('session_id') upload_id = kw.get('upload_id') if session_id: session = request.env['encoach.cat.session'].sudo().browse(int(session_id)) if not session.exists(): return _error_response('Session not found', 404) try: from odoo.addons.encoach_ai.services.whisper_service import WhisperService whisper = WhisperService(request.env) attachments = request.env['ir.attachment'].sudo().search([ ('res_model', '=', 'encoach.cat.session'), ('create_uid', '=', request.env.uid), ], limit=1, order='create_date desc') if attachments and attachments.datas: audio_data = base64.b64decode(attachments.datas) transcript = whisper.transcribe(audio_data, use_api=True) return _json_response({ 'status': 'scored', 'transcription': transcript.get('text', ''), }) except Exception as ai_err: _logger.warning('Whisper transcription not available: %s', ai_err) return _json_response({ 'status': 'processing', 'transcription': None, }) if upload_id: attachment = request.env['ir.attachment'].sudo().browse(int(upload_id)) if not attachment.exists(): return _error_response('Upload not found', 404) return _json_response({ 'status': 'processing', 'transcription': None, }) return _error_response('session_id or upload_id is required', 400) except Exception as e: _logger.exception('speaking status failed') return _error_response(str(e), 500) # ------------------------------------------------------------------ # GET /api/placement/results # ------------------------------------------------------------------ @http.route('/api/placement/results', type='http', auth='none', methods=['GET'], csrf=False) @jwt_required def results(self, **kw): try: user = request.env.user session = request.env['encoach.cat.session'].sudo().search([ ('student_id', '=', user.id), ('status', '=', 'completed'), ], limit=1, order='completed_at desc') if not session: return _error_response('No completed placement found', 404) abilities = request.env['encoach.student.ability.model'].sudo().search([ ('student_id', '=', user.id), ]) skills = [{ 'skill': ab.skill, 'theta': round(ab.theta, 4), 'cefr_level': _theta_to_cefr(ab.theta), } for ab in abilities] return _json_response({ 'cefr_level': _theta_to_cefr(session.current_theta), 'skills': skills, 'placement_date': session.completed_at, }) except Exception as e: _logger.exception('placement results failed') return _error_response(str(e), 500) # ------------------------------------------------------------------ # GET /api/placement/learning-path # ------------------------------------------------------------------ @http.route('/api/placement/learning-path', type='http', auth='none', methods=['GET'], csrf=False) @jwt_required def learning_path(self, **kw): try: user = request.env.user abilities = request.env['encoach.student.ability.model'].sudo().search([ ('student_id', '=', user.id), ]) gap_areas = [] for ab in abilities: if ab.theta < 0.0: gap_areas.append({ 'skill': ab.skill, 'current_level': _theta_to_cefr(ab.theta), 'theta': round(ab.theta, 4), }) recommended = [] cefr_map = {} for ab in abilities: cefr_map[ab.skill] = _theta_to_cefr(ab.theta) weakest_skills = sorted(abilities, key=lambda a: a.theta) for ab in weakest_skills[:3]: recommended.append({ 'skill': ab.skill, 'course_type': 'remedial', 'suggested_level': _theta_to_cefr(ab.theta), 'priority': 'high' if ab.theta < -1.0 else 'medium', }) return _json_response({ 'recommended_courses': recommended, 'gap_areas': gap_areas, }) except Exception as e: _logger.exception('learning path failed') return _error_response(str(e), 500)