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:
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', []),
|
||||
}
|
||||
Reference in New Issue
Block a user