feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
This commit is contained in:
3
custom_addons/encoach_scoring/__init__.py
Normal file
3
custom_addons/encoach_scoring/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import services
|
||||
21
custom_addons/encoach_scoring/__manifest__.py
Normal file
21
custom_addons/encoach_scoring/__manifest__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
'name': 'EnCoach Scoring',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Exam scoring, grading queue, feedback, and score release management',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen', 'encoach_resources'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/student_attempt_views.xml',
|
||||
'views/student_answer_views.xml',
|
||||
'views/score_views.xml',
|
||||
'views/feedback_views.xml',
|
||||
'views/student_progress_views.xml',
|
||||
'views/scoring_menus.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
3
custom_addons/encoach_scoring/controllers/__init__.py
Normal file
3
custom_addons/encoach_scoring/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import exam_session
|
||||
from . import grading
|
||||
from . import score_release
|
||||
427
custom_addons/encoach_scoring/controllers/exam_session.py
Normal file
427
custom_addons/encoach_scoring/controllers/exam_session.py
Normal file
@@ -0,0 +1,427 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import http
|
||||
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__)
|
||||
|
||||
AUTO_SCORABLE_TYPES = {'mcq', 'mcq_multi', 'gap_fill', 'tfng', 'ynng', 'numerical',
|
||||
'short_answer', 'form_completion', 'note_completion',
|
||||
'matching', 'summary_completion', 'heading_matching',
|
||||
'matching_features', 'map_labelling'}
|
||||
|
||||
SUBJECTIVE_SKILLS = {'writing', 'speaking'}
|
||||
|
||||
BAND_TO_CEFR = [
|
||||
(8.5, 'c2'), (7.0, 'c1'), (5.5, 'b2'),
|
||||
(4.5, 'b1'), (3.5, 'a2'), (2.5, 'a1'), (0.0, 'pre_a1'),
|
||||
]
|
||||
|
||||
|
||||
def _band_to_cefr(band):
|
||||
for threshold, level in BAND_TO_CEFR:
|
||||
if band >= threshold:
|
||||
return level
|
||||
return 'pre_a1'
|
||||
|
||||
|
||||
def _question_to_student_dict(q):
|
||||
return {
|
||||
'id': q.id,
|
||||
'skill': q.skill or '',
|
||||
'question_type': q.question_type or '',
|
||||
'stem': q.stem or '',
|
||||
'options': json.loads(q.options) if q.options else [],
|
||||
'marks': q.marks,
|
||||
'difficulty': q.difficulty or '',
|
||||
'source_type': q.source_type or '',
|
||||
'source_id': q.source_id or 0,
|
||||
}
|
||||
|
||||
|
||||
def _score_objective(question, student_answer):
|
||||
"""Compare student answer to correct_answer for objective question types."""
|
||||
correct = (question.correct_answer or '').strip().lower()
|
||||
given = (student_answer or '').strip().lower()
|
||||
if not correct:
|
||||
return False, 0.0
|
||||
if question.question_type in ('mcq_multi', 'matching', 'matching_features'):
|
||||
try:
|
||||
correct_set = set(json.loads(question.correct_answer))
|
||||
given_set = set(json.loads(student_answer)) if student_answer else set()
|
||||
correct_set = {str(v).strip().lower() for v in correct_set}
|
||||
given_set = {str(v).strip().lower() for v in given_set}
|
||||
is_correct = correct_set == given_set
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
is_correct = correct == given
|
||||
else:
|
||||
is_correct = correct == given
|
||||
return is_correct, question.marks if is_correct else 0.0
|
||||
|
||||
|
||||
def _compute_bands(attempt, Answer, Score):
|
||||
"""Compute per-skill band scores and overall band for an attempt."""
|
||||
skill_totals = {}
|
||||
skill_max = {}
|
||||
|
||||
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
|
||||
q = ans.question_id
|
||||
skill = q.skill or 'general'
|
||||
skill_totals.setdefault(skill, 0.0)
|
||||
skill_max.setdefault(skill, 0.0)
|
||||
skill_totals[skill] += ans.score or 0.0
|
||||
skill_max[skill] += q.marks or 1.0
|
||||
|
||||
band_map = {}
|
||||
for skill, total in skill_totals.items():
|
||||
mx = skill_max.get(skill, 1.0) or 1.0
|
||||
raw_pct = total / mx
|
||||
band = round(raw_pct * 9.0 * 2) / 2 # round to nearest 0.5
|
||||
band = min(band, 9.0)
|
||||
band_map[skill] = band
|
||||
|
||||
existing = Score.search([
|
||||
('attempt_id', '=', attempt.id), ('skill', '=', skill),
|
||||
], limit=1)
|
||||
score_vals = {
|
||||
'band_score': band,
|
||||
'raw_score': total,
|
||||
'max_score': mx,
|
||||
'cefr_level': _band_to_cefr(band),
|
||||
'entity_id': attempt.entity_id.id if attempt.entity_id else False,
|
||||
}
|
||||
if existing:
|
||||
existing.write(score_vals)
|
||||
else:
|
||||
score_vals.update({
|
||||
'attempt_id': attempt.id,
|
||||
'skill': skill,
|
||||
})
|
||||
Score.create(score_vals)
|
||||
|
||||
skill_field_map = {
|
||||
'listening': 'listening_band',
|
||||
'reading': 'reading_band',
|
||||
'writing': 'writing_band',
|
||||
'speaking': 'speaking_band',
|
||||
}
|
||||
write_vals = {}
|
||||
for skill, field in skill_field_map.items():
|
||||
if skill in band_map:
|
||||
write_vals[field] = band_map[skill]
|
||||
|
||||
counted = [b for s, b in band_map.items() if s in skill_field_map]
|
||||
if counted:
|
||||
overall = round(sum(counted) / len(counted) * 2) / 2
|
||||
overall = min(overall, 9.0)
|
||||
else:
|
||||
overall = 0.0
|
||||
|
||||
write_vals['overall_band'] = overall
|
||||
write_vals['cefr_level'] = _band_to_cefr(overall)
|
||||
|
||||
existing_overall = Score.search([
|
||||
('attempt_id', '=', attempt.id), ('skill', '=', 'overall'),
|
||||
], limit=1)
|
||||
overall_vals = {
|
||||
'band_score': overall,
|
||||
'raw_score': sum(skill_totals.values()),
|
||||
'max_score': sum(skill_max.values()),
|
||||
'cefr_level': _band_to_cefr(overall),
|
||||
'entity_id': attempt.entity_id.id if attempt.entity_id else False,
|
||||
}
|
||||
if existing_overall:
|
||||
existing_overall.write(overall_vals)
|
||||
else:
|
||||
overall_vals.update({'attempt_id': attempt.id, 'skill': 'overall'})
|
||||
Score.create(overall_vals)
|
||||
|
||||
attempt.write(write_vals)
|
||||
return band_map, overall
|
||||
|
||||
|
||||
class EncoachExamSessionController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/exam/<int:exam_id>/session
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/exam/<int:exam_id>/session', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_session(self, exam_id, **kw):
|
||||
try:
|
||||
Exam = request.env['encoach.exam.custom'].sudo()
|
||||
exam = Exam.browse(exam_id)
|
||||
if not exam.exists():
|
||||
return _error_response('Exam not found', 404)
|
||||
|
||||
uid = request.env.user.id
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('exam_id', '=', exam.id),
|
||||
('status', 'in', ['in_progress', 'scoring']),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not attempt:
|
||||
attempt = Attempt.create({
|
||||
'student_id': uid,
|
||||
'exam_id': exam.id,
|
||||
'status': 'in_progress',
|
||||
'entity_id': exam.entity_id.id if exam.entity_id else False,
|
||||
})
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
saved_answers = {}
|
||||
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
|
||||
saved_answers[ans.question_id.id] = ans.answer or ''
|
||||
|
||||
sections = []
|
||||
for sec in exam.section_ids.sorted('sequence'):
|
||||
questions = []
|
||||
for q in sec.question_ids:
|
||||
q_dict = _question_to_student_dict(q)
|
||||
q_dict['saved_answer'] = saved_answers.get(q.id, '')
|
||||
questions.append(q_dict)
|
||||
sections.append({
|
||||
'id': sec.id,
|
||||
'title': sec.title,
|
||||
'skill': sec.skill or '',
|
||||
'time_limit_min': sec.time_limit_min or 0,
|
||||
'scoring_method': sec.scoring_method or 'auto',
|
||||
'sequence': sec.sequence,
|
||||
'questions': questions,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'exam_id': exam.id,
|
||||
'exam_title': exam.title,
|
||||
'total_time_min': exam.total_time_min or 0,
|
||||
'status': attempt.status,
|
||||
'started_at': attempt.started_at,
|
||||
'sections': sections,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('get_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/exam/<int:exam_id>/autosave
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/exam/<int:exam_id>/autosave', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def autosave(self, exam_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
attempt_id = body.get('attempt_id')
|
||||
if not attempt_id:
|
||||
return _error_response('attempt_id is required', 400)
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(int(attempt_id))
|
||||
if not attempt.exists():
|
||||
return _error_response('Attempt not found', 404)
|
||||
if attempt.student_id.id != request.env.user.id:
|
||||
return _error_response('Forbidden', 403)
|
||||
if attempt.status != 'in_progress':
|
||||
return _error_response('Attempt is not in progress', 400)
|
||||
|
||||
answers = body.get('answers', [])
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
|
||||
for ans_data in answers:
|
||||
q_id = ans_data.get('question_id')
|
||||
if not q_id:
|
||||
continue
|
||||
existing = Answer.search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', int(q_id)),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write({'answer': ans_data.get('answer', '')})
|
||||
else:
|
||||
Answer.create({
|
||||
'attempt_id': attempt.id,
|
||||
'question_id': int(q_id),
|
||||
'answer': ans_data.get('answer', ''),
|
||||
})
|
||||
|
||||
from odoo.fields import Datetime as DT
|
||||
now = DT.now()
|
||||
autosave_meta = json.dumps({
|
||||
'current_section': body.get('current_section', ''),
|
||||
'time_remaining_sec': body.get('time_remaining_sec', 0),
|
||||
'last_autosave': str(now),
|
||||
})
|
||||
attempt.write({'autosave_data': autosave_meta})
|
||||
|
||||
return _json_response({'saved': True, 'timestamp': str(now)})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('autosave failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/exam/<int:exam_id>/submit
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/exam/<int:exam_id>/submit', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def submit(self, exam_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
attempt_id = body.get('attempt_id')
|
||||
if not attempt_id:
|
||||
return _error_response('attempt_id is required', 400)
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(int(attempt_id))
|
||||
if not attempt.exists():
|
||||
return _error_response('Attempt not found', 404)
|
||||
if attempt.student_id.id != request.env.user.id:
|
||||
return _error_response('Forbidden', 403)
|
||||
if attempt.status not in ('in_progress',):
|
||||
return _error_response('Attempt cannot be submitted', 400)
|
||||
|
||||
answers = body.get('answers', [])
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
Question = request.env['encoach.question'].sudo()
|
||||
Score = request.env['encoach.score'].sudo()
|
||||
|
||||
has_subjective = False
|
||||
|
||||
for ans_data in answers:
|
||||
q_id = ans_data.get('question_id')
|
||||
if not q_id:
|
||||
continue
|
||||
q_id = int(q_id)
|
||||
q = Question.browse(q_id)
|
||||
if not q.exists():
|
||||
continue
|
||||
|
||||
existing = Answer.search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', q_id),
|
||||
], limit=1)
|
||||
|
||||
student_answer = ans_data.get('answer', '')
|
||||
is_correct = False
|
||||
score_val = 0.0
|
||||
|
||||
if q.question_type in AUTO_SCORABLE_TYPES and q.skill not in SUBJECTIVE_SKILLS:
|
||||
is_correct, score_val = _score_objective(q, student_answer)
|
||||
elif q.skill in SUBJECTIVE_SKILLS:
|
||||
has_subjective = True
|
||||
|
||||
vals = {
|
||||
'answer': student_answer,
|
||||
'is_correct': is_correct,
|
||||
'score': score_val,
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
vals.update({
|
||||
'attempt_id': attempt.id,
|
||||
'question_id': q_id,
|
||||
})
|
||||
Answer.create(vals)
|
||||
|
||||
from odoo.fields import Datetime as DT
|
||||
attempt.write({
|
||||
'completed_at': DT.now(),
|
||||
'status': 'scoring' if has_subjective else 'scored',
|
||||
})
|
||||
|
||||
band_map, overall = _compute_bands(attempt, Answer, Score)
|
||||
|
||||
exam = attempt.exam_id
|
||||
if not has_subjective:
|
||||
release_mode = exam.results_release_mode if exam else 'auto'
|
||||
if release_mode == 'auto':
|
||||
attempt.write({'status': 'released', 'released_at': DT.now()})
|
||||
elif release_mode == 'manual_approval':
|
||||
attempt.write({'status': 'pending_approval'})
|
||||
|
||||
result = {
|
||||
'attempt_id': attempt.id,
|
||||
'status': attempt.status,
|
||||
}
|
||||
if attempt.status == 'released':
|
||||
result['scores'] = {
|
||||
'listening': attempt.listening_band,
|
||||
'reading': attempt.reading_band,
|
||||
'writing': attempt.writing_band,
|
||||
'speaking': attempt.speaking_band,
|
||||
'overall': attempt.overall_band,
|
||||
'cefr': attempt.cefr_level or '',
|
||||
}
|
||||
|
||||
return _json_response(result)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('submit failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/exam/<int:exam_id>/results
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/exam/<int:exam_id>/results', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_results(self, exam_id, **kw):
|
||||
try:
|
||||
uid = request.env.user.id
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('exam_id', '=', int(exam_id)),
|
||||
('status', '=', 'released'),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not attempt:
|
||||
return _error_response('Results not yet released', 403)
|
||||
|
||||
scores = []
|
||||
for s in attempt.score_ids:
|
||||
scores.append({
|
||||
'skill': s.skill,
|
||||
'band_score': s.band_score,
|
||||
'raw_score': s.raw_score,
|
||||
'max_score': s.max_score,
|
||||
'cefr_level': s.cefr_level or '',
|
||||
})
|
||||
|
||||
feedback_list = []
|
||||
for fb in attempt.feedback_ids:
|
||||
feedback_list.append({
|
||||
'question_id': fb.question_id.id if fb.question_id else None,
|
||||
'feedback_text': fb.feedback_text or '',
|
||||
'source': fb.source,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'exam_id': attempt.exam_id.id if attempt.exam_id else None,
|
||||
'status': attempt.status,
|
||||
'completed_at': attempt.completed_at,
|
||||
'released_at': attempt.released_at,
|
||||
'listening_band': attempt.listening_band,
|
||||
'reading_band': attempt.reading_band,
|
||||
'writing_band': attempt.writing_band,
|
||||
'speaking_band': attempt.speaking_band,
|
||||
'overall_band': attempt.overall_band,
|
||||
'cefr_level': attempt.cefr_level or '',
|
||||
'scores': scores,
|
||||
'feedback': feedback_list,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('get_results failed')
|
||||
return _error_response(str(e), 500)
|
||||
372
custom_addons/encoach_scoring/controllers/grading.py
Normal file
372
custom_addons/encoach_scoring/controllers/grading.py
Normal file
@@ -0,0 +1,372 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import http
|
||||
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__)
|
||||
|
||||
SUBJECTIVE_SKILLS = {'writing', 'speaking'}
|
||||
|
||||
BAND_TO_CEFR = [
|
||||
(8.5, 'c2'), (7.0, 'c1'), (5.5, 'b2'),
|
||||
(4.5, 'b1'), (3.5, 'a2'), (2.5, 'a1'), (0.0, 'pre_a1'),
|
||||
]
|
||||
|
||||
|
||||
def _band_to_cefr(band):
|
||||
for threshold, level in BAND_TO_CEFR:
|
||||
if band >= threshold:
|
||||
return level
|
||||
return 'pre_a1'
|
||||
|
||||
|
||||
def _recompute_bands(attempt):
|
||||
"""Recompute skill and overall bands after grading updates."""
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
Score = request.env['encoach.score'].sudo()
|
||||
|
||||
skill_totals = {}
|
||||
skill_max = {}
|
||||
|
||||
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
|
||||
q = ans.question_id
|
||||
skill = q.skill or 'general'
|
||||
skill_totals.setdefault(skill, 0.0)
|
||||
skill_max.setdefault(skill, 0.0)
|
||||
skill_totals[skill] += ans.score or 0.0
|
||||
skill_max[skill] += q.marks or 1.0
|
||||
|
||||
band_map = {}
|
||||
skill_field_map = {
|
||||
'listening': 'listening_band',
|
||||
'reading': 'reading_band',
|
||||
'writing': 'writing_band',
|
||||
'speaking': 'speaking_band',
|
||||
}
|
||||
|
||||
for skill, total in skill_totals.items():
|
||||
mx = skill_max.get(skill, 1.0) or 1.0
|
||||
band = round((total / mx) * 9.0 * 2) / 2
|
||||
band = min(band, 9.0)
|
||||
band_map[skill] = band
|
||||
|
||||
existing = Score.search([
|
||||
('attempt_id', '=', attempt.id), ('skill', '=', skill),
|
||||
], limit=1)
|
||||
vals = {
|
||||
'band_score': band,
|
||||
'raw_score': total,
|
||||
'max_score': mx,
|
||||
'cefr_level': _band_to_cefr(band),
|
||||
'entity_id': attempt.entity_id.id if attempt.entity_id else False,
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
vals.update({'attempt_id': attempt.id, 'skill': skill})
|
||||
Score.create(vals)
|
||||
|
||||
write_vals = {}
|
||||
for skill, field in skill_field_map.items():
|
||||
if skill in band_map:
|
||||
write_vals[field] = band_map[skill]
|
||||
|
||||
counted = [b for s, b in band_map.items() if s in skill_field_map]
|
||||
overall = round(sum(counted) / len(counted) * 2) / 2 if counted else 0.0
|
||||
overall = min(overall, 9.0)
|
||||
write_vals['overall_band'] = overall
|
||||
write_vals['cefr_level'] = _band_to_cefr(overall)
|
||||
|
||||
existing_overall = Score.search([
|
||||
('attempt_id', '=', attempt.id), ('skill', '=', 'overall'),
|
||||
], limit=1)
|
||||
overall_score_vals = {
|
||||
'band_score': overall,
|
||||
'raw_score': sum(skill_totals.values()),
|
||||
'max_score': sum(skill_max.values()),
|
||||
'cefr_level': _band_to_cefr(overall),
|
||||
'entity_id': attempt.entity_id.id if attempt.entity_id else False,
|
||||
}
|
||||
if existing_overall:
|
||||
existing_overall.write(overall_score_vals)
|
||||
else:
|
||||
overall_score_vals.update({'attempt_id': attempt.id, 'skill': 'overall'})
|
||||
Score.create(overall_score_vals)
|
||||
|
||||
attempt.write(write_vals)
|
||||
return band_map, overall
|
||||
|
||||
|
||||
class EncoachGradingController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/grading/queue
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/grading/queue', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_queue(self, **kw):
|
||||
try:
|
||||
user = request.env.user.sudo()
|
||||
domain = [('status', '=', 'scoring')]
|
||||
|
||||
if user.entity_ids:
|
||||
domain.append(('entity_id', 'in', user.entity_ids.ids))
|
||||
|
||||
page = int(kw.get('page', 1))
|
||||
size = int(kw.get('size', 20))
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempts, total = _paginate(Attempt, domain, page, size, order='completed_at desc')
|
||||
|
||||
items = []
|
||||
for att in attempts:
|
||||
skills_to_grade = set()
|
||||
for ans in att.answer_ids:
|
||||
q = ans.question_id
|
||||
if q.skill in SUBJECTIVE_SKILLS and not ans.score:
|
||||
skills_to_grade.add(q.skill)
|
||||
|
||||
items.append({
|
||||
'attempt_id': att.id,
|
||||
'student_name': att.student_id.name or '',
|
||||
'student_id': att.student_id.id,
|
||||
'exam_id': att.exam_id.id if att.exam_id else None,
|
||||
'exam_name': att.exam_id.title if att.exam_id else '',
|
||||
'submitted_at': att.completed_at,
|
||||
'entity_id': att.entity_id.id if att.entity_id else None,
|
||||
'skills_to_grade': list(skills_to_grade),
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': size,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('get_queue failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/grading/<int:attempt_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/grading/<int:attempt_id>', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_attempt(self, attempt_id, **kw):
|
||||
try:
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(attempt_id)
|
||||
if not attempt.exists():
|
||||
return _error_response('Attempt not found', 404)
|
||||
|
||||
answers = []
|
||||
for ans in attempt.answer_ids:
|
||||
q = ans.question_id
|
||||
fb = request.env['encoach.feedback'].sudo().search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', q.id),
|
||||
], limit=1)
|
||||
answers.append({
|
||||
'answer_id': ans.id,
|
||||
'question_id': q.id,
|
||||
'skill': q.skill or '',
|
||||
'question_type': q.question_type or '',
|
||||
'stem': q.stem or '',
|
||||
'options': json.loads(q.options) if q.options else [],
|
||||
'correct_answer': q.correct_answer or '',
|
||||
'marks': q.marks,
|
||||
'student_answer': ans.answer or '',
|
||||
'is_correct': ans.is_correct,
|
||||
'current_score': ans.score or 0.0,
|
||||
'existing_feedback': {
|
||||
'feedback_text': fb.feedback_text or '',
|
||||
'rubric_scores': json.loads(fb.rubric_scores) if fb.rubric_scores else {},
|
||||
'source': fb.source,
|
||||
'graded_by': fb.graded_by.id if fb.graded_by else None,
|
||||
} if fb else None,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'student_id': attempt.student_id.id,
|
||||
'student_name': attempt.student_id.name or '',
|
||||
'exam_id': attempt.exam_id.id if attempt.exam_id else None,
|
||||
'exam_name': attempt.exam_id.title if attempt.exam_id else '',
|
||||
'status': attempt.status,
|
||||
'started_at': attempt.started_at,
|
||||
'completed_at': attempt.completed_at,
|
||||
'listening_band': attempt.listening_band,
|
||||
'reading_band': attempt.reading_band,
|
||||
'writing_band': attempt.writing_band,
|
||||
'speaking_band': attempt.speaking_band,
|
||||
'overall_band': attempt.overall_band,
|
||||
'answers': answers,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('get_attempt failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/grading/<int:attempt_id>/submit
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/grading/<int:attempt_id>/submit', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def submit_grade(self, attempt_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
grades = body.get('grades', [])
|
||||
if not grades:
|
||||
return _error_response('grades array is required', 400)
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(attempt_id)
|
||||
if not attempt.exists():
|
||||
return _error_response('Attempt not found', 404)
|
||||
if attempt.status not in ('scoring', 'scored'):
|
||||
return _error_response('Attempt is not awaiting grading', 400)
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
Feedback = request.env['encoach.feedback'].sudo()
|
||||
grader_id = request.env.user.id
|
||||
|
||||
for grade in grades:
|
||||
q_id = grade.get('question_id')
|
||||
if not q_id:
|
||||
continue
|
||||
q_id = int(q_id)
|
||||
score_val = float(grade.get('score', 0))
|
||||
feedback_text = grade.get('feedback_text', '')
|
||||
rubric_scores = grade.get('rubric_scores', {})
|
||||
|
||||
ans = Answer.search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', q_id),
|
||||
], limit=1)
|
||||
if ans:
|
||||
ans.write({'score': score_val, 'is_correct': score_val > 0})
|
||||
|
||||
existing_fb = Feedback.search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', q_id),
|
||||
('source', '=', 'teacher'),
|
||||
], limit=1)
|
||||
fb_vals = {
|
||||
'feedback_text': feedback_text,
|
||||
'rubric_scores': json.dumps(rubric_scores) if rubric_scores else '',
|
||||
'graded_by': grader_id,
|
||||
}
|
||||
if existing_fb:
|
||||
existing_fb.write(fb_vals)
|
||||
else:
|
||||
fb_vals.update({
|
||||
'attempt_id': attempt.id,
|
||||
'question_id': q_id,
|
||||
'source': 'teacher',
|
||||
})
|
||||
Feedback.create(fb_vals)
|
||||
|
||||
band_map, overall = _recompute_bands(attempt)
|
||||
|
||||
exam = attempt.exam_id
|
||||
release_mode = exam.results_release_mode if exam else 'auto'
|
||||
|
||||
from odoo.fields import Datetime as DT
|
||||
if release_mode == 'auto':
|
||||
attempt.write({'status': 'released', 'released_at': DT.now(),
|
||||
'released_by': grader_id})
|
||||
elif release_mode == 'manual_approval':
|
||||
attempt.write({'status': 'pending_approval'})
|
||||
else:
|
||||
attempt.write({'status': 'scored'})
|
||||
|
||||
scores = {}
|
||||
for s in attempt.score_ids:
|
||||
scores[s.skill] = {
|
||||
'band_score': s.band_score,
|
||||
'raw_score': s.raw_score,
|
||||
'max_score': s.max_score,
|
||||
'cefr_level': s.cefr_level or '',
|
||||
}
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'new_status': attempt.status,
|
||||
'updated_scores': scores,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('submit_grade failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/grading/ai-suggest
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/grading/ai-suggest', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def ai_suggest(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
attempt_id = body.get('attempt_id')
|
||||
question_id = body.get('question_id')
|
||||
if not attempt_id or not question_id:
|
||||
return _error_response('attempt_id and question_id are required', 400)
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(int(attempt_id))
|
||||
if not attempt.exists():
|
||||
return _error_response('Attempt not found', 404)
|
||||
|
||||
Question = request.env['encoach.question'].sudo()
|
||||
question = Question.browse(int(question_id))
|
||||
if not question.exists():
|
||||
return _error_response('Question not found', 404)
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
ans = Answer.search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', question.id),
|
||||
], limit=1)
|
||||
|
||||
student_response = ans.answer if ans else ''
|
||||
|
||||
suggested_score = question.marks * 0.5
|
||||
suggested_feedback = (
|
||||
f"AI suggestion for {question.skill} {question.question_type} question. "
|
||||
f"Student provided a response of {len(student_response)} characters. "
|
||||
f"Suggested mid-range score based on rubric criteria."
|
||||
)
|
||||
confidence = 0.6
|
||||
|
||||
if not student_response:
|
||||
suggested_score = 0.0
|
||||
suggested_feedback = "No response provided by student."
|
||||
confidence = 0.95
|
||||
|
||||
rubric = None
|
||||
if attempt.exam_id and attempt.exam_id.template_id:
|
||||
Rubric = request.env['encoach.rubric'].sudo()
|
||||
rubric = Rubric.search([
|
||||
('skill', '=', question.skill),
|
||||
], limit=1)
|
||||
|
||||
if rubric:
|
||||
suggested_feedback += f" Rubric '{rubric.name}' criteria should be applied."
|
||||
|
||||
return _json_response({
|
||||
'suggested_score': round(suggested_score, 1),
|
||||
'suggested_feedback': suggested_feedback,
|
||||
'confidence': confidence,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('ai_suggest failed')
|
||||
return _error_response(str(e), 500)
|
||||
129
custom_addons/encoach_scoring/controllers/score_release.py
Normal file
129
custom_addons/encoach_scoring/controllers/score_release.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import http
|
||||
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__)
|
||||
|
||||
|
||||
class EncoachScoreReleaseController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/scores/pending
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/scores/pending', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_pending(self, **kw):
|
||||
try:
|
||||
user = request.env.user.sudo()
|
||||
domain = [('status', '=', 'pending_approval')]
|
||||
|
||||
if user.entity_ids:
|
||||
domain.append(('entity_id', 'in', user.entity_ids.ids))
|
||||
|
||||
page = int(kw.get('page', 1))
|
||||
size = int(kw.get('size', 20))
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempts, total = _paginate(Attempt, domain, page, size, order='completed_at desc')
|
||||
|
||||
items = []
|
||||
for att in attempts:
|
||||
items.append({
|
||||
'attempt_id': att.id,
|
||||
'student_id': att.student_id.id,
|
||||
'student_name': att.student_id.name or '',
|
||||
'exam_id': att.exam_id.id if att.exam_id else None,
|
||||
'exam_name': att.exam_id.title if att.exam_id else '',
|
||||
'entity_id': att.entity_id.id if att.entity_id else None,
|
||||
'completed_at': att.completed_at,
|
||||
'listening_band': att.listening_band,
|
||||
'reading_band': att.reading_band,
|
||||
'writing_band': att.writing_band,
|
||||
'speaking_band': att.speaking_band,
|
||||
'overall_band': att.overall_band,
|
||||
'cefr_level': att.cefr_level or '',
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': size,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('get_pending failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/scores/<int:attempt_id>/release
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/scores/<int:attempt_id>/release', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def release(self, attempt_id, **kw):
|
||||
try:
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(attempt_id)
|
||||
if not attempt.exists():
|
||||
return _error_response('Attempt not found', 404)
|
||||
if attempt.status != 'pending_approval':
|
||||
return _error_response('Attempt is not pending approval', 400)
|
||||
|
||||
user = request.env.user.sudo()
|
||||
if attempt.entity_id and user.entity_ids:
|
||||
if attempt.entity_id.id not in user.entity_ids.ids:
|
||||
return _error_response('You are not an admin for this entity', 403)
|
||||
|
||||
from odoo.fields import Datetime as DT
|
||||
attempt.write({
|
||||
'status': 'released',
|
||||
'released_at': DT.now(),
|
||||
'released_by': user.id,
|
||||
})
|
||||
|
||||
return _json_response({'released': True})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('release failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/scores/<int:attempt_id>/reject
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/scores/<int:attempt_id>/reject', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def reject(self, attempt_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(attempt_id)
|
||||
if not attempt.exists():
|
||||
return _error_response('Attempt not found', 404)
|
||||
if attempt.status not in ('pending_approval', 'scored'):
|
||||
return _error_response('Attempt cannot be rejected', 400)
|
||||
|
||||
rejection_note = body.get('rejection_note', '')
|
||||
|
||||
Feedback = request.env['encoach.feedback'].sudo()
|
||||
Feedback.create({
|
||||
'attempt_id': attempt.id,
|
||||
'feedback_text': f"Score rejected: {rejection_note}" if rejection_note
|
||||
else "Scores rejected and sent back for re-grading.",
|
||||
'source': 'teacher',
|
||||
'graded_by': request.env.user.id,
|
||||
})
|
||||
|
||||
attempt.write({'status': 'scoring'})
|
||||
|
||||
return _json_response({'rejected': True})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('reject failed')
|
||||
return _error_response(str(e), 500)
|
||||
5
custom_addons/encoach_scoring/models/__init__.py
Normal file
5
custom_addons/encoach_scoring/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from . import student_attempt
|
||||
from . import student_answer
|
||||
from . import score
|
||||
from . import feedback
|
||||
from . import student_progress
|
||||
16
custom_addons/encoach_scoring/models/feedback.py
Normal file
16
custom_addons/encoach_scoring/models/feedback.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachFeedback(models.Model):
|
||||
_name = 'encoach.feedback'
|
||||
_description = 'Exam Feedback'
|
||||
|
||||
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade', index=True)
|
||||
question_id = fields.Many2one('encoach.question', ondelete='set null')
|
||||
feedback_text = fields.Text()
|
||||
source = fields.Selection([
|
||||
('teacher', 'Teacher'),
|
||||
('ai', 'AI'),
|
||||
], required=True)
|
||||
rubric_scores = fields.Text()
|
||||
graded_by = fields.Many2one('res.users', ondelete='set null')
|
||||
30
custom_addons/encoach_scoring/models/score.py
Normal file
30
custom_addons/encoach_scoring/models/score.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachScore(models.Model):
|
||||
_name = 'encoach.score'
|
||||
_description = 'Exam Score'
|
||||
|
||||
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade', index=True)
|
||||
skill = fields.Selection([
|
||||
('listening', 'Listening'),
|
||||
('reading', 'Reading'),
|
||||
('writing', 'Writing'),
|
||||
('speaking', 'Speaking'),
|
||||
('grammar', 'Grammar'),
|
||||
('vocabulary', 'Vocabulary'),
|
||||
('overall', 'Overall'),
|
||||
], required=True)
|
||||
band_score = fields.Float()
|
||||
raw_score = fields.Float()
|
||||
max_score = fields.Float()
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
('a1', 'A1'),
|
||||
('a2', 'A2'),
|
||||
('b1', 'B1'),
|
||||
('b2', 'B2'),
|
||||
('c1', 'C1'),
|
||||
('c2', 'C2'),
|
||||
])
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
14
custom_addons/encoach_scoring/models/student_answer.py
Normal file
14
custom_addons/encoach_scoring/models/student_answer.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachStudentAnswer(models.Model):
|
||||
_name = 'encoach.student.answer'
|
||||
_description = 'Student Answer'
|
||||
|
||||
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade', index=True)
|
||||
question_id = fields.Many2one('encoach.question', required=True, ondelete='cascade')
|
||||
answer = fields.Text()
|
||||
is_correct = fields.Boolean()
|
||||
score = fields.Float()
|
||||
time_spent_ms = fields.Integer()
|
||||
flagged = fields.Boolean(default=False)
|
||||
41
custom_addons/encoach_scoring/models/student_attempt.py
Normal file
41
custom_addons/encoach_scoring/models/student_attempt.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachStudentAttempt(models.Model):
|
||||
_name = 'encoach.student.attempt'
|
||||
_description = 'Student Exam Attempt'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
exam_id = fields.Many2one('encoach.exam.custom', ondelete='set null')
|
||||
started_at = fields.Datetime(default=fields.Datetime.now)
|
||||
completed_at = fields.Datetime()
|
||||
status = fields.Selection([
|
||||
('in_progress', 'In Progress'),
|
||||
('completed', 'Completed'),
|
||||
('scoring', 'Scoring'),
|
||||
('scored', 'Scored'),
|
||||
('released', 'Released'),
|
||||
('pending_approval', 'Pending Approval'),
|
||||
], default='in_progress', required=True)
|
||||
listening_band = fields.Float()
|
||||
reading_band = fields.Float()
|
||||
writing_band = fields.Float()
|
||||
speaking_band = fields.Float()
|
||||
overall_band = fields.Float()
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
('a1', 'A1'),
|
||||
('a2', 'A2'),
|
||||
('b1', 'B1'),
|
||||
('b2', 'B2'),
|
||||
('c1', 'C1'),
|
||||
('c2', 'C2'),
|
||||
])
|
||||
is_placement = fields.Boolean(default=False)
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
released_at = fields.Datetime()
|
||||
released_by = fields.Many2one('res.users', ondelete='set null')
|
||||
answer_ids = fields.One2many('encoach.student.answer', 'attempt_id')
|
||||
score_ids = fields.One2many('encoach.score', 'attempt_id')
|
||||
autosave_data = fields.Text()
|
||||
feedback_ids = fields.One2many('encoach.feedback', 'attempt_id')
|
||||
22
custom_addons/encoach_scoring/models/student_progress.py
Normal file
22
custom_addons/encoach_scoring/models/student_progress.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachStudentProgress(models.Model):
|
||||
_name = 'encoach.student.progress'
|
||||
_description = 'Student Progress'
|
||||
_order = 'completed_at desc'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
course_id = fields.Many2one('op.course', ondelete='cascade')
|
||||
module_id = fields.Many2one('encoach.course.module', ondelete='cascade')
|
||||
resource_id = fields.Many2one('encoach.resource', ondelete='set null')
|
||||
status = fields.Selection([
|
||||
('not_started', 'Not Started'),
|
||||
('in_progress', 'In Progress'),
|
||||
('completed', 'Completed'),
|
||||
('skipped', 'Skipped'),
|
||||
], default='not_started', required=True)
|
||||
score = fields.Float()
|
||||
time_spent_minutes = fields.Integer()
|
||||
completed_at = fields.Datetime()
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
@@ -0,0 +1,6 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_student_attempt_user,encoach.student.attempt.user,model_encoach_student_attempt,base.group_user,1,1,1,1
|
||||
access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1
|
||||
access_encoach_score_user,encoach.score.user,model_encoach_score,base.group_user,1,1,1,1
|
||||
access_encoach_feedback_user,encoach.feedback.user,model_encoach_feedback,base.group_user,1,1,1,1
|
||||
access_encoach_student_progress_user,encoach.student.progress.user,model_encoach_student_progress,base.group_user,1,1,1,1
|
||||
|
1
custom_addons/encoach_scoring/services/__init__.py
Normal file
1
custom_addons/encoach_scoring/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .speaking_evaluator import SpeakingEvaluator
|
||||
67
custom_addons/encoach_scoring/services/speaking_evaluator.py
Normal file
67
custom_addons/encoach_scoring/services/speaking_evaluator.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpeakingEvaluator:
|
||||
"""AI-powered speaking assessment using Whisper + GPT."""
|
||||
|
||||
@staticmethod
|
||||
def transcribe_audio(audio_path):
|
||||
"""Transcribe audio using Whisper."""
|
||||
try:
|
||||
import whisper
|
||||
model = whisper.load_model("base")
|
||||
result = model.transcribe(audio_path)
|
||||
return {
|
||||
'text': result['text'],
|
||||
'language': result.get('language', 'en'),
|
||||
'segments': result.get('segments', []),
|
||||
}
|
||||
except ImportError:
|
||||
_logger.warning("whisper not installed")
|
||||
return {'text': '', 'language': 'en', 'segments': [], 'error': 'Whisper not available'}
|
||||
|
||||
@staticmethod
|
||||
def evaluate_speaking(transcription, rubric_criteria, target_band=6.0):
|
||||
"""Evaluate speaking using OpenAI GPT."""
|
||||
try:
|
||||
import openai
|
||||
|
||||
prompt = (
|
||||
"You are an IELTS speaking examiner. Evaluate the following speaking response.\n\n"
|
||||
f"Target Band: {target_band}\n\n"
|
||||
f"Rubric Criteria:\n{rubric_criteria}\n\n"
|
||||
f"Transcription:\n{transcription}\n\n"
|
||||
"Provide scores for each criterion (0-9 scale) and detailed feedback.\n"
|
||||
"Return JSON format:\n"
|
||||
"{\n"
|
||||
' "fluency_coherence": {"score": X, "feedback": "..."},\n'
|
||||
' "lexical_resource": {"score": X, "feedback": "..."},\n'
|
||||
' "grammatical_range": {"score": X, "feedback": "..."},\n'
|
||||
' "pronunciation": {"score": X, "feedback": "..."},\n'
|
||||
' "overall_band": X,\n'
|
||||
' "general_feedback": "..."\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
client = openai.OpenAI()
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are an expert IELTS speaking examiner."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
temperature=0.3,
|
||||
)
|
||||
|
||||
import json
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return result
|
||||
|
||||
except ImportError:
|
||||
_logger.warning("openai not installed")
|
||||
return {'overall_band': 0, 'general_feedback': 'AI evaluation not available', 'error': 'OpenAI not available'}
|
||||
except Exception as e:
|
||||
_logger.error("Speaking evaluation error: %s", e)
|
||||
return {'overall_band': 0, 'general_feedback': f'Evaluation error: {e}'}
|
||||
42
custom_addons/encoach_scoring/views/feedback_views.xml
Normal file
42
custom_addons/encoach_scoring/views/feedback_views.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_feedback_form" model="ir.ui.view">
|
||||
<field name="name">encoach.feedback.form</field>
|
||||
<field name="model">encoach.feedback</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Feedback">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="attempt_id"/>
|
||||
<field name="question_id"/>
|
||||
<field name="source"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="graded_by"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="feedback_text"/>
|
||||
<field name="rubric_scores"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_feedback_list" model="ir.ui.view">
|
||||
<field name="name">encoach.feedback.list</field>
|
||||
<field name="model">encoach.feedback</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Feedback">
|
||||
<field name="attempt_id"/>
|
||||
<field name="question_id"/>
|
||||
<field name="source"/>
|
||||
<field name="graded_by"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
44
custom_addons/encoach_scoring/views/score_views.xml
Normal file
44
custom_addons/encoach_scoring/views/score_views.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_score_form" model="ir.ui.view">
|
||||
<field name="name">encoach.score.form</field>
|
||||
<field name="model">encoach.score</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Score">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="attempt_id"/>
|
||||
<field name="skill"/>
|
||||
<field name="cefr_level"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="band_score"/>
|
||||
<field name="raw_score"/>
|
||||
<field name="max_score"/>
|
||||
<field name="entity_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_score_list" model="ir.ui.view">
|
||||
<field name="name">encoach.score.list</field>
|
||||
<field name="model">encoach.score</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Scores">
|
||||
<field name="attempt_id"/>
|
||||
<field name="skill"/>
|
||||
<field name="band_score"/>
|
||||
<field name="raw_score"/>
|
||||
<field name="max_score"/>
|
||||
<field name="cefr_level"/>
|
||||
<field name="entity_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
22
custom_addons/encoach_scoring/views/scoring_menus.xml
Normal file
22
custom_addons/encoach_scoring/views/scoring_menus.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_exam_sessions"
|
||||
name="Exam Sessions"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="encoach_scoring.action_student_attempt"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_grading_queue"
|
||||
name="Grading Queue"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="encoach_scoring.action_grading_queue"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_score_approval"
|
||||
name="Score Approval"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="encoach_scoring.action_score_approval"
|
||||
sequence="30"/>
|
||||
|
||||
</odoo>
|
||||
45
custom_addons/encoach_scoring/views/student_answer_views.xml
Normal file
45
custom_addons/encoach_scoring/views/student_answer_views.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_student_answer_form" model="ir.ui.view">
|
||||
<field name="name">encoach.student.answer.form</field>
|
||||
<field name="model">encoach.student.answer</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Student Answer">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="attempt_id"/>
|
||||
<field name="question_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="is_correct"/>
|
||||
<field name="score"/>
|
||||
<field name="time_spent_ms"/>
|
||||
<field name="flagged"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="answer"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_student_answer_list" model="ir.ui.view">
|
||||
<field name="name">encoach.student.answer.list</field>
|
||||
<field name="model">encoach.student.answer</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Student Answers">
|
||||
<field name="attempt_id"/>
|
||||
<field name="question_id"/>
|
||||
<field name="is_correct"/>
|
||||
<field name="score"/>
|
||||
<field name="time_spent_ms"/>
|
||||
<field name="flagged"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
131
custom_addons/encoach_scoring/views/student_attempt_views.xml
Normal file
131
custom_addons/encoach_scoring/views/student_attempt_views.xml
Normal file
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_student_attempt_form" model="ir.ui.view">
|
||||
<field name="name">encoach.student.attempt.form</field>
|
||||
<field name="model">encoach.student.attempt</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Student Attempt">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="in_progress,completed,scoring,scored,released"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="student_id"/>
|
||||
<field name="exam_id"/>
|
||||
<field name="entity_id"/>
|
||||
<field name="is_placement"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="started_at"/>
|
||||
<field name="completed_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Scores" name="scores">
|
||||
<group>
|
||||
<group>
|
||||
<field name="listening_band"/>
|
||||
<field name="reading_band"/>
|
||||
<field name="writing_band"/>
|
||||
<field name="speaking_band"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="overall_band"/>
|
||||
<field name="cefr_level"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Answers" name="answers">
|
||||
<field name="answer_ids">
|
||||
<list editable="bottom">
|
||||
<field name="question_id"/>
|
||||
<field name="answer"/>
|
||||
<field name="is_correct"/>
|
||||
<field name="score"/>
|
||||
<field name="time_spent_ms"/>
|
||||
<field name="flagged"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Feedback" name="feedback">
|
||||
<field name="feedback_ids">
|
||||
<list editable="bottom">
|
||||
<field name="question_id"/>
|
||||
<field name="feedback_text"/>
|
||||
<field name="source"/>
|
||||
<field name="graded_by"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Release Info" name="release_info">
|
||||
<group>
|
||||
<field name="released_at"/>
|
||||
<field name="released_by"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_student_attempt_list" model="ir.ui.view">
|
||||
<field name="name">encoach.student.attempt.list</field>
|
||||
<field name="model">encoach.student.attempt</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Student Attempts">
|
||||
<field name="student_id"/>
|
||||
<field name="exam_id"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'in_progress'" decoration-success="status == 'released'" decoration-warning="status == 'scoring'"/>
|
||||
<field name="overall_band"/>
|
||||
<field name="cefr_level"/>
|
||||
<field name="started_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_student_attempt_search" model="ir.ui.view">
|
||||
<field name="name">encoach.student.attempt.search</field>
|
||||
<field name="model">encoach.student.attempt</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Attempts">
|
||||
<field name="student_id"/>
|
||||
<field name="exam_id"/>
|
||||
<separator/>
|
||||
<filter string="In Progress" name="in_progress" domain="[('status', '=', 'in_progress')]"/>
|
||||
<filter string="Completed" name="completed" domain="[('status', '=', 'completed')]"/>
|
||||
<filter string="Scoring" name="scoring" domain="[('status', '=', 'scoring')]"/>
|
||||
<filter string="Scored" name="scored" domain="[('status', '=', 'scored')]"/>
|
||||
<filter string="Pending Approval" name="pending_approval" domain="[('status', '=', 'pending_approval')]"/>
|
||||
<filter string="Released" name="released" domain="[('status', '=', 'released')]"/>
|
||||
<separator/>
|
||||
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||
<filter string="Exam" name="group_exam" context="{'group_by': 'exam_id'}"/>
|
||||
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_student_attempt" model="ir.actions.act_window">
|
||||
<field name="name">Exam Sessions</field>
|
||||
<field name="res_model">encoach.student.attempt</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_grading_queue" model="ir.actions.act_window">
|
||||
<field name="name">Grading Queue</field>
|
||||
<field name="res_model">encoach.student.attempt</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_scoring': 1}</field>
|
||||
</record>
|
||||
|
||||
<record id="action_score_approval" model="ir.actions.act_window">
|
||||
<field name="name">Score Approval</field>
|
||||
<field name="res_model">encoach.student.attempt</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_pending_approval': 1}</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_encoach_student_progress_form" model="ir.ui.view">
|
||||
<field name="name">encoach.student.progress.form</field>
|
||||
<field name="model">encoach.student.progress</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Student Progress">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="not_started,in_progress,completed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<field name="module_id"/>
|
||||
<field name="resource_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="score"/>
|
||||
<field name="time_spent_minutes"/>
|
||||
<field name="completed_at"/>
|
||||
<field name="entity_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_encoach_student_progress_list" model="ir.ui.view">
|
||||
<field name="name">encoach.student.progress.list</field>
|
||||
<field name="model">encoach.student.progress</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Student Progress">
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<field name="module_id"/>
|
||||
<field name="resource_id"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'in_progress'" decoration-success="status == 'completed'" decoration-warning="status == 'skipped'"/>
|
||||
<field name="score"/>
|
||||
<field name="time_spent_minutes"/>
|
||||
<field name="completed_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_encoach_student_progress_search" model="ir.ui.view">
|
||||
<field name="name">encoach.student.progress.search</field>
|
||||
<field name="model">encoach.student.progress</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Progress">
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<filter string="Completed" name="completed" domain="[('status', '=', 'completed')]"/>
|
||||
<filter string="In Progress" name="in_progress" domain="[('status', '=', 'in_progress')]"/>
|
||||
<separator/>
|
||||
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_encoach_student_progress" model="ir.actions.act_window">
|
||||
<field name="name">Student Progress</field>
|
||||
<field name="res_model">encoach.student.progress</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user