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:
1
custom_addons/encoach_it/__init__.py
Normal file
1
custom_addons/encoach_it/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import services
|
||||
15
custom_addons/encoach_it/__manifest__.py
Normal file
15
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,
|
||||
}
|
||||
1
custom_addons/encoach_it/security/ir.model.access.csv
Normal file
1
custom_addons/encoach_it/security/ir.model.access.csv
Normal file
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
1
custom_addons/encoach_it/services/__init__.py
Normal file
1
custom_addons/encoach_it/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .code_scorer import CodeScorer
|
||||
255
custom_addons/encoach_it/services/code_scorer.py
Normal file
255
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