feat: initial backend codebase — EnCoach v3
Complete Odoo 19 backend with 25 custom addons: - encoach_core: user/entity/role management - encoach_api: REST API + JWT auth - encoach_ai: OpenAI integration, AI settings, generation - encoach_ai_course: AI-powered English & IELTS course generation - encoach_exam_template/session: exam creation, structures, sessions - encoach_scoring: AI auto-grading + manual approval - encoach_vector: pgvector RAG integration - encoach_adaptive: adaptive learning engine - encoach_placement: placement testing - encoach_taxonomy/resources: content taxonomy & resource management - Plus 14 more modules for courses, branding, portal, etc. Includes docs: user guide, generation report, developer workflow. Made-with: Cursor
This commit is contained in:
1
custom_addons/encoach_ai_course/controllers/__init__.py
Normal file
1
custom_addons/encoach_ai_course/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import ai_course
|
||||
561
custom_addons/encoach_ai_course/controllers/ai_course.py
Normal file
561
custom_addons/encoach_ai_course/controllers/ai_course.py
Normal file
@@ -0,0 +1,561 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import http, fields
|
||||
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 EncoachAiCourseController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai-course/english/create
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/english/create', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_english(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
cefr_level = body.get('cefr_level')
|
||||
if not cefr_level:
|
||||
return _error_response('cefr_level is required', 400)
|
||||
|
||||
user = request.env.user.sudo()
|
||||
gap_profile_id = body.get('gap_profile_id')
|
||||
|
||||
GapProfile = request.env['encoach.gap.profile'].sudo()
|
||||
if gap_profile_id:
|
||||
gap_profile = GapProfile.browse(int(gap_profile_id))
|
||||
if not gap_profile.exists():
|
||||
return _error_response('Gap profile not found', 404)
|
||||
else:
|
||||
gap_profile = GapProfile.search(
|
||||
[('student_id', '=', user.id)],
|
||||
order='created_at desc', limit=1,
|
||||
)
|
||||
if not gap_profile:
|
||||
gap_profile = GapProfile.create({
|
||||
'student_id': user.id,
|
||||
'source_type': 'exam',
|
||||
'skill_gaps': json.dumps([]),
|
||||
'topic_weaknesses': json.dumps([]),
|
||||
})
|
||||
|
||||
brief = {
|
||||
'skill_gaps': json.loads(gap_profile.skill_gaps or '[]'),
|
||||
'topic_weaknesses': json.loads(gap_profile.topic_weaknesses or '[]'),
|
||||
'cefr_level': cefr_level,
|
||||
}
|
||||
|
||||
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||
log = Log.create({
|
||||
'student_id': user.id,
|
||||
'course_type': 'general_english',
|
||||
'status': 'pending_review',
|
||||
'brief': json.dumps(brief),
|
||||
'attempts': 1,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'log_id': log.id,
|
||||
'status': log.status,
|
||||
'brief': brief,
|
||||
}, 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('create_english failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai-course/ielts/create
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/ielts/create', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_ielts(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
skill = body.get('skill')
|
||||
target_band = body.get('target_band')
|
||||
|
||||
if not skill:
|
||||
return _error_response('skill is required', 400)
|
||||
if not target_band:
|
||||
return _error_response('target_band is required', 400)
|
||||
|
||||
valid_skills = ('listening', 'reading', 'writing', 'speaking')
|
||||
if skill not in valid_skills:
|
||||
return _error_response(
|
||||
f'skill must be one of: {", ".join(valid_skills)}', 400,
|
||||
)
|
||||
|
||||
brief = {
|
||||
'skill': skill,
|
||||
'target_band': float(target_band),
|
||||
'custom_instructions': body.get('brief', ''),
|
||||
}
|
||||
|
||||
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||
log = Log.create({
|
||||
'skill': skill,
|
||||
'status': 'format_check',
|
||||
'brief': json.dumps(brief),
|
||||
'attempts': 1,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'log_id': log.id,
|
||||
'status': log.status,
|
||||
'skill': log.skill,
|
||||
}, 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('create_ielts failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai-course/<int:course_id>/quality
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/<int:course_id>/quality', type='http',
|
||||
auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def quality(self, course_id, **kw):
|
||||
try:
|
||||
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||
log = Log.browse(course_id)
|
||||
if not log.exists():
|
||||
return _error_response('Generation log not found', 404)
|
||||
|
||||
return _json_response({
|
||||
'status': log.status,
|
||||
'readability_score': 0.0,
|
||||
'cefr_alignment': log.status in ('quality_check', 'approved'),
|
||||
'grammar_issues': [],
|
||||
'attempts': log.attempts,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('quality check failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai-course/<int:course_id>/approve
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/<int:course_id>/approve', type='http',
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def approve(self, course_id, **kw):
|
||||
try:
|
||||
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||
log = Log.browse(course_id)
|
||||
if not log.exists():
|
||||
return _error_response('Generation log not found', 404)
|
||||
|
||||
if log.status == 'approved':
|
||||
return _error_response('Already approved', 400)
|
||||
|
||||
log.write({
|
||||
'status': 'approved',
|
||||
'approved_by': request.env.user.id,
|
||||
})
|
||||
|
||||
return _json_response({'approved': True})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('approve failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai-course/<int:course_id>/reject
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/<int:course_id>/reject', type='http',
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def reject(self, course_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
|
||||
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||
log = Log.browse(course_id)
|
||||
if not log.exists():
|
||||
return _error_response('Generation log not found', 404)
|
||||
|
||||
reason = body.get('reason', '')
|
||||
can_retry = log.attempts < 3
|
||||
|
||||
if can_retry:
|
||||
log.write({
|
||||
'status': 'generating',
|
||||
'attempts': log.attempts + 1,
|
||||
'error_log': reason or log.error_log,
|
||||
})
|
||||
else:
|
||||
log.write({
|
||||
'status': 'rejected',
|
||||
'error_log': reason or log.error_log,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'rejected': True,
|
||||
'can_retry': can_retry,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('reject failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai-course/<int:course_id>/validation
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/<int:course_id>/validation', type='http',
|
||||
auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def validation(self, course_id, **kw):
|
||||
try:
|
||||
IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||
ielts_log = IeltsLog.browse(course_id)
|
||||
if ielts_log.exists():
|
||||
fmt_result = {}
|
||||
band_result = {}
|
||||
try:
|
||||
fmt_result = json.loads(ielts_log.format_check_result or '{}')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
try:
|
||||
band_result = json.loads(ielts_log.band_check_result or '{}')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
overall_passed = bool(
|
||||
ielts_log.format_check_result and ielts_log.band_check_result
|
||||
)
|
||||
return _json_response({
|
||||
'type': 'ielts',
|
||||
'validation_results': {
|
||||
'format_check_result': fmt_result,
|
||||
'band_check_result': band_result,
|
||||
},
|
||||
'overall_passed': overall_passed,
|
||||
})
|
||||
|
||||
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||
log = Log.browse(course_id)
|
||||
if not log.exists():
|
||||
return _error_response('Generation log not found', 404)
|
||||
|
||||
validation_results = {
|
||||
'status': log.status,
|
||||
'quality_passed': log.status in ('approved', 'pending_review'),
|
||||
'attempts': log.attempts,
|
||||
}
|
||||
|
||||
return _json_response({
|
||||
'type': 'english',
|
||||
'validation_results': validation_results,
|
||||
'overall_passed': log.status == 'approved',
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('validation check failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai-course/<int:course_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/<int:course_id>', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_course(self, course_id, **kw):
|
||||
try:
|
||||
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||
log = Log.browse(course_id)
|
||||
if not log.exists():
|
||||
IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||
ielts = IeltsLog.browse(course_id)
|
||||
if not ielts.exists():
|
||||
return _error_response('Course/log not found', 404)
|
||||
return _json_response({
|
||||
'id': ielts.id,
|
||||
'type': 'ielts',
|
||||
'skill': ielts.skill or '',
|
||||
'status': ielts.status or '',
|
||||
'review_status': getattr(ielts, 'review_status', ''),
|
||||
'created_at': ielts.create_date.isoformat() if ielts.create_date else '',
|
||||
})
|
||||
|
||||
brief = {}
|
||||
try:
|
||||
brief = json.loads(log.brief or '{}')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return _json_response({
|
||||
'id': log.id,
|
||||
'type': 'general_english',
|
||||
'status': log.status or '',
|
||||
'course_type': log.course_type or '',
|
||||
'brief': brief,
|
||||
'attempts': log.attempts,
|
||||
'student_id': log.student_id.id if log.student_id else None,
|
||||
'created_at': log.create_date.isoformat() if log.create_date else '',
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('get_course failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai-course/<int:course_id>/tracks
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/<int:course_id>/tracks', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_tracks(self, course_id, **kw):
|
||||
try:
|
||||
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||
log = Log.browse(course_id)
|
||||
if not log.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
|
||||
generated = {}
|
||||
try:
|
||||
generated = json.loads(log.generated_content or '{}')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
tracks = []
|
||||
modules = generated.get('modules', [])
|
||||
for i, mod in enumerate(modules):
|
||||
tracks.append({
|
||||
'index': i,
|
||||
'title': mod.get('title', f'Module {i+1}'),
|
||||
'skill': mod.get('skill', ''),
|
||||
'status': 'completed' if i == 0 else 'locked',
|
||||
'progress': 100 if i == 0 else 0,
|
||||
})
|
||||
|
||||
if not tracks:
|
||||
tracks = [{
|
||||
'index': 0,
|
||||
'title': 'Course content pending generation',
|
||||
'skill': '',
|
||||
'status': 'pending',
|
||||
'progress': 0,
|
||||
}]
|
||||
|
||||
return _json_response({'tracks': tracks})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('get_tracks failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai-course/english/taxonomy
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/english/taxonomy', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def english_taxonomy(self, **kw):
|
||||
try:
|
||||
taxonomy = {
|
||||
'skills': ['reading', 'listening', 'writing', 'speaking', 'grammar', 'vocabulary'],
|
||||
'cefr_levels': ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'],
|
||||
'content_types': ['lesson', 'exercise', 'assessment', 'review'],
|
||||
'topic_domains': [
|
||||
'daily_life', 'work', 'education', 'travel',
|
||||
'technology', 'environment', 'health', 'culture',
|
||||
],
|
||||
}
|
||||
|
||||
Taxonomy = request.env.get('encoach.taxonomy.domain')
|
||||
if Taxonomy:
|
||||
domains = Taxonomy.sudo().search([])
|
||||
if domains:
|
||||
taxonomy['topic_domains'] = [
|
||||
{'id': d.id, 'name': d.name, 'description': getattr(d, 'description', '')}
|
||||
for d in domains
|
||||
]
|
||||
|
||||
return _json_response(taxonomy)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('english_taxonomy failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai-course/examiner-review
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/examiner-review', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def examiner_review(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
log_id = body.get('log_id')
|
||||
action = body.get('action')
|
||||
examiner_notes = body.get('examiner_notes', '')
|
||||
|
||||
if not log_id:
|
||||
return _error_response('log_id is required', 400)
|
||||
if action not in ('approve', 'reject', 'revise'):
|
||||
return _error_response('action must be approve, reject, or revise', 400)
|
||||
|
||||
IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||
log = IeltsLog.browse(int(log_id))
|
||||
if not log.exists():
|
||||
return _error_response('Log not found', 404)
|
||||
|
||||
status_map = {
|
||||
'approve': 'approved',
|
||||
'reject': 'rejected',
|
||||
'revise': 'revision_needed',
|
||||
}
|
||||
|
||||
log.write({
|
||||
'review_status': status_map[action],
|
||||
'examiner_id': request.env.user.id,
|
||||
'examiner_notes': examiner_notes,
|
||||
'reviewed_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
return _json_response({'status': status_map[action], 'log_id': log_id})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('examiner_review failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai-course/review-queue
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/review-queue', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def review_queue(self, **kw):
|
||||
try:
|
||||
body = _get_json_body() if request.httprequest.content_length else {}
|
||||
page = int(kw.get('page', 1))
|
||||
size = int(kw.get('size', 20))
|
||||
|
||||
Resource = request.env['encoach.resource'].sudo()
|
||||
domain = [('ai_generated', '=', True), ('review_status', '=', 'pending')]
|
||||
total = Resource.search_count(domain)
|
||||
resources = Resource.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
|
||||
|
||||
return _json_response({
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': size,
|
||||
'items': [r.to_api_dict() for r in resources],
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('review_queue failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai-course/review/<int:resource_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/review/<int:resource_id>', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def review_resource(self, resource_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
action = body.get('action')
|
||||
notes = body.get('notes', '')
|
||||
|
||||
if action not in ('approve', 'reject'):
|
||||
return _error_response('action must be approve or reject', 400)
|
||||
|
||||
Resource = request.env['encoach.resource'].sudo()
|
||||
resource = Resource.browse(resource_id)
|
||||
if not resource.exists():
|
||||
return _error_response('Resource not found', 404)
|
||||
|
||||
vals = {
|
||||
'review_status': 'approved' if action == 'approve' else 'rejected',
|
||||
'approved': action == 'approve',
|
||||
}
|
||||
resource.write(vals)
|
||||
|
||||
return _json_response({'status': vals['review_status'], 'resource_id': resource_id})
|
||||
except Exception as e:
|
||||
_logger.exception('review_resource failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai-course/ielts-review-queue
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/ielts-review-queue', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def ielts_review_queue(self, **kw):
|
||||
try:
|
||||
page = int(kw.get('page', 1))
|
||||
size = int(kw.get('size', 20))
|
||||
|
||||
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||
domain = [('review_status', '=', 'pending_review')]
|
||||
total = Log.search_count(domain)
|
||||
logs = Log.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
|
||||
|
||||
items = []
|
||||
for log in logs:
|
||||
items.append({
|
||||
'id': log.id,
|
||||
'skill': log.skill or '',
|
||||
'band_target': log.band_target if hasattr(log, 'band_target') else 0,
|
||||
'review_status': log.review_status,
|
||||
'created_at': log.create_date.isoformat() if log.create_date else '',
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': size,
|
||||
'items': items,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('ielts_review_queue failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai-course/ielts-review/<int:log_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/ielts-review/<int:log_id>', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def ielts_review(self, log_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
action = body.get('action')
|
||||
examiner_notes = body.get('examiner_notes', '')
|
||||
|
||||
if action not in ('approve', 'reject', 'revise'):
|
||||
return _error_response('action must be approve, reject, or revise', 400)
|
||||
|
||||
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||
log = Log.browse(log_id)
|
||||
if not log.exists():
|
||||
return _error_response('Log not found', 404)
|
||||
|
||||
status_map = {
|
||||
'approve': 'approved',
|
||||
'reject': 'rejected',
|
||||
'revise': 'revision_needed',
|
||||
}
|
||||
|
||||
log.write({
|
||||
'review_status': status_map[action],
|
||||
'examiner_id': request.env.user.id,
|
||||
'examiner_notes': examiner_notes,
|
||||
'reviewed_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
return _json_response({'status': status_map[action], 'log_id': log_id})
|
||||
except Exception as e:
|
||||
_logger.exception('ielts_review failed')
|
||||
return _error_response(str(e), 500)
|
||||
Reference in New Issue
Block a user