feat(backend): Phase 2/3 hardening release

Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
  and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.

Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
  pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
  encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
  and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.

Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
  spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
  new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.

Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
  new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
  active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
  admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
  with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 14:16:09 +04:00
parent 1a0349c381
commit 3972023a30
64 changed files with 4121 additions and 701 deletions

View File

@@ -7,4 +7,4 @@ from . import exam_schedules
from . import rubrics
from . import approval_workflows
from . import entities
from . import exam_session
from . import review_workflow

View File

@@ -256,36 +256,46 @@ class ApprovalWorkflowController(http.Controller):
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve_request(self, req_id, **kw):
"""Approve the current stage or finalize the request.
Wrapped in a ``SAVEPOINT`` so stage + request writes commit or roll back
atomically. The savepoint is released only after both writes succeed,
preventing partial states (e.g. stage marked ``approved`` while request
still ``in_progress``) that were possible with the previous
non-transactional implementation.
"""
try:
body = _get_json_body()
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
return _error_response('Request not found', 404)
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'approved',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'approved',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
# Move to next stage or finalize.
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
stage_ids = [s.id for s in stages]
try:
idx = stage_ids.index(stage.id) if stage else -1
except ValueError:
idx = -1
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
stage_ids = [s.id for s in stages]
try:
idx = stage_ids.index(stage.id) if stage else -1
except ValueError:
idx = -1
if 0 <= idx and idx + 1 < len(stage_ids):
next_stage = request.env['encoach.approval.stage'].sudo().browse(stage_ids[idx + 1])
req_rec.write({
'current_stage_id': next_stage.id,
'state': 'in_progress',
})
else:
req_rec.write({'state': 'approved'})
if 0 <= idx and idx + 1 < len(stage_ids):
next_stage = request.env['encoach.approval.stage'].sudo().browse(
stage_ids[idx + 1]
)
req_rec.write({
'current_stage_id': next_stage.id,
'state': 'in_progress',
})
else:
req_rec.write({'state': 'approved'})
return _json_response({'success': True, 'id': req_id,
'state': req_rec.state})
@@ -297,19 +307,26 @@ class ApprovalWorkflowController(http.Controller):
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject_request(self, req_id, **kw):
"""Reject a stage and mark the request rejected atomically.
Uses a ``SAVEPOINT`` so that if the request ``write`` fails after the
stage has been updated, Postgres rolls both writes back and the caller
sees a clean 500 rather than a half-updated workflow.
"""
try:
body = _get_json_body()
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
return _error_response('Request not found', 404)
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'rejected',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
req_rec.write({'state': 'rejected'})
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'rejected',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
req_rec.write({'state': 'rejected'})
return _json_response({'success': True, 'id': req_id,
'state': req_rec.state})
except Exception as e:

View File

@@ -1,316 +0,0 @@
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,
)
_logger = logging.getLogger(__name__)
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,
}
class ExamSessionController(http.Controller):
@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)
sec_dict = {
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'difficulty': sec.difficulty or '',
'time_limit_min': sec.time_limit_min or 0,
'total_marks': sec.total_marks or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
'questions': questions,
}
if sec.passage_text:
sec_dict['passage_text'] = sec.passage_text
if sec.instructions_text:
sec_dict['instructions_text'] = sec.instructions_text
if sec.content_json:
try:
sec_dict['content'] = json.loads(sec.content_json)
except (json.JSONDecodeError, TypeError):
pass
sections.append(sec_dict)
return _json_response({
'attempt_id': attempt.id,
'exam_id': exam.id,
'exam_title': exam.title,
'exam_mode': exam.exam_mode or 'official',
'total_time_min': exam.total_time_min or 0,
'total_marks': exam.total_marks or 0,
'grading_system': exam.grading_system or 'ielts',
'access_type': exam.access_type or 'private',
'randomize_questions': exam.randomize_questions or False,
'status': attempt.status,
'started_at': str(attempt.started_at) if attempt.started_at else None,
'sections': sections,
})
except Exception as e:
_logger.exception('get_session failed')
return _error_response(str(e), 500)
@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')
raw_answers = body.get('answers', [])
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() or attempt.student_id.id != request.env.user.id:
return _error_response('Invalid attempt', 403)
answer_pairs = self._normalize_answers(raw_answers)
Answer = request.env['encoach.student.answer'].sudo()
saved = 0
for q_id, answer_val in answer_pairs:
existing = Answer.search([
('attempt_id', '=', attempt.id),
('question_id', '=', q_id),
], limit=1)
if existing:
existing.write({'answer': str(answer_val)})
else:
Answer.create({
'attempt_id': attempt.id,
'question_id': q_id,
'answer': str(answer_val),
})
saved += 1
return _json_response({'saved': saved})
except Exception as e:
_logger.exception('autosave failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/submit', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def submit_exam(self, exam_id, **kw):
try:
body = _get_json_body()
attempt_id = body.get('attempt_id')
raw_answers = body.get('answers', [])
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() or attempt.student_id.id != request.env.user.id:
return _error_response('Invalid attempt', 403)
answer_pairs = self._normalize_answers(raw_answers)
Answer = request.env['encoach.student.answer'].sudo()
Question = request.env['encoach.question'].sudo()
total_score = 0.0
max_score = 0.0
for q_id, answer_val in answer_pairs:
q = Question.browse(q_id)
if not q.exists():
continue
is_correct = False
score = 0.0
correct = (q.correct_answer or '').strip().lower()
given = str(answer_val).strip().lower()
if correct and given:
is_correct = correct == given
score = q.marks if is_correct else 0.0
existing = Answer.search([
('attempt_id', '=', attempt.id),
('question_id', '=', q_id),
], limit=1)
vals = {
'answer': str(answer_val),
'score': score,
'is_correct': is_correct,
}
if existing:
existing.write(vals)
else:
vals.update({
'attempt_id': attempt.id,
'question_id': q_id,
})
Answer.create(vals)
total_score += score
max_score += q.marks
from odoo.fields import Datetime
attempt.write({
'status': 'completed',
'finished_at': Datetime.now(),
'total_score': total_score,
'max_score': max_score,
})
return _json_response({
'attempt_id': attempt.id,
'status': 'completed',
'total_score': total_score,
'max_score': max_score,
'percentage': round(total_score / max_score * 100, 1) if max_score else 0,
'results_available': True,
})
except Exception as e:
_logger.exception('submit_exam failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/status', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_status(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', '=', exam_id),
], limit=1, order='id desc')
if not attempt:
return _error_response('No attempt found', 404)
scores_available = attempt.status == 'completed' and attempt.max_score > 0
return _json_response({
'status': attempt.status,
'scores_available': scores_available,
'total_score': attempt.total_score,
'max_score': attempt.max_score,
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
})
except Exception as e:
_logger.exception('get_status failed')
return _error_response(str(e), 500)
@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', '=', exam_id),
('status', '=', 'completed'),
], limit=1, order='id desc')
if not attempt:
return _error_response('No completed attempt found', 404)
Answer = request.env['encoach.student.answer'].sudo()
answers = []
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
answers.append({
'question_id': ans.question_id.id,
'answer': ans.answer or '',
'score': ans.score,
'is_correct': ans.is_correct,
'feedback': ans.feedback or '',
})
Exam = request.env['encoach.exam.custom'].sudo()
exam = Exam.browse(exam_id)
return _json_response({
'attempt_id': attempt.id,
'exam_id': exam_id,
'exam_title': exam.title if exam.exists() else '',
'status': attempt.status,
'total_score': attempt.total_score,
'max_score': attempt.max_score,
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
'started_at': str(attempt.started_at) if attempt.started_at else None,
'finished_at': str(attempt.finished_at) if attempt.finished_at else None,
'answers': answers,
})
except Exception as e:
_logger.exception('get_results failed')
return _error_response(str(e), 500)
@staticmethod
def _normalize_answers(raw):
"""Accept answers as list of {question_id, answer} or dict {qid: answer}."""
if isinstance(raw, list):
pairs = []
for item in raw:
if isinstance(item, dict):
qid = item.get('question_id') or item.get('qid')
ans = item.get('answer', '')
if qid:
pairs.append((int(qid), ans))
return pairs
if isinstance(raw, dict):
return [(int(k), v) for k, v in raw.items()]
return []

View File

@@ -0,0 +1,256 @@
"""Human-in-the-loop review workflow for AI-generated exams.
When an exam is created via the AI pipeline and fails the automated quality
gate (QualityChecker + IeltsValidator), it transitions to ``pending_review``
instead of being published directly (see ADR 0004 and P1.1 in the hardening
release). This controller exposes the admin-facing endpoints used by the
review queue UI to inspect, approve, or reject those exams.
Endpoints:
* ``GET /api/exam/review/queue`` — paginated list of pending exams
* ``GET /api/exam/review/<exam_id>`` — detail with per-question quality data
* ``POST /api/exam/review/<exam_id>/approve`` — publish (optional notes)
* ``POST /api/exam/review/<exam_id>/reject`` — back to draft (notes required)
"""
import json
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
_paginate,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _question_to_review_dict(q):
"""Compact projection of a question for the review UI."""
try:
report = json.loads(q.quality_report) if q.quality_report else None
except (TypeError, ValueError):
report = {'raw': q.quality_report}
return {
'id': q.id,
'skill': q.skill,
'question_type': q.question_type,
'difficulty': q.difficulty,
'stem': q.stem or '',
'marks': q.marks or 0.0,
'ai_generated': bool(q.ai_generated),
'ielts_certified': bool(q.ielts_certified),
'format_validated': bool(q.format_validated),
'ai_model_used': q.ai_model_used or '',
'ai_prompt_hash': q.ai_prompt_hash or '',
'quality_score': q.quality_score or 0.0,
'quality_report': report,
}
def _review_summary(exam):
"""Aggregate quality stats across every question in the exam."""
questions = exam.section_ids.mapped('question_ids')
total = len(questions)
if total == 0:
return {
'question_count': 0,
'avg_quality_score': 0.0,
'min_quality_score': 0.0,
'failing_count': 0,
'ai_generated_count': 0,
}
scores = [q.quality_score or 0.0 for q in questions]
failing_threshold = 0.7 # configurable later via ir.config_parameter
return {
'question_count': total,
'avg_quality_score': round(sum(scores) / total, 3),
'min_quality_score': round(min(scores), 3),
'failing_count': sum(1 for s in scores if s < failing_threshold),
'ai_generated_count': sum(1 for q in questions if q.ai_generated),
}
def _exam_to_review_dict(exam, *, include_questions=False):
data = {
'id': exam.id,
'title': exam.title,
'status': exam.status,
'subject_id': exam.subject_id.id if exam.subject_id else None,
'subject_name': exam.subject_id.name if exam.subject_id else '',
'entity_id': exam.entity_id.id if exam.entity_id else None,
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
'teacher_name': exam.teacher_id.name if exam.teacher_id else '',
'grading_system': exam.grading_system,
'total_time_min': exam.total_time_min or 0,
'total_marks': exam.total_marks or 0.0,
'reviewed_by_id': exam.reviewed_by_id.id if exam.reviewed_by_id else None,
'reviewed_by_name': exam.reviewed_by_id.name if exam.reviewed_by_id else '',
'reviewed_at': (
fields.Datetime.to_string(exam.reviewed_at) if exam.reviewed_at else None
),
'review_notes': exam.review_notes or '',
'summary': _review_summary(exam),
}
if include_questions:
data['sections'] = []
for sec in exam.section_ids:
data['sections'].append({
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'sequence': sec.sequence,
'questions': [
_question_to_review_dict(q) for q in sec.question_ids
],
})
return data
class EncoachExamReviewController(http.Controller):
"""Admin review queue for AI-generated exams gated by the quality checker."""
# ------------------------------------------------------------------
# GET /api/exam/review/queue
# ------------------------------------------------------------------
@http.route('/api/exam/review/queue', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def queue(self, **kw):
try:
Exam = request.env['encoach.exam.custom'].sudo()
# Default to pending_review but allow callers to inspect history too.
status = (kw.get('status') or 'pending_review').strip()
domain = [('status', '=', status)] if status else []
search = (kw.get('search') or '').strip()
if search:
domain.append(('title', 'ilike', search))
subject_id = kw.get('subject_id')
if subject_id:
try:
domain.append(('subject_id', '=', int(subject_id)))
except (TypeError, ValueError):
pass
total = Exam.search_count(domain)
page, per_page, offset = _paginate(kw)
exams = Exam.search(
domain, limit=per_page, offset=offset, order='id desc',
)
items = [_exam_to_review_dict(e) for e in exams]
return _json_response({
'items': items,
'data': items,
'total': total,
'page': page,
'size': per_page,
})
except Exception as e:
_logger.exception('exam review queue failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/review/<exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def detail(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
return _json_response(
_exam_to_review_dict(exam, include_questions=True)
)
except Exception as e:
_logger.exception('exam review detail failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/review/<exam_id>/approve
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'pending_review':
return _error_response(
"Only exams in 'pending_review' can be approved "
f"(current status: {exam.status})",
400,
)
body = _get_json_body() or {}
notes = (body.get('notes') or '').strip() or False
with request.env.cr.savepoint():
exam.write({
'status': 'published',
'reviewed_by_id': request.env.user.id,
'reviewed_at': fields.Datetime.now(),
'review_notes': notes,
})
_logger.info(
'exam %s approved by user %s', exam.id, request.env.user.id,
)
return _json_response(
_exam_to_review_dict(exam, include_questions=False)
)
except Exception as e:
_logger.exception('exam review approve failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/review/<exam_id>/reject
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'pending_review':
return _error_response(
"Only exams in 'pending_review' can be rejected "
f"(current status: {exam.status})",
400,
)
body = _get_json_body() or {}
notes = (body.get('notes') or '').strip()
# Require notes on rejection — the author needs actionable feedback.
if not notes:
return _error_response(
'Review notes are required when rejecting an exam.', 400,
)
with request.env.cr.savepoint():
exam.write({
'status': 'draft',
'reviewed_by_id': request.env.user.id,
'reviewed_at': fields.Datetime.now(),
'review_notes': notes,
})
_logger.info(
'exam %s rejected by user %s', exam.id, request.env.user.id,
)
return _json_response(
_exam_to_review_dict(exam, include_questions=False)
)
except Exception as e:
_logger.exception('exam review reject failed')
return _error_response(str(e), 500)

View File

@@ -11,5 +11,4 @@ from . import exam_custom_section
from . import exam_assignment
from . import exam_schedule
from . import exam_structure
from . import student_attempt
from . import approval

View File

@@ -39,7 +39,22 @@ class EncoachExamCustom(models.Model):
randomize_questions = fields.Boolean(default=False)
status = fields.Selection([
('draft', 'Draft'),
('pending_review', 'Pending Review'),
('published', 'Published'),
('archived', 'Archived'),
], default='draft', required=True)
section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id')
# Human-in-the-loop review audit trail. Populated by the
# /api/exam/review/<id>/(approve|reject) endpoints so we know who signed
# off on an AI-generated exam and what their reasoning was.
reviewed_by_id = fields.Many2one(
'res.users', ondelete='set null',
string='Reviewed By',
readonly=True,
)
reviewed_at = fields.Datetime(string='Reviewed At', readonly=True)
review_notes = fields.Text(
string='Review Notes',
help='Approver or rejecter comments. Required when rejecting back to draft.',
)

View File

@@ -1,4 +1,8 @@
from odoo import models, fields
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class EncoachQuestion(models.Model):
@@ -67,3 +71,55 @@ class EncoachQuestion(models.Model):
format_validated = fields.Boolean(default=False)
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
topic_id = fields.Many2one('encoach.topic', ondelete='set null')
ai_model_used = fields.Char(
string='AI Model',
help='LLM model that produced this question (e.g. gpt-4o-mini).',
index=True,
)
ai_prompt_hash = fields.Char(
string='Prompt Hash',
help='SHA-256 hex digest of the rendered prompt used to generate this question.',
index=True,
)
ai_log_id = fields.Integer(
string='AI Generation Log ID',
help='Row id in encoach.ai.generation.log (soft ref — no FK to avoid cross-module coupling).',
index=True,
)
ai_generated_at = fields.Datetime(string='AI Generated At')
quality_score = fields.Float(
string='Quality Score',
help='Aggregate quality score from automated checkers (0-1).',
)
quality_report = fields.Text(
string='Quality Report (JSON)',
help='JSON dump of the last QualityChecker + IeltsValidator run.',
)
@api.model
def _auto_init(self):
res = super()._auto_init()
cr = self.env.cr
for name, ddl in (
(
'encoach_question_skill_status_idx',
"CREATE INDEX IF NOT EXISTS encoach_question_skill_status_idx "
"ON encoach_question (skill, status)",
),
(
'encoach_question_subject_difficulty_idx',
"CREATE INDEX IF NOT EXISTS encoach_question_subject_difficulty_idx "
"ON encoach_question (subject_id, difficulty) WHERE subject_id IS NOT NULL",
),
(
'encoach_question_ai_prompt_hash_idx',
"CREATE INDEX IF NOT EXISTS encoach_question_ai_prompt_hash_idx "
"ON encoach_question (ai_prompt_hash) WHERE ai_prompt_hash IS NOT NULL",
),
):
try:
cr.execute(ddl)
except Exception:
_logger.warning("could not create index %s", name, exc_info=True)
return res

View File

@@ -1,52 +0,0 @@
from odoo import models, fields
class EncoachStudentAttempt(models.Model):
_name = 'encoach.student.attempt'
_description = 'Student Exam Attempt'
_order = 'id desc'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade')
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
status = fields.Selection([
('in_progress', 'In Progress'),
('scoring', 'Scoring'),
('completed', 'Completed'),
('abandoned', 'Abandoned'),
], default='in_progress', required=True)
started_at = fields.Datetime(default=fields.Datetime.now)
finished_at = fields.Datetime()
overall_band = fields.Float()
cefr_level = fields.Char(size=10)
listening_band = fields.Float()
reading_band = fields.Float()
writing_band = fields.Float()
speaking_band = fields.Float()
total_score = fields.Float()
max_score = fields.Float()
class EncoachStudentAnswer(models.Model):
_name = 'encoach.student.answer'
_description = 'Student Answer'
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
question_id = fields.Many2one('encoach.question', required=True, ondelete='cascade')
answer = fields.Text()
score = fields.Float()
is_correct = fields.Boolean()
feedback = fields.Text()
class EncoachStudentScore(models.Model):
_name = 'encoach.student.score'
_description = 'Student Skill Score'
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
skill = fields.Char(size=50, required=True)
band_score = fields.Float()
raw_score = fields.Float()
max_score = fields.Float()
cefr_level = fields.Char(size=10)
entity_id = fields.Many2one('encoach.entity', ondelete='set null')

View File

@@ -12,9 +12,6 @@ access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_e
access_encoach_exam_assignment_user,encoach.exam.assignment.user,model_encoach_exam_assignment,base.group_user,1,1,1,1
access_encoach_exam_schedule_user,encoach.exam.schedule.user,model_encoach_exam_schedule,base.group_user,1,1,1,1
access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1
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_student_score_user,encoach.student.score.user,model_encoach_student_score,base.group_user,1,1,1,1
access_encoach_approval_workflow_user,encoach.approval.workflow.user,model_encoach_approval_workflow,base.group_user,1,1,1,1
access_encoach_approval_stage_user,encoach.approval.stage.user,model_encoach_approval_stage,base.group_user,1,1,1,1
access_encoach_approval_request_user,encoach.approval.request.user,model_encoach_approval_request,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 access_encoach_exam_assignment_user encoach.exam.assignment.user model_encoach_exam_assignment base.group_user 1 1 1 1
13 access_encoach_exam_schedule_user encoach.exam.schedule.user model_encoach_exam_schedule base.group_user 1 1 1 1
14 access_encoach_exam_structure_user encoach.exam.structure.user model_encoach_exam_structure base.group_user 1 1 1 1
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_student_score_user encoach.student.score.user model_encoach_student_score base.group_user 1 1 1 1
15 access_encoach_approval_workflow_user encoach.approval.workflow.user model_encoach_approval_workflow base.group_user 1 1 1 1
16 access_encoach_approval_stage_user encoach.approval.stage.user model_encoach_approval_stage base.group_user 1 1 1 1
17 access_encoach_approval_request_user encoach.approval.request.user model_encoach_approval_request base.group_user 1 1 1 1