- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit) - Fix speaking script generation error handling and empty response display - Add custom exam list API (GET /api/exam/custom/list) - Add assignments REST API (list, create, get) - Add rubrics REST API (list, create) - Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player - Improve submit feedback with exam ID and status in toast notifications - Fix ExamsListPage to show both custom exams and exam sessions - Connect RubricsPage to backend API with fallback data - Add Dockerfile, docker-compose.yml, requirements.txt for deployment - Fix placement, grading, scoring, and auth controllers - Add ErrorBoundary component for frontend resilience - Add QA report and credentials documentation Made-with: Cursor
435 lines
16 KiB
Python
435 lines
16 KiB
Python
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)
|