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

@@ -5,7 +5,7 @@
'summary': 'Computerized Adaptive Testing (CAT) placement engine with CEFR mapping',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_exam_template'],
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_ai'],
'data': [
'security/ir.model.access.csv',
'views/cat_session_views.xml',

View File

@@ -8,6 +8,7 @@ from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
from odoo.addons.encoach_ai.services.cefr_mapper import theta_to_cefr as _theta_to_cefr
_logger = logging.getLogger(__name__)
@@ -15,18 +16,6 @@ LEARNING_RATE = 0.3
SEM_THRESHOLD = 0.3
MAX_QUESTIONS = 40
THETA_CEFR = [
(-3.0, 'pre_a1'), (-2.0, 'a1'), (-1.0, 'a2'),
(0.0, 'b1'), (1.0, 'b2'), (2.0, 'c1'), (3.0, 'c2'),
]
def _theta_to_cefr(theta):
for boundary, level in THETA_CEFR:
if theta <= boundary:
return level
return 'c2'
def _irt_probability(theta, a, b, c):
"""3PL IRT probability."""

View File

@@ -1,83 +1,20 @@
class CefrMapper:
"""Maps IRT theta values to CEFR levels and IELTS band scores."""
"""Thin re-export of the canonical CEFR mapper for backward compat.
THETA_TO_CEFR = [
(-4.0, -2.5, 'pre_a1'),
(-2.5, -1.5, 'a1'),
(-1.5, -0.5, 'a2'),
(-0.5, 0.5, 'b1'),
(0.5, 1.5, 'b2'),
(1.5, 2.5, 'c1'),
(2.5, 4.0, 'c2'),
]
The canonical implementation lives in ``encoach_ai.services.cefr_mapper``.
This shim is kept so existing imports like
``from odoo.addons.encoach_placement.services.cefr_mapper import CefrMapper``
continue to work. See P0.9 in §21 of docs/PROJECT_SUMMARY.md.
"""
CEFR_TO_BAND = {
'pre_a1': 2.0,
'a1': 3.0,
'a2': 4.0,
'b1': 5.0,
'b2': 6.5,
'c1': 7.5,
'c2': 9.0,
}
CEFR_LABELS = {
'pre_a1': 'Pre-A1 (Beginner)',
'a1': 'A1 (Elementary)',
'a2': 'A2 (Pre-Intermediate)',
'b1': 'B1 (Intermediate)',
'b2': 'B2 (Upper-Intermediate)',
'c1': 'C1 (Advanced)',
'c2': 'C2 (Proficient)',
}
@staticmethod
def theta_to_cefr(theta):
for low, high, level in CefrMapper.THETA_TO_CEFR:
if low <= theta < high:
return level
return 'c2' if theta >= 2.5 else 'pre_a1'
@staticmethod
def theta_to_band(theta):
cefr = CefrMapper.theta_to_cefr(theta)
base_band = CefrMapper.CEFR_TO_BAND.get(cefr, 5.0)
for low, high, level in CefrMapper.THETA_TO_CEFR:
if level == cefr:
range_width = high - low
if range_width > 0:
position = (theta - low) / range_width
else:
position = 0.5
cefr_list = list(CefrMapper.CEFR_TO_BAND.keys())
idx = cefr_list.index(cefr)
next_band = CefrMapper.CEFR_TO_BAND.get(
cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0
)
band = base_band + position * (next_band - base_band)
return round(band * 2) / 2
return base_band
@staticmethod
def band_to_cefr(band):
if band < 2.5:
return 'pre_a1'
if band < 3.5:
return 'a1'
if band < 4.5:
return 'a2'
if band < 5.5:
return 'b1'
if band < 7.0:
return 'b2'
if band < 8.0:
return 'c1'
return 'c2'
@staticmethod
def get_cefr_label(cefr_code):
return CefrMapper.CEFR_LABELS.get(cefr_code, cefr_code)
from odoo.addons.encoach_ai.services.cefr_mapper import ( # noqa: F401
CefrMapper,
band_to_cefr,
theta_to_cefr,
theta_to_band,
cefr_to_band,
normalize_cefr,
cefr_label,
THETA_TO_CEFR,
CEFR_TO_BAND,
CEFR_LABELS,
)