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

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