Full backend implementation with custom Odoo modules: - encoach_api: Core API, user management, JWT auth - encoach_exam: Exam generation (reading, writing, listening, speaking) - encoach_evaluate: AI-powered evaluation (writing, speaking) - encoach_training: Training tips and walkthrough - encoach_storage: File storage management - encoach_payment: Stripe, PayPal, Paymob integration - encoach_mail: Email notifications Made-with: Cursor
294 lines
11 KiB
Python
294 lines
11 KiB
Python
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/<int:session_id>/<string:exercise_id>', '/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)
|