feat: initial backend codebase — EnCoach v3

Complete Odoo 19 backend with 25 custom addons:
- encoach_core: user/entity/role management
- encoach_api: REST API + JWT auth
- encoach_ai: OpenAI integration, AI settings, generation
- encoach_ai_course: AI-powered English & IELTS course generation
- encoach_exam_template/session: exam creation, structures, sessions
- encoach_scoring: AI auto-grading + manual approval
- encoach_vector: pgvector RAG integration
- encoach_adaptive: adaptive learning engine
- encoach_placement: placement testing
- encoach_taxonomy/resources: content taxonomy & resource management
- Plus 14 more modules for courses, branding, portal, etc.

Includes docs: user guide, generation report, developer workflow.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-11 15:44:20 +04:00
commit 982d4bca30
371 changed files with 35211 additions and 0 deletions

View File

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

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

View File

@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

View File

@@ -0,0 +1 @@
from .code_scorer import CodeScorer

View 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