import json import logging import threading import odoo from odoo import api, SUPERUSER_ID, http from odoo.http import request from .base import EncoachMixin _logger = logging.getLogger(__name__) def _run_grading_background(dbname, evaluation_id, input_data, grading_type): """Execute AI grading in a background thread with its own cursor.""" try: registry = odoo.registry(dbname) with registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) service = env['encoach.ai.grading'] if grading_type == 'writing': service.grade_writing(evaluation_id, input_data) elif grading_type == 'speaking': service.grade_speaking(evaluation_id, input_data) elif grading_type == 'interactive_speaking': service.grade_interactive_speaking(evaluation_id, input_data) except Exception: _logger.exception("Background grading failed for evaluation %s", evaluation_id) try: registry = odoo.registry(dbname) with registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) env['encoach.evaluation'].browse(evaluation_id).write({ 'status': 'error', 'error_message': 'Grading failed unexpectedly', }) except Exception: _logger.exception("Failed to update evaluation status after error") class EvaluationsController(EncoachMixin, http.Controller): # ------------------------------------------------- evaluate writing @http.route('/api/evaluate/writing', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') def evaluate_writing(self, **kwargs): try: user = self._authenticate() if not user: return self._error_response('Unauthorized', 401) body = self._get_json_body() required = ('sessionId', 'exerciseId', 'question', 'answer', 'task') for field in required: if not body.get(field): return self._error_response(f'{field} is required', 400) Evaluation = request.env['encoach.evaluation'].sudo() evaluation = Evaluation.create({ 'user_id': int(body.get('userId', user.id)), 'session_id': int(body['sessionId']), 'exercise_id': body['exerciseId'], 'eval_type': 'writing', 'status': 'pending', 'input_data': json.dumps({ 'question': body['question'], 'answer': body['answer'], 'task': body['task'], 'attachment': body.get('attachment'), }), }) dbname = request.env.cr.dbname input_data = { 'question': body['question'], 'answer': body['answer'], 'task': body['task'], 'attachment': body.get('attachment'), } thread = threading.Thread( target=_run_grading_background, args=(dbname, evaluation.id, input_data, 'writing'), daemon=True, ) thread.start() return self._json_response({'ok': True}) except Exception: _logger.exception("Evaluate writing error") return self._error_response('Internal server error', 500) # ------------------------------------------------- evaluate speaking @http.route('/api/evaluate/speaking', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') def evaluate_speaking(self, **kwargs): """Accept multipart form with audio files and question fields.""" try: user = self._authenticate() if not user: return self._error_response('Unauthorized', 401) form = request.httprequest.form files = request.httprequest.files user_id = form.get('userId', str(user.id)) session_id = form.get('sessionId') exercise_id = form.get('exerciseId') task = form.get('task') if not session_id or not exercise_id or not task: return self._error_response('sessionId, exerciseId, and task are required', 400) audio_entries = [] idx = 1 while True: audio_key = f'audio_{idx}' question_key = f'question_{idx}' audio_file = files.get(audio_key) if not audio_file: break audio_entries.append({ 'audio_data': audio_file.read(), 'audio_filename': audio_file.filename, 'question': form.get(question_key, ''), }) idx += 1 if not audio_entries: return self._error_response('At least one audio file is required', 400) Attachment = request.env['ir.attachment'].sudo() saved_audios = [] for entry in audio_entries: import base64 att = Attachment.create({ 'name': entry['audio_filename'], 'datas': base64.b64encode(entry['audio_data']), 'res_model': 'encoach.evaluation', 'type': 'binary', }) saved_audios.append({ 'attachment_id': att.id, 'question': entry['question'], }) Evaluation = request.env['encoach.evaluation'].sudo() evaluation = Evaluation.create({ 'user_id': int(user_id), 'session_id': int(session_id), 'exercise_id': exercise_id, 'eval_type': 'speaking', 'status': 'pending', 'input_data': json.dumps({ 'task': task, 'audios': [{'attachment_id': a['attachment_id'], 'question': a['question']} for a in saved_audios], }), }) dbname = request.env.cr.dbname input_data = { 'task': task, 'audios': saved_audios, } thread = threading.Thread( target=_run_grading_background, args=(dbname, evaluation.id, input_data, 'speaking'), daemon=True, ) thread.start() return self._json_response({'ok': True}) except Exception: _logger.exception("Evaluate speaking error") return self._error_response('Internal server error', 500) # ---------------------------------------- evaluate interactive speaking @http.route( '/api/evaluate/interactiveSpeaking', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*', ) def evaluate_interactive_speaking(self, **kwargs): """Same as speaking but for interactive Q&A pairs.""" try: user = self._authenticate() if not user: return self._error_response('Unauthorized', 401) form = request.httprequest.form files = request.httprequest.files user_id = form.get('userId', str(user.id)) session_id = form.get('sessionId') exercise_id = form.get('exerciseId') task = form.get('task') if not session_id or not exercise_id or not task: return self._error_response('sessionId, exerciseId, and task are required', 400) audio_entries = [] idx = 1 while True: audio_key = f'audio_{idx}' question_key = f'question_{idx}' audio_file = files.get(audio_key) if not audio_file: break audio_entries.append({ 'audio_data': audio_file.read(), 'audio_filename': audio_file.filename, 'question': form.get(question_key, ''), }) idx += 1 if not audio_entries: return self._error_response('At least one audio file is required', 400) import base64 Attachment = request.env['ir.attachment'].sudo() saved_audios = [] for entry in audio_entries: att = Attachment.create({ 'name': entry['audio_filename'], 'datas': base64.b64encode(entry['audio_data']), 'res_model': 'encoach.evaluation', 'type': 'binary', }) saved_audios.append({ 'attachment_id': att.id, 'question': entry['question'], }) Evaluation = request.env['encoach.evaluation'].sudo() evaluation = Evaluation.create({ 'user_id': int(user_id), 'session_id': int(session_id), 'exercise_id': exercise_id, 'eval_type': 'interactive_speaking', 'status': 'pending', 'input_data': json.dumps({ 'task': task, 'audios': [{'attachment_id': a['attachment_id'], 'question': a['question']} for a in saved_audios], }), }) dbname = request.env.cr.dbname input_data = { 'task': task, 'audios': saved_audios, } thread = threading.Thread( target=_run_grading_background, args=(dbname, evaluation.id, input_data, 'interactive_speaking'), daemon=True, ) thread.start() return self._json_response({'ok': True}) except Exception: _logger.exception("Evaluate interactive speaking error") return self._error_response('Internal server error', 500) # ----------------------------------------------------- poll status @http.route(['/api/evaluate//', '/api/evaluate/status'], type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') def evaluation_status(self, session_id=None, exercise_id=None, **kwargs): try: user = self._authenticate() if not user: return self._error_response('Unauthorized', 401) if not session_id: session_id = kwargs.get('sessionId') if not exercise_id: exercise_id = kwargs.get('exerciseId') if not session_id or not exercise_id: return self._error_response('sessionId and exerciseId are required', 400) evaluation = request.env['encoach.evaluation'].sudo().search([ ('session_id', '=', int(session_id)), ('exercise_id', '=', str(exercise_id)), ], limit=1, order='create_date desc') if not evaluation: return self._error_response('Evaluation not found', 404) result = None if evaluation.status == 'completed' and evaluation.result: result = json.loads(evaluation.result) return self._json_response({ 'status': evaluation.status, 'result': result, }) except Exception: _logger.exception("Evaluation status error") return self._error_response('Internal server error', 500)