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:
Yamen Ahmad
2026-04-11 15:44:20 +04:00
commit 982d4bca30
371 changed files with 35211 additions and 0 deletions

View File

@@ -0,0 +1 @@
from .validator import IeltsValidator

View File

@@ -0,0 +1,334 @@
import json
import logging
_logger = logging.getLogger(__name__)
ACADEMIC_PASSAGE_WORD_RANGES = {
1: (700, 900),
2: (800, 1000),
3: (900, 1100),
}
GENERAL_PASSAGE_WORD_RANGES = {
1: (400, 700),
2: (600, 900),
3: (800, 1100),
}
LISTENING_SPEAKER_MAP = {
1: 'conversation',
2: 'monologue',
3: 'conversation',
4: 'monologue',
}
LISTENING_CONTEXT_MAP = {
1: ['social', 'everyday'],
2: ['social', 'everyday'],
3: ['academic', 'training'],
4: ['academic', 'lecture'],
}
AUTO_SCORED_TYPES = [
'multiple_choice', 'true_false_not_given', 'yes_no_not_given',
'matching_headings', 'matching_information', 'sentence_completion',
'summary_completion', 'form_completion', 'map_labeling',
]
class IeltsValidator:
"""Two-layer IELTS content validator: format compliance and structural checks."""
def validate_passage(self, passage_record):
"""Validate reading passage structure and constraints.
Academic passages: section 1 (700-900w), section 2 (800-1000w), section 3 (900-1100w).
General passages: section 1 (400-700w), section 2 (600-900w), section 3 (800-1100w).
"""
errors = []
section_num = passage_record.get('section_num', 0)
if section_num < 1 or section_num > 3:
errors.append(f'Invalid section_num {section_num}, must be 1-3')
body_text = passage_record.get('body_text', '')
word_count = len(body_text.split()) if body_text else 0
exam_type = passage_record.get('exam_type', 'academic')
word_ranges = ACADEMIC_PASSAGE_WORD_RANGES if exam_type == 'academic' else GENERAL_PASSAGE_WORD_RANGES
expected = word_ranges.get(section_num, (400, 1100))
if word_count < expected[0] or word_count > expected[1]:
errors.append(
f'Word count {word_count} outside expected range '
f'{expected[0]}-{expected[1]} for {exam_type} section {section_num}'
)
if not body_text or not body_text.strip():
errors.append('body_text must not be empty')
if not passage_record.get('topic_category'):
errors.append('topic_category must not be empty')
if not passage_record.get('title'):
errors.append('Passage title is required')
questions = passage_record.get('questions', [])
if questions:
q_count = len(questions)
if section_num == 1 and not (10 <= q_count <= 15):
errors.append(f'Section 1 should have 10-15 questions, got {q_count}')
elif section_num == 2 and not (12 <= q_count <= 15):
errors.append(f'Section 2 should have 12-15 questions, got {q_count}')
elif section_num == 3 and not (13 <= q_count <= 15):
errors.append(f'Section 3 should have 13-15 questions, got {q_count}')
return {'passed': len(errors) == 0, 'errors': errors}
def validate_audio(self, audio_record):
"""Validate audio/listening content structure.
Parts 1 & 3 must be conversation (2+ speakers).
Parts 2 & 4 must be monologue (single speaker).
"""
errors = []
part = audio_record.get('part', 0)
if part < 1 or part > 4:
errors.append(f'Invalid part {part}, must be 1-4')
expected_format = LISTENING_SPEAKER_MAP.get(part)
actual_format = audio_record.get('speaker_format', '')
if expected_format and actual_format and actual_format != expected_format:
errors.append(
f'Part {part} requires {expected_format} format, got {actual_format}'
)
context_type = audio_record.get('context_type', '')
allowed = LISTENING_CONTEXT_MAP.get(part, [])
if allowed and context_type and context_type not in allowed:
errors.append(
f'context_type "{context_type}" invalid for part {part}, '
f'expected one of: {allowed}'
)
if not audio_record.get('audio_url'):
errors.append('audio_url must not be empty')
duration = audio_record.get('duration_seconds', 0)
if duration > 0:
if part in (1, 2) and duration > 600:
errors.append(f'Part {part} audio should not exceed 10 minutes, got {duration}s')
if part in (3, 4) and duration > 900:
errors.append(f'Part {part} audio should not exceed 15 minutes, got {duration}s')
if not audio_record.get('transcript'):
errors.append('Transcript is recommended for listening content')
questions = audio_record.get('questions', [])
if questions:
q_count = len(questions)
if not (8 <= q_count <= 12):
errors.append(f'Listening part should have 8-12 questions, got {q_count}')
return {'passed': len(errors) == 0, 'errors': errors}
def validate_writing_prompt(self, prompt_record):
"""Validate writing prompt requirements.
Task 1: min_words >= 150, academic needs visual_url.
Task 2: min_words >= 250.
"""
errors = []
task_type = prompt_record.get('task_type', '')
min_words = prompt_record.get('min_words', 0)
if task_type == 'task1':
if min_words < 150:
errors.append(f'Task 1 requires min_words >= 150, got {min_words}')
exam_type = prompt_record.get('exam_type', '')
if exam_type == 'academic' and not prompt_record.get('visual_url'):
errors.append('Academic Task 1 requires a visual_url (chart/graph/diagram)')
if not prompt_record.get('prompt_text'):
errors.append('Task 1 prompt_text is required')
elif task_type == 'task2':
if min_words < 250:
errors.append(f'Task 2 requires min_words >= 250, got {min_words}')
if not prompt_record.get('prompt_text'):
errors.append('Task 2 prompt_text (essay topic) is required')
else:
errors.append(f'Unknown task_type: {task_type}')
if not prompt_record.get('rubric_id'):
errors.append('rubric must be assigned')
time_limit = prompt_record.get('time_limit_minutes', 0)
if task_type == 'task1' and time_limit > 0 and time_limit != 20:
errors.append(f'Task 1 recommended time is 20 minutes, got {time_limit}')
if task_type == 'task2' and time_limit > 0 and time_limit != 40:
errors.append(f'Task 2 recommended time is 40 minutes, got {time_limit}')
return {'passed': len(errors) == 0, 'errors': errors}
def validate_speaking_card(self, card_record):
"""Validate speaking card structure.
Part 1: needs questions list (4-5 questions).
Part 2: needs topic + bullet_points (3-4 points) + follow-up question.
Part 3: needs questions list (4-6 discussion questions).
"""
errors = []
part = card_record.get('part', 0)
if part < 1 or part > 3:
errors.append(f'Invalid part {part}, must be 1-3')
if part == 1:
questions = card_record.get('questions', [])
if not questions:
errors.append('Part 1 requires a non-empty questions list')
elif len(questions) < 4:
errors.append(f'Part 1 should have at least 4 questions, got {len(questions)}')
if not card_record.get('topic'):
errors.append('Part 1 topic is required')
if part == 2:
bullet_points = card_record.get('bullet_points', [])
if not bullet_points:
errors.append('Part 2 requires non-empty bullet_points')
elif len(bullet_points) < 3:
errors.append(f'Part 2 should have at least 3 bullet points, got {len(bullet_points)}')
if not card_record.get('topic'):
errors.append('Part 2 topic card is required')
if not card_record.get('follow_up'):
errors.append('Part 2 should include a follow-up question')
if part == 3:
questions = card_record.get('questions', [])
if not questions:
errors.append('Part 3 requires a non-empty questions list')
elif len(questions) < 4:
errors.append(f'Part 3 should have at least 4 discussion questions, got {len(questions)}')
return {'passed': len(errors) == 0, 'errors': errors}
def validate_question(self, question_record):
"""Validate question record.
Auto-scored types need correct_answer.
IRT params: discrimination [0.2, 3.0], difficulty [-4, 4], guessing [0, 0.5].
"""
errors = []
q_type = question_record.get('question_type', '')
if q_type in AUTO_SCORED_TYPES:
if not question_record.get('correct_answer'):
errors.append(f'correct_answer required for auto-scored type "{q_type}"')
if q_type == 'multiple_choice':
options = question_record.get('options', [])
if options and len(options) < 3:
errors.append('Multiple choice questions should have at least 3 options')
correct = question_record.get('correct_answer')
if correct and options and correct not in options:
errors.append('correct_answer must be one of the provided options')
discrimination = question_record.get('irt_discrimination', 1.0)
if discrimination < 0.2 or discrimination > 3.0:
errors.append(
f'irt_discrimination {discrimination} out of recommended range [0.2, 3.0]'
)
difficulty = question_record.get('irt_difficulty', 0.0)
if difficulty < -4 or difficulty > 4:
errors.append(
f'irt_difficulty {difficulty} out of valid range [-4, 4]'
)
guessing = question_record.get('irt_guessing', 0.25)
if guessing < 0 or guessing > 0.5:
errors.append(
f'irt_guessing {guessing} out of valid range [0, 0.5]'
)
if not question_record.get('question_text'):
errors.append('question_text must not be empty')
return {'passed': len(errors) == 0, 'errors': errors}
def run_full_validation(self, env, content_records):
"""Run all validators and create ielts.standards.check records.
Args:
env: Odoo environment.
content_records: list of dicts with '_type' key indicating content type.
Returns:
dict summary with 'total', 'passed', 'failed', 'results', and 'all_passed'.
"""
validators = {
'passage': self.validate_passage,
'audio': self.validate_audio,
'writing_prompt': self.validate_writing_prompt,
'speaking_card': self.validate_speaking_card,
'question': self.validate_question,
}
results = []
passed_count = 0
failed_count = 0
CheckModel = env['encoach.ielts.standards.check'].sudo()
for record in content_records:
content_type = record.get('_type', '')
validator = validators.get(content_type)
if not validator:
results.append({
'content_type': content_type,
'passed': False,
'errors': [f'Unknown content type: {content_type}'],
})
failed_count += 1
continue
result = validator(record)
results.append({
'content_type': content_type,
'passed': result['passed'],
'errors': result.get('errors', []),
})
if result['passed']:
passed_count += 1
else:
failed_count += 1
content_id = record.get('id', 0)
check_type_map = {
'passage': 'reading_format',
'audio': 'listening_format',
'writing_prompt': 'writing_format',
'speaking_card': 'speaking_format',
'question': 'question_format',
}
try:
CheckModel.create({
'content_id': content_id,
'content_type': content_type,
'check_type': check_type_map.get(content_type, 'format_compliance'),
'passed': result['passed'],
'error_detail': json.dumps(result.get('errors', [])),
})
except Exception as e:
_logger.error("Failed to create standards check record: %s", e)
return {
'total': len(content_records),
'passed': passed_count,
'failed': failed_count,
'all_passed': failed_count == 0,
'results': results,
}