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:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
commit 907a5c0e92
331 changed files with 23511 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
from . import models
from . import services

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

View File

@@ -0,0 +1 @@
from . import ielts_standards_check

View File

@@ -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)

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_ielts_standards_check_user encoach.ielts.standards.check.user model_encoach_ielts_standards_check base.group_user 1 1 1 1

View File

@@ -0,0 +1,2 @@
from .quality_checker import QualityChecker
from . import content_gate

View File

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

View File

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

View File

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

View File

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