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
custom_addons/encoach_quality_gate/services/__init__.py
Normal file
2
custom_addons/encoach_quality_gate/services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .quality_checker import QualityChecker
|
||||
from . import content_gate
|
||||
36
custom_addons/encoach_quality_gate/services/content_gate.py
Normal file
36
custom_addons/encoach_quality_gate/services/content_gate.py
Normal 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
|
||||
156
custom_addons/encoach_quality_gate/services/quality_checker.py
Normal file
156
custom_addons/encoach_quality_gate/services/quality_checker.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user