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:
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user