feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
This commit is contained in:
3
custom_addons/encoach_ai_course/__init__.py
Normal file
3
custom_addons/encoach_ai_course/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import services
|
||||
from . import controllers
|
||||
20
custom_addons/encoach_ai_course/__manifest__.py
Normal file
20
custom_addons/encoach_ai_course/__manifest__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
'name': 'EnCoach AI Course',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'AI content generation pipelines for General English and IELTS courses',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/ai_generation_log_views.xml',
|
||||
'views/ai_ielts_log_views.xml',
|
||||
'views/ai_course_menus.xml',
|
||||
'views/ai_review_views.xml',
|
||||
'views/ielts_review_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
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
|
||||
396
custom_addons/encoach_ai_course/controllers/ai_course.py
Normal file
396
custom_addons/encoach_ai_course/controllers/ai_course.py
Normal file
@@ -0,0 +1,396 @@
|
||||
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/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)
|
||||
2
custom_addons/encoach_ai_course/models/__init__.py
Normal file
2
custom_addons/encoach_ai_course/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import ai_generation_log
|
||||
from . import ai_ielts_generation_log
|
||||
25
custom_addons/encoach_ai_course/models/ai_generation_log.py
Normal file
25
custom_addons/encoach_ai_course/models/ai_generation_log.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachAiGenerationLog(models.Model):
|
||||
_name = 'encoach.ai.generation.log'
|
||||
_description = 'AI Generation Log'
|
||||
|
||||
student_id = fields.Many2one('res.users', ondelete='set null')
|
||||
course_type = fields.Selection([
|
||||
('general_english', 'General English'),
|
||||
('ielts', 'IELTS'),
|
||||
], required=True)
|
||||
brief = fields.Text()
|
||||
attempts = fields.Integer(default=0)
|
||||
final_resource_id = fields.Integer(string='Final Resource ID')
|
||||
approved_by = fields.Many2one('res.users', ondelete='set null')
|
||||
status = fields.Selection([
|
||||
('generating', 'Generating'),
|
||||
('quality_check', 'Quality Check'),
|
||||
('pending_review', 'Pending Review'),
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
], default='generating', required=True)
|
||||
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||
error_log = fields.Text()
|
||||
@@ -0,0 +1,35 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachAiIeltsGenerationLog(models.Model):
|
||||
_name = 'encoach.ai.ielts.generation.log'
|
||||
_description = 'AI IELTS Generation Log'
|
||||
|
||||
skill = fields.Selection([
|
||||
('listening', 'Listening'),
|
||||
('reading', 'Reading'),
|
||||
('writing', 'Writing'),
|
||||
('speaking', 'Speaking'),
|
||||
], required=True)
|
||||
brief = fields.Text()
|
||||
format_check_result = fields.Text()
|
||||
band_check_result = fields.Text()
|
||||
examiner_id = fields.Many2one('res.users', ondelete='set null')
|
||||
status = fields.Selection([
|
||||
('generating', 'Generating'),
|
||||
('format_check', 'Format Check'),
|
||||
('examiner_review', 'Examiner Review'),
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
], default='generating', required=True)
|
||||
attempts = fields.Integer(default=0)
|
||||
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||
review_status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('pending_review', 'Pending Review'),
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
('revision_needed', 'Revision Needed'),
|
||||
], default='draft')
|
||||
examiner_notes = fields.Text()
|
||||
reviewed_at = fields.Datetime()
|
||||
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
|
||||
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
|
||||
|
2
custom_addons/encoach_ai_course/services/__init__.py
Normal file
2
custom_addons/encoach_ai_course/services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .english_pipeline import EnglishPipeline
|
||||
from .ielts_pipeline import IeltsPipeline
|
||||
124
custom_addons/encoach_ai_course/services/english_pipeline.py
Normal file
124
custom_addons/encoach_ai_course/services/english_pipeline.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
except ImportError:
|
||||
OpenAIService = None
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
|
||||
except ImportError:
|
||||
ContentSourceGate = None
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnglishPipeline:
|
||||
"""AI content generation pipeline for General English courses."""
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
self.ai = OpenAIService(env)
|
||||
|
||||
def generate_content(self, env, gap_profile, cefr_level):
|
||||
"""Generate General English content based on gap analysis.
|
||||
|
||||
Args:
|
||||
env: Odoo environment.
|
||||
gap_profile: dict with 'skill_gaps' list describing weak areas.
|
||||
cefr_level: target CEFR level string (e.g. 'B1').
|
||||
|
||||
Returns:
|
||||
dict with generated content.
|
||||
"""
|
||||
skill_gaps = gap_profile.get('skill_gaps', [])
|
||||
gaps_description = ', '.join(skill_gaps) if skill_gaps else 'general skills'
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': (
|
||||
'You are an expert English language curriculum designer. '
|
||||
'Return JSON with keys: "title", "objectives", "units" '
|
||||
'(each unit has "topic", "grammar_focus", "vocabulary", '
|
||||
'"reading_text", "exercises").'
|
||||
)},
|
||||
{'role': 'user', 'content': (
|
||||
f'Generate a General English course at CEFR {cefr_level} level. '
|
||||
f'Focus on these gap areas: {gaps_description}. '
|
||||
f'Include practical, real-world content appropriate for the level.'
|
||||
)},
|
||||
]
|
||||
|
||||
try:
|
||||
content = self.ai.chat_json(messages, temperature=0.8)
|
||||
except Exception as e:
|
||||
_logger.error("English content generation failed: %s", e)
|
||||
env['encoach.ai.generation.log'].create({
|
||||
'course_type': 'general_english',
|
||||
'brief': json.dumps(gap_profile),
|
||||
'status': 'rejected',
|
||||
'error_log': str(e),
|
||||
})
|
||||
return {'error': str(e)}
|
||||
|
||||
resource = env['encoach.ai.generation.log'].create({
|
||||
'course_type': 'general_english',
|
||||
'brief': json.dumps(gap_profile),
|
||||
'status': 'quality_check',
|
||||
'attempts': 1,
|
||||
})
|
||||
|
||||
# Apply content source gate logic
|
||||
if ContentSourceGate is not None:
|
||||
try:
|
||||
ContentSourceGate.apply_gate(resource)
|
||||
except Exception as e:
|
||||
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
|
||||
|
||||
return content
|
||||
|
||||
def quality_check(self, env, content, cefr_level):
|
||||
"""Run quality gate checks on generated content.
|
||||
|
||||
Args:
|
||||
env: Odoo environment.
|
||||
content: dict of generated content to validate.
|
||||
cefr_level: target CEFR level for alignment check.
|
||||
|
||||
Returns:
|
||||
dict with 'passed' bool and 'issues' list.
|
||||
"""
|
||||
issues = []
|
||||
|
||||
if not content.get('units'):
|
||||
issues.append('No units generated')
|
||||
else:
|
||||
for i, unit in enumerate(content['units'], 1):
|
||||
if not unit.get('exercises'):
|
||||
issues.append(f'Unit {i} has no exercises')
|
||||
if not unit.get('reading_text'):
|
||||
issues.append(f'Unit {i} has no reading text')
|
||||
|
||||
if not content.get('objectives'):
|
||||
issues.append('No learning objectives defined')
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': (
|
||||
'You are a CEFR alignment expert. Return JSON with keys: '
|
||||
'"aligned" (bool) and "issues" (list of strings).'
|
||||
)},
|
||||
{'role': 'user', 'content': (
|
||||
f'Check if this content is aligned with CEFR {cefr_level}: '
|
||||
f'{json.dumps(content)}'
|
||||
)},
|
||||
]
|
||||
|
||||
try:
|
||||
alignment = self.ai.chat_json(messages, temperature=0.3)
|
||||
if not alignment.get('aligned'):
|
||||
issues.extend(alignment.get('issues', ['CEFR misalignment detected']))
|
||||
except Exception as e:
|
||||
_logger.warning("CEFR alignment check failed: %s", e)
|
||||
issues.append(f'CEFR alignment check error: {e}')
|
||||
|
||||
return {'passed': len(issues) == 0, 'issues': issues}
|
||||
170
custom_addons/encoach_ai_course/services/ielts_pipeline.py
Normal file
170
custom_addons/encoach_ai_course/services/ielts_pipeline.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
except ImportError:
|
||||
OpenAIService = None
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
|
||||
except ImportError:
|
||||
ContentSourceGate = None
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
SKILL_PROMPTS = {
|
||||
'listening': (
|
||||
'Generate an IELTS Listening section. Return JSON with keys: '
|
||||
'"script", "speakers", "duration_estimate", "questions".'
|
||||
),
|
||||
'reading': (
|
||||
'Generate an IELTS Reading passage with questions. Return JSON with keys: '
|
||||
'"passage", "word_count", "questions", "question_types".'
|
||||
),
|
||||
'writing': (
|
||||
'Generate an IELTS Writing task. Return JSON with keys: '
|
||||
'"task_type", "prompt", "requirements", "sample_band_descriptors".'
|
||||
),
|
||||
'speaking': (
|
||||
'Generate an IELTS Speaking part. Return JSON with keys: '
|
||||
'"part", "questions", "cue_card" (if part 2), "follow_up_questions".'
|
||||
),
|
||||
}
|
||||
|
||||
IELTS_FORMAT_RULES = {
|
||||
'listening': {'required_keys': ['script', 'questions'], 'min_questions': 10},
|
||||
'reading': {'required_keys': ['passage', 'questions'], 'min_word_count': 500},
|
||||
'writing': {'required_keys': ['prompt', 'requirements']},
|
||||
'speaking': {'required_keys': ['questions']},
|
||||
}
|
||||
|
||||
|
||||
class IeltsPipeline:
|
||||
"""AI content generation pipeline for IELTS skill-specific content."""
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
self.ai = OpenAIService(env)
|
||||
|
||||
def generate_content(self, env, skill, brief):
|
||||
"""Generate IELTS skill-specific content.
|
||||
|
||||
Args:
|
||||
env: Odoo environment.
|
||||
skill: one of 'listening', 'reading', 'writing', 'speaking'.
|
||||
brief: dict with generation parameters.
|
||||
|
||||
Returns:
|
||||
dict with generated content.
|
||||
"""
|
||||
system_prompt = SKILL_PROMPTS.get(skill, SKILL_PROMPTS['reading'])
|
||||
brief_text = json.dumps(brief) if isinstance(brief, dict) else str(brief)
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': (
|
||||
f'You are an expert IELTS content creator. {system_prompt}'
|
||||
)},
|
||||
{'role': 'user', 'content': (
|
||||
f'Generate IELTS {skill} content based on this brief: {brief_text}'
|
||||
)},
|
||||
]
|
||||
|
||||
try:
|
||||
content = self.ai.chat_json(messages, temperature=0.8)
|
||||
except Exception as e:
|
||||
_logger.error("IELTS %s content generation failed: %s", skill, e)
|
||||
return {'error': str(e)}
|
||||
|
||||
resource = env['encoach.ai.ielts.generation.log'].create({
|
||||
'skill': skill,
|
||||
'brief': brief_text,
|
||||
'status': 'format_check',
|
||||
'attempts': 1,
|
||||
})
|
||||
|
||||
# Apply content source gate logic
|
||||
if ContentSourceGate is not None:
|
||||
try:
|
||||
ContentSourceGate.apply_gate(resource)
|
||||
except Exception as e:
|
||||
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
|
||||
|
||||
return content
|
||||
|
||||
def format_check(self, env, content, skill):
|
||||
"""Validate IELTS format compliance for a given skill.
|
||||
|
||||
Args:
|
||||
env: Odoo environment.
|
||||
content: dict of generated content.
|
||||
skill: IELTS skill type.
|
||||
|
||||
Returns:
|
||||
dict with 'passed' bool and 'errors' list.
|
||||
"""
|
||||
errors = []
|
||||
rules = IELTS_FORMAT_RULES.get(skill, {})
|
||||
|
||||
for key in rules.get('required_keys', []):
|
||||
if key not in content or not content[key]:
|
||||
errors.append(f'Missing required key: {key}')
|
||||
|
||||
if skill == 'listening':
|
||||
questions = content.get('questions', [])
|
||||
min_q = rules.get('min_questions', 10)
|
||||
if len(questions) < min_q:
|
||||
errors.append(
|
||||
f'Listening requires at least {min_q} questions, '
|
||||
f'got {len(questions)}'
|
||||
)
|
||||
|
||||
if skill == 'reading':
|
||||
passage = content.get('passage', '')
|
||||
min_wc = rules.get('min_word_count', 500)
|
||||
word_count = len(passage.split()) if passage else 0
|
||||
if word_count < min_wc:
|
||||
errors.append(
|
||||
f'Reading passage must be at least {min_wc} words, '
|
||||
f'got {word_count}'
|
||||
)
|
||||
|
||||
if skill == 'speaking' and not content.get('questions'):
|
||||
errors.append('Speaking section requires at least one question')
|
||||
|
||||
return {'passed': len(errors) == 0, 'errors': errors}
|
||||
|
||||
def band_calibration(self, env, content, target_band):
|
||||
"""Check content aligns with target IELTS band level.
|
||||
|
||||
Args:
|
||||
env: Odoo environment.
|
||||
content: dict of generated content.
|
||||
target_band: float target band score (e.g. 6.5).
|
||||
|
||||
Returns:
|
||||
dict with 'passed' bool and 'issues' list.
|
||||
"""
|
||||
messages = [
|
||||
{'role': 'system', 'content': (
|
||||
'You are an IELTS band calibration expert. Evaluate whether '
|
||||
'the provided content matches the target band level. '
|
||||
'Return JSON with keys: "calibrated" (bool), "estimated_band" (float), '
|
||||
'"issues" (list of strings).'
|
||||
)},
|
||||
{'role': 'user', 'content': (
|
||||
f'Target band: {target_band}. '
|
||||
f'Content: {json.dumps(content)}'
|
||||
)},
|
||||
]
|
||||
|
||||
try:
|
||||
result = self.ai.chat_json(messages, temperature=0.3)
|
||||
except Exception as e:
|
||||
_logger.error("Band calibration failed: %s", e)
|
||||
return {'passed': False, 'issues': [f'Band calibration error: {e}']}
|
||||
|
||||
return {
|
||||
'passed': result.get('calibrated', False),
|
||||
'issues': result.get('issues', []),
|
||||
}
|
||||
16
custom_addons/encoach_ai_course/views/ai_course_menus.xml
Normal file
16
custom_addons/encoach_ai_course/views/ai_course_menus.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_ai_generation_logs"
|
||||
name="AI Generation Logs"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_ai_course.action_ai_generation_log"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_ai_ielts_logs"
|
||||
name="IELTS Generation Logs"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_ai_course.action_ai_ielts_log"
|
||||
sequence="40"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_ai_generation_log_form" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.generation.log.form</field>
|
||||
<field name="model">encoach.ai.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Generation Log">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="generating,quality_check,pending_review,approved"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="student_id"/>
|
||||
<field name="course_type"/>
|
||||
<field name="attempts"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="final_resource_id"/>
|
||||
<field name="approved_by"/>
|
||||
<field name="created_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Brief" name="brief">
|
||||
<field name="brief"/>
|
||||
</page>
|
||||
<page string="Error Log" name="error_log">
|
||||
<field name="error_log"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_generation_log_list" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.generation.log.list</field>
|
||||
<field name="model">encoach.ai.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Generation Logs">
|
||||
<field name="course_type"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'pending_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
|
||||
<field name="attempts"/>
|
||||
<field name="student_id"/>
|
||||
<field name="created_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_generation_log_search" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.generation.log.search</field>
|
||||
<field name="model">encoach.ai.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Generation Logs">
|
||||
<field name="student_id"/>
|
||||
<separator/>
|
||||
<filter string="Generating" name="generating" domain="[('status', '=', 'generating')]"/>
|
||||
<filter string="Quality Check" name="quality_check" domain="[('status', '=', 'quality_check')]"/>
|
||||
<filter string="Pending Review" name="pending_review" domain="[('status', '=', 'pending_review')]"/>
|
||||
<filter string="Approved" name="approved" domain="[('status', '=', 'approved')]"/>
|
||||
<filter string="Rejected" name="rejected" domain="[('status', '=', 'rejected')]"/>
|
||||
<separator/>
|
||||
<filter string="General English" name="general_english" domain="[('course_type', '=', 'general_english')]"/>
|
||||
<filter string="IELTS" name="ielts" domain="[('course_type', '=', 'ielts')]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ai_generation_log" model="ir.actions.act_window">
|
||||
<field name="name">AI Generation Logs</field>
|
||||
<field name="res_model">encoach.ai.generation.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
59
custom_addons/encoach_ai_course/views/ai_ielts_log_views.xml
Normal file
59
custom_addons/encoach_ai_course/views/ai_ielts_log_views.xml
Normal file
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_ai_ielts_log_form" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.ielts.generation.log.form</field>
|
||||
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="IELTS Generation Log">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="generating,format_check,examiner_review,approved"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="skill"/>
|
||||
<field name="attempts"/>
|
||||
<field name="examiner_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="created_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Brief" name="brief">
|
||||
<field name="brief"/>
|
||||
</page>
|
||||
<page string="Format Check Result" name="format_check">
|
||||
<field name="format_check_result"/>
|
||||
</page>
|
||||
<page string="Band Check Result" name="band_check">
|
||||
<field name="band_check_result"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_ielts_log_list" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.ielts.generation.log.list</field>
|
||||
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="IELTS Generation Logs">
|
||||
<field name="skill"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'examiner_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
|
||||
<field name="attempts"/>
|
||||
<field name="examiner_id"/>
|
||||
<field name="created_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ai_ielts_log" model="ir.actions.act_window">
|
||||
<field name="name">IELTS Generation Logs</field>
|
||||
<field name="res_model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
27
custom_addons/encoach_ai_course/views/ai_review_views.xml
Normal file
27
custom_addons/encoach_ai_course/views/ai_review_views.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_ai_review_queue_list" model="ir.ui.view">
|
||||
<field name="name">encoach.resource.ai.review.list</field>
|
||||
<field name="model">encoach.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Content Review Queue">
|
||||
<field name="name"/>
|
||||
<field name="type"/>
|
||||
<field name="cefr_level"/>
|
||||
<field name="grammar_topic"/>
|
||||
<field name="review_status" widget="badge" decoration-info="review_status == 'pending'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'"/>
|
||||
<field name="create_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ai_review_queue" model="ir.actions.act_window">
|
||||
<field name="name">AI Content Review</field>
|
||||
<field name="res_model">encoach.resource</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('ai_generated', '=', True)]</field>
|
||||
<field name="context">{'search_default_pending_review': 1}</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
52
custom_addons/encoach_ai_course/views/ielts_review_views.xml
Normal file
52
custom_addons/encoach_ai_course/views/ielts_review_views.xml
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_ielts_review_form" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.ielts.generation.log.review.form</field>
|
||||
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="IELTS Content Review">
|
||||
<header>
|
||||
<field name="review_status" widget="statusbar" statusbar_visible="draft,pending_review,approved"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="skill" readonly="1"/>
|
||||
<field name="review_status"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="examiner_id"/>
|
||||
<field name="reviewed_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Examiner Notes">
|
||||
<field name="examiner_notes" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ielts_review_list" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.ielts.generation.log.review.list</field>
|
||||
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="IELTS Content Review Queue">
|
||||
<field name="skill"/>
|
||||
<field name="review_status" widget="badge" decoration-info="review_status == 'pending_review'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'" decoration-warning="review_status == 'revision_needed'"/>
|
||||
<field name="examiner_id"/>
|
||||
<field name="reviewed_at"/>
|
||||
<field name="create_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ielts_review_queue" model="ir.actions.act_window">
|
||||
<field name="name">IELTS Content Review</field>
|
||||
<field name="res_model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('review_status', '\!=', 'draft')]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user