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:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
commit 907a5c0e92
331 changed files with 23511 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import services

View 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,
}

View File

@@ -0,0 +1,3 @@
from . import exam_session
from . import grading
from . import score_release

View 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)

View 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)

View 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)

View File

@@ -0,0 +1,5 @@
from . import student_attempt
from . import student_answer
from . import score
from . import feedback
from . import student_progress

View 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')

View 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')

View 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)

View 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')

View 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')

View File

@@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_student_attempt_user encoach.student.attempt.user model_encoach_student_attempt base.group_user 1 1 1 1
3 access_encoach_student_answer_user encoach.student.answer.user model_encoach_student_answer base.group_user 1 1 1 1
4 access_encoach_score_user encoach.score.user model_encoach_score base.group_user 1 1 1 1
5 access_encoach_feedback_user encoach.feedback.user model_encoach_feedback base.group_user 1 1 1 1
6 access_encoach_student_progress_user encoach.student.progress.user model_encoach_student_progress base.group_user 1 1 1 1

View File

@@ -0,0 +1 @@
from .speaking_evaluator import SpeakingEvaluator

View 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}'}

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>