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:
@@ -0,0 +1 @@
|
||||
from .validator import IeltsValidator
|
||||
334
custom_addons/encoach_ielts_validation/services/validator.py
Normal file
334
custom_addons/encoach_ielts_validation/services/validator.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user