|
|
|
|
@@ -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,
|
|
|
|
|
}
|