feat: EnCoach V2 — complete OWL refactor with 15 new modules
Full architectural refactor from React to Odoo OWL: - 15 new Odoo modules: signup, placement, exam_template, scoring, course_gen, entity_onboard, ai_course, quality_gate, ielts_validation, pdf_report, verification, math, it, portal, exam_session - 6 modified modules: core (new user fields), exam (template types), adaptive (events/paths/settings), branding (white-label), resources (CEFR/AI fields), taxonomy (Math+IT hierarchies) - ~79 new REST API endpoints across all controller packages - ~50 admin backend views (form/list/kanban/search/menu) - 5 custom OWL components: dashboard, content pool, exam validation, gap analysis, adaptive timeline - POS-inspired full-screen exam session with 9 question renderers - Student portal with 12 website pages (QWeb + OWL) - AI/ML services: IRT 3PL CAT engine, CEFR mapper, quality gates, IELTS validator, SymPy math scorer, speaking evaluator, adaptive engine - Seed data: IELTS/TOEFL/STEP/IC3 templates, 30+ sample questions, English/Math/IT taxonomy trees with 50+ topics - Updated requirements.txt with new dependencies Made-with: Cursor
This commit is contained in:
1
new_project/custom_addons/encoach_it/__init__.py
Normal file
1
new_project/custom_addons/encoach_it/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import services
|
||||
15
new_project/custom_addons/encoach_it/__manifest__.py
Normal file
15
new_project/custom_addons/encoach_it/__manifest__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
'name': 'EnCoach IT',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'IT question types: code completion, output prediction, SQL query scoring',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_exam_template'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
@@ -0,0 +1 @@
|
||||
from .code_scorer import CodeScorer
|
||||
255
new_project/custom_addons/encoach_it/services/code_scorer.py
Normal file
255
new_project/custom_addons/encoach_it/services/code_scorer.py
Normal file
@@ -0,0 +1,255 @@
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
LANGUAGE_EXTENSIONS = {
|
||||
'python': '.py',
|
||||
'javascript': '.js',
|
||||
'java': '.java',
|
||||
'c': '.c',
|
||||
'cpp': '.cpp',
|
||||
}
|
||||
|
||||
LANGUAGE_RUNNERS = {
|
||||
'python': ['python3', '{file}'],
|
||||
'javascript': ['node', '{file}'],
|
||||
}
|
||||
|
||||
EXECUTION_TIMEOUT = 10
|
||||
|
||||
|
||||
class CodeScorer:
|
||||
|
||||
@staticmethod
|
||||
def score_code_output(student_answer, expected_output):
|
||||
"""Compare student output against expected output (case-insensitive, stripped)."""
|
||||
norm_student = CodeScorer._normalize_output(str(student_answer))
|
||||
norm_expected = CodeScorer._normalize_output(str(expected_output))
|
||||
|
||||
if norm_student.lower() == norm_expected.lower():
|
||||
return {
|
||||
'correct': True,
|
||||
'score': 1.0,
|
||||
'feedback': 'Correct output.',
|
||||
}
|
||||
|
||||
student_lines = norm_student.splitlines()
|
||||
expected_lines = norm_expected.splitlines()
|
||||
if len(student_lines) == len(expected_lines):
|
||||
matching = sum(
|
||||
1 for s, e in zip(student_lines, expected_lines)
|
||||
if s.strip().lower() == e.strip().lower()
|
||||
)
|
||||
partial = matching / len(expected_lines)
|
||||
if partial > 0:
|
||||
return {
|
||||
'correct': False,
|
||||
'score': round(partial, 2),
|
||||
'feedback': f'{matching}/{len(expected_lines)} lines match.',
|
||||
}
|
||||
|
||||
return {
|
||||
'correct': False,
|
||||
'score': 0.0,
|
||||
'feedback': 'Output does not match the expected result.',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def score_code_completion(student_code, test_cases, language='python'):
|
||||
"""Run student code against test cases in a sandboxed subprocess.
|
||||
|
||||
Each test case: {'input': str, 'expected_output': str}
|
||||
"""
|
||||
if language not in LANGUAGE_RUNNERS:
|
||||
return {
|
||||
'passed': 0, 'total': len(test_cases), 'score': 0.0,
|
||||
'details': [{'test_case': i + 1, 'passed': False,
|
||||
'note': f'Language {language} execution not supported'}
|
||||
for i in range(len(test_cases))],
|
||||
}
|
||||
|
||||
total = len(test_cases)
|
||||
passed = 0
|
||||
details = []
|
||||
|
||||
ext = LANGUAGE_EXTENSIONS.get(language, '.txt')
|
||||
|
||||
for i, tc in enumerate(test_cases):
|
||||
tc_input = tc.get('input', '')
|
||||
tc_expected = tc.get('expected_output', '')
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w', suffix=ext, delete=False, prefix='encoach_'
|
||||
) as f:
|
||||
f.write(student_code)
|
||||
tmp_path = f.name
|
||||
|
||||
cmd = [part.replace('{file}', tmp_path) for part in LANGUAGE_RUNNERS[language]]
|
||||
result = subprocess.run(
|
||||
cmd, input=tc_input, capture_output=True, text=True,
|
||||
timeout=EXECUTION_TIMEOUT,
|
||||
)
|
||||
|
||||
actual_output = result.stdout.strip()
|
||||
stderr = result.stderr.strip()
|
||||
|
||||
if result.returncode != 0:
|
||||
details.append({
|
||||
'test_case': i + 1,
|
||||
'input': tc_input,
|
||||
'expected_output': tc_expected,
|
||||
'actual_output': actual_output,
|
||||
'passed': False,
|
||||
'error': stderr[:500] if stderr else f'Exit code {result.returncode}',
|
||||
})
|
||||
continue
|
||||
|
||||
match = CodeScorer._normalize_output(actual_output).lower() == \
|
||||
CodeScorer._normalize_output(tc_expected).lower()
|
||||
|
||||
if match:
|
||||
passed += 1
|
||||
|
||||
details.append({
|
||||
'test_case': i + 1,
|
||||
'input': tc_input,
|
||||
'expected_output': tc_expected,
|
||||
'actual_output': actual_output,
|
||||
'passed': match,
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
details.append({
|
||||
'test_case': i + 1,
|
||||
'input': tc_input,
|
||||
'expected_output': tc_expected,
|
||||
'actual_output': None,
|
||||
'passed': False,
|
||||
'error': f'Execution timed out after {EXECUTION_TIMEOUT}s',
|
||||
})
|
||||
except Exception as e:
|
||||
details.append({
|
||||
'test_case': i + 1,
|
||||
'input': tc_input,
|
||||
'expected_output': tc_expected,
|
||||
'actual_output': None,
|
||||
'passed': False,
|
||||
'error': str(e),
|
||||
})
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'passed': passed,
|
||||
'total': total,
|
||||
'score': round(passed / total, 2) if total > 0 else 0.0,
|
||||
'details': details,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def score_sql_query(student_sql, expected_result, db_config=None):
|
||||
"""Score a SQL query submission.
|
||||
|
||||
With db_config: executes against a real database and compares result sets.
|
||||
Without db_config: falls back to normalized syntax comparison.
|
||||
"""
|
||||
if db_config is not None:
|
||||
return CodeScorer._execute_sql(student_sql, expected_result, db_config)
|
||||
|
||||
norm_student = CodeScorer._normalize_sql(str(student_sql))
|
||||
norm_expected = CodeScorer._normalize_sql(str(expected_result))
|
||||
|
||||
if norm_student == norm_expected:
|
||||
return {
|
||||
'correct': True,
|
||||
'score': 1.0,
|
||||
'feedback': 'SQL syntax matches expected query.',
|
||||
}
|
||||
|
||||
student_parts = CodeScorer._extract_sql_clauses(norm_student)
|
||||
expected_parts = CodeScorer._extract_sql_clauses(norm_expected)
|
||||
matching_clauses = sum(1 for k in expected_parts if student_parts.get(k) == expected_parts[k])
|
||||
total_clauses = len(expected_parts)
|
||||
|
||||
if total_clauses > 0 and matching_clauses > 0:
|
||||
partial = matching_clauses / total_clauses
|
||||
return {
|
||||
'correct': False,
|
||||
'score': round(partial, 2),
|
||||
'feedback': f'{matching_clauses}/{total_clauses} SQL clauses match.',
|
||||
}
|
||||
|
||||
return {
|
||||
'correct': False,
|
||||
'score': 0.0,
|
||||
'feedback': 'SQL query does not match the expected structure.',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _execute_sql(student_sql, expected_result, db_config):
|
||||
"""Execute SQL against a database and compare results."""
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(**db_config)
|
||||
conn.set_session(readonly=True, autocommit=True)
|
||||
cur = conn.cursor()
|
||||
cur.execute(student_sql)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if isinstance(expected_result, list):
|
||||
expected_set = set(tuple(r) for r in expected_result)
|
||||
actual_set = set(rows)
|
||||
correct = expected_set == actual_set
|
||||
return {
|
||||
'correct': correct,
|
||||
'score': 1.0 if correct else 0.0,
|
||||
'feedback': 'Correct!' if correct else 'Result set does not match.',
|
||||
'row_count': len(rows),
|
||||
}
|
||||
|
||||
return {'correct': False, 'score': 0.0, 'feedback': 'Cannot compare results.'}
|
||||
except ImportError:
|
||||
return {'correct': False, 'score': 0.0, 'feedback': 'psycopg2 not available for SQL execution.'}
|
||||
except Exception as e:
|
||||
return {'correct': False, 'score': 0.0, 'feedback': f'SQL execution error: {e}'}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_output(text):
|
||||
"""Strip whitespace and normalize line endings."""
|
||||
text = text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_sql(sql):
|
||||
"""Normalize SQL for structural comparison: lowercase, collapse whitespace."""
|
||||
sql = sql.strip().lower()
|
||||
sql = re.sub(r'\s+', ' ', sql)
|
||||
sql = re.sub(r'\s*;\s*$', '', sql)
|
||||
return sql
|
||||
|
||||
@staticmethod
|
||||
def _extract_sql_clauses(sql):
|
||||
"""Extract major SQL clauses for partial matching."""
|
||||
clauses = {}
|
||||
keywords = ['select', 'from', 'where', 'group by', 'having', 'order by', 'limit', 'join']
|
||||
sql_lower = sql.lower()
|
||||
positions = []
|
||||
for kw in keywords:
|
||||
idx = sql_lower.find(kw)
|
||||
if idx >= 0:
|
||||
positions.append((idx, kw))
|
||||
positions.sort()
|
||||
for i, (pos, kw) in enumerate(positions):
|
||||
end = positions[i + 1][0] if i + 1 < len(positions) else len(sql)
|
||||
clauses[kw] = sql[pos:end].strip()
|
||||
return clauses
|
||||
Reference in New Issue
Block a user