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:
2
backend/custom_addons/encoach_quality_gate/__init__.py
Normal file
2
backend/custom_addons/encoach_quality_gate/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import services
|
||||
17
backend/custom_addons/encoach_quality_gate/__manifest__.py
Normal file
17
backend/custom_addons/encoach_quality_gate/__manifest__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
'name': 'EnCoach Quality Gate',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Content quality gates: readability analysis, CEFR alignment, grammar checking',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_exam_template'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/ielts_check_views.xml',
|
||||
'views/quality_menus.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import ielts_standards_check
|
||||
@@ -0,0 +1,22 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachIeltsStandardsCheck(models.Model):
|
||||
_name = 'encoach.ielts.standards.check'
|
||||
_description = 'IELTS Standards Check'
|
||||
|
||||
content_id = fields.Integer(required=True)
|
||||
content_type = fields.Selection([
|
||||
('passage', 'Passage'),
|
||||
('audio', 'Audio'),
|
||||
('writing_prompt', 'Writing Prompt'),
|
||||
('speaking_card', 'Speaking Card'),
|
||||
], required=True)
|
||||
check_type = fields.Selection([
|
||||
('format_compliance', 'Format Compliance'),
|
||||
('band_calibration', 'Band Calibration'),
|
||||
('answer_key_completeness', 'Answer Key Completeness'),
|
||||
], required=True)
|
||||
passed = fields.Boolean(default=False)
|
||||
error_detail = fields.Text()
|
||||
checked_at = fields.Datetime(default=fields.Datetime.now)
|
||||
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_ielts_standards_check_user,encoach.ielts.standards.check.user,model_encoach_ielts_standards_check,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,2 @@
|
||||
from .quality_checker import QualityChecker
|
||||
from . import content_gate
|
||||
@@ -0,0 +1,36 @@
|
||||
import random
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentSourceGate:
|
||||
"""Determines review requirements based on content source.
|
||||
|
||||
- AI-generated, not IELTS-certified → mandatory full review
|
||||
- IELTS-certified entity content → spot-check (random 10% sampling)
|
||||
- Otherwise → auto-approve
|
||||
"""
|
||||
|
||||
SPOT_CHECK_RATE = 0.10
|
||||
|
||||
@classmethod
|
||||
def check(cls, resource):
|
||||
if resource.ai_generated and not resource.ielts_certified:
|
||||
return 'mandatory_review'
|
||||
if hasattr(resource, 'ielts_certified') and resource.ielts_certified:
|
||||
if random.random() < cls.SPOT_CHECK_RATE:
|
||||
return 'spot_check'
|
||||
return 'auto_approve'
|
||||
return 'auto_approve'
|
||||
|
||||
@classmethod
|
||||
def apply_gate(cls, resource):
|
||||
result = cls.check(resource)
|
||||
if result == 'mandatory_review':
|
||||
resource.write({'review_status': 'pending', 'approved': False})
|
||||
elif result == 'spot_check':
|
||||
resource.write({'review_status': 'pending', 'approved': False})
|
||||
else:
|
||||
resource.write({'review_status': 'approved', 'approved': True})
|
||||
return result
|
||||
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QualityChecker:
|
||||
"""Content quality gate: readability, CEFR alignment, grammar."""
|
||||
|
||||
CEFR_FLESCH_RANGES = {
|
||||
'pre_a1': (90, 100), 'a1': (80, 90), 'a2': (70, 80),
|
||||
'b1': (60, 70), 'b2': (50, 60), 'c1': (30, 50), 'c2': (0, 30),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def check_readability(text):
|
||||
"""Compute Flesch-Kincaid readability metrics."""
|
||||
try:
|
||||
import textstat
|
||||
score = textstat.flesch_reading_ease(text)
|
||||
grade = textstat.flesch_kincaid_grade(text)
|
||||
|
||||
cefr = 'b1'
|
||||
for level, (low, high) in QualityChecker.CEFR_FLESCH_RANGES.items():
|
||||
if low <= score < high:
|
||||
cefr = level
|
||||
break
|
||||
if score < 0:
|
||||
cefr = 'c2'
|
||||
elif score >= 100:
|
||||
cefr = 'pre_a1'
|
||||
|
||||
return {
|
||||
'flesch_score': round(score, 1),
|
||||
'grade_level': round(grade, 1),
|
||||
'cefr_estimate': cefr,
|
||||
'word_count': textstat.lexicon_count(text),
|
||||
'sentence_count': textstat.sentence_count(text),
|
||||
'avg_sentence_length': round(textstat.avg_sentence_length(text), 1),
|
||||
}
|
||||
except ImportError:
|
||||
_logger.warning("textstat not installed, using fallback readability check")
|
||||
return QualityChecker._fallback_readability(text)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_readability(text):
|
||||
words = text.split()
|
||||
sentences = re.split(r'[.!?]+', text)
|
||||
sentences = [s for s in sentences if s.strip()]
|
||||
word_count = len(words)
|
||||
sentence_count = max(len(sentences), 1)
|
||||
syllable_count = sum(QualityChecker._count_syllables(w) for w in words)
|
||||
|
||||
if word_count == 0:
|
||||
return {
|
||||
'flesch_score': 100, 'grade_level': 0, 'cefr_estimate': 'pre_a1',
|
||||
'word_count': 0, 'sentence_count': 0, 'avg_sentence_length': 0,
|
||||
}
|
||||
|
||||
asl = word_count / sentence_count
|
||||
asw = syllable_count / word_count
|
||||
flesch = 206.835 - 1.015 * asl - 84.6 * asw
|
||||
grade = 0.39 * asl + 11.8 * asw - 15.59
|
||||
|
||||
cefr = 'b1'
|
||||
for level, (low, high) in QualityChecker.CEFR_FLESCH_RANGES.items():
|
||||
if low <= flesch < high:
|
||||
cefr = level
|
||||
break
|
||||
|
||||
return {
|
||||
'flesch_score': round(flesch, 1),
|
||||
'grade_level': round(max(grade, 0), 1),
|
||||
'cefr_estimate': cefr,
|
||||
'word_count': word_count,
|
||||
'sentence_count': sentence_count,
|
||||
'avg_sentence_length': round(asl, 1),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _count_syllables(word):
|
||||
word = word.lower().strip('.,!?;:')
|
||||
if not word:
|
||||
return 0
|
||||
count = 0
|
||||
vowels = 'aeiouy'
|
||||
prev_vowel = False
|
||||
for char in word:
|
||||
is_vowel = char in vowels
|
||||
if is_vowel and not prev_vowel:
|
||||
count += 1
|
||||
prev_vowel = is_vowel
|
||||
if word.endswith('e') and count > 1:
|
||||
count -= 1
|
||||
return max(count, 1)
|
||||
|
||||
@staticmethod
|
||||
def check_cefr_alignment(text, target_cefr):
|
||||
"""Check if text readability aligns with target CEFR level."""
|
||||
readability = QualityChecker.check_readability(text)
|
||||
actual_cefr = readability['cefr_estimate']
|
||||
|
||||
cefr_order = ['pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2']
|
||||
actual_idx = cefr_order.index(actual_cefr) if actual_cefr in cefr_order else 3
|
||||
target_idx = cefr_order.index(target_cefr) if target_cefr in cefr_order else 3
|
||||
|
||||
aligned = abs(actual_idx - target_idx) <= 1
|
||||
|
||||
return {
|
||||
'aligned': aligned,
|
||||
'actual_cefr': actual_cefr,
|
||||
'target_cefr': target_cefr,
|
||||
'deviation': actual_idx - target_idx,
|
||||
'readability': readability,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def check_grammar(text):
|
||||
"""Check grammar using language_tool_python."""
|
||||
try:
|
||||
import language_tool_python
|
||||
tool = language_tool_python.LanguageTool('en-US')
|
||||
matches = tool.check(text)
|
||||
errors = []
|
||||
for match in matches:
|
||||
errors.append({
|
||||
'message': match.message,
|
||||
'offset': match.offset,
|
||||
'length': match.errorLength,
|
||||
'rule': match.ruleId,
|
||||
'category': match.category,
|
||||
'replacements': match.replacements[:3] if match.replacements else [],
|
||||
})
|
||||
tool.close()
|
||||
return {
|
||||
'error_count': len(errors),
|
||||
'errors': errors,
|
||||
}
|
||||
except ImportError:
|
||||
_logger.warning("language_tool_python not installed, skipping grammar check")
|
||||
return {'error_count': 0, 'errors': [], 'note': 'Grammar checker not available'}
|
||||
|
||||
@staticmethod
|
||||
def run_all_checks(text, target_cefr='b1'):
|
||||
readability = QualityChecker.check_readability(text)
|
||||
alignment = QualityChecker.check_cefr_alignment(text, target_cefr)
|
||||
grammar = QualityChecker.check_grammar(text)
|
||||
|
||||
passed = alignment['aligned'] and grammar['error_count'] < 5
|
||||
|
||||
return {
|
||||
'passed': passed,
|
||||
'readability': readability,
|
||||
'cefr_alignment': alignment,
|
||||
'grammar': grammar,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_ielts_check_form" model="ir.ui.view">
|
||||
<field name="name">encoach.ielts.standards.check.form</field>
|
||||
<field name="model">encoach.ielts.standards.check</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="IELTS Standards Check">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="content_id"/>
|
||||
<field name="content_type"/>
|
||||
<field name="check_type"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="passed"/>
|
||||
<field name="checked_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="error_detail"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ielts_check_list" model="ir.ui.view">
|
||||
<field name="name">encoach.ielts.standards.check.list</field>
|
||||
<field name="model">encoach.ielts.standards.check</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="IELTS Standards Checks">
|
||||
<field name="content_type"/>
|
||||
<field name="check_type"/>
|
||||
<field name="passed" widget="badge" decoration-success="passed" decoration-danger="not passed"/>
|
||||
<field name="checked_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ielts_check_search" model="ir.ui.view">
|
||||
<field name="name">encoach.ielts.standards.check.search</field>
|
||||
<field name="model">encoach.ielts.standards.check</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search IELTS Checks">
|
||||
<separator/>
|
||||
<filter string="Passed" name="passed" domain="[('passed', '=', True)]"/>
|
||||
<filter string="Failed" name="failed" domain="[('passed', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter string="Passage" name="passage" domain="[('content_type', '=', 'passage')]"/>
|
||||
<filter string="Audio" name="audio" domain="[('content_type', '=', 'audio')]"/>
|
||||
<filter string="Writing Prompt" name="writing_prompt" domain="[('content_type', '=', 'writing_prompt')]"/>
|
||||
<filter string="Speaking Card" name="speaking_card" domain="[('content_type', '=', 'speaking_card')]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ielts_check" model="ir.actions.act_window">
|
||||
<field name="name">IELTS Standards Checks</field>
|
||||
<field name="res_model">encoach.ielts.standards.check</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_ielts_checks"
|
||||
name="IELTS Standards Checks"
|
||||
parent="encoach_core.menu_encoach_content"
|
||||
action="encoach_quality_gate.action_ielts_check"
|
||||
sequence="10"/>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user