EnCoach Odoo 19 custom modules
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
This commit is contained in:
293
encoach_api/controllers/evaluations.py
Normal file
293
encoach_api/controllers/evaluations.py
Normal file
@@ -0,0 +1,293 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user