EnCoach Odoo 19 custom modules
Full backend implementation with custom Odoo modules: - encoach_api: Core API, user management, JWT auth - encoach_exam: Exam generation (reading, writing, listening, speaking) - encoach_evaluate: AI-powered evaluation (writing, speaking) - encoach_training: Training tips and walkthrough - encoach_storage: File storage management - encoach_payment: Stripe, PayPal, Paymob integration - encoach_mail: Email notifications Made-with: Cursor
This commit is contained in:
340
encoach_ai_grading/services/grading_service.py
Normal file
340
encoach_ai_grading/services/grading_service.py
Normal file
@@ -0,0 +1,340 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE
|
||||
from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
WRITING_RESPONSE_TEMPLATE = json.dumps({
|
||||
"comment": "comment about student's response quality",
|
||||
"overall": 0.0,
|
||||
"task_response": {
|
||||
"Task Achievement": {"grade": 0.0, "comment": "..."},
|
||||
"Coherence and Cohesion": {"grade": 0.0, "comment": "..."},
|
||||
"Lexical Resource": {"grade": 0.0, "comment": "..."},
|
||||
"Grammatical Range and Accuracy": {"grade": 0.0, "comment": "..."},
|
||||
},
|
||||
})
|
||||
|
||||
SPEAKING_RESPONSE_TEMPLATE = json.dumps({
|
||||
"comment": "extensive comment about answer quality",
|
||||
"overall": 0.0,
|
||||
"task_response": {
|
||||
"Fluency and Coherence": {"grade": 0.0, "comment": "extensive comment..."},
|
||||
"Lexical Resource": {"grade": 0.0, "comment": "..."},
|
||||
"Grammatical Range and Accuracy": {"grade": 0.0, "comment": "..."},
|
||||
"Pronunciation": {"grade": 0.0, "comment": "..."},
|
||||
},
|
||||
})
|
||||
|
||||
SHORT_ANSWER_TEMPLATE = json.dumps({
|
||||
"exercises": [
|
||||
{"id": "exercise-id", "correct": True, "correct_answer": "the correct answer"},
|
||||
],
|
||||
})
|
||||
|
||||
SUMMARY_TEMPLATE = json.dumps({
|
||||
"sections": [
|
||||
{
|
||||
"code": "section-code",
|
||||
"name": "Section Name",
|
||||
"grade": 0.0,
|
||||
"evaluation": "Detailed evaluation text...",
|
||||
"suggestions": "Improvement suggestions...",
|
||||
"bullet_points": ["point 1", "point 2"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
SPEAKING_TASK_INSTRUCTIONS = {
|
||||
1: (
|
||||
'Address the student as "you". '
|
||||
"If the answers are not 2 or 3 sentences long, "
|
||||
"warn the student that they should be."
|
||||
),
|
||||
2: 'Address the student as "you".',
|
||||
3: (
|
||||
'Address the student as "you" and pay special attention '
|
||||
"to coherence between the answers."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class EncoachGradingService:
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
self.ai = EncoachOpenAIService(env)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writing grading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def grade_writing(self, question, answer, task, attachment=None):
|
||||
"""Grade a writing answer using GPT-4o with IELTS writing rubric.
|
||||
|
||||
Returns dict with comment, overall, task_response, fixed_text,
|
||||
perfect_answer, and ai_detection results.
|
||||
"""
|
||||
system_msg = (
|
||||
"You are a helpful assistant designed to output JSON "
|
||||
f"on this format: {WRITING_RESPONSE_TEMPLATE}"
|
||||
)
|
||||
|
||||
if task == 1:
|
||||
user_prompt = (
|
||||
f"Evaluate the given Writing Task {task} response based on the "
|
||||
"IELTS grading system, ensuring a strict assessment that penalizes "
|
||||
"errors. Deduct points for deviations from the task, and assign a "
|
||||
"score of 0 if the response fails to address the question. "
|
||||
"Additionally, provide a detailed commentary highlighting both "
|
||||
"strengths and weaknesses in the response.\n"
|
||||
'Refer to the parts of the letter as: "Greeting Opener", '
|
||||
'"bullet 1", "bullet 2", "bullet 3", '
|
||||
'"closer (restate the purpose of the letter)", "closing greeting".\n'
|
||||
f'Question: "{question}"\nAnswer: "{answer}"'
|
||||
)
|
||||
else:
|
||||
user_prompt = (
|
||||
f"Evaluate the given Writing Task {task} response based on the "
|
||||
"IELTS grading system, ensuring a strict assessment that penalizes "
|
||||
"errors. Deduct points for deviations from the task, and assign a "
|
||||
"score of 0 if the response fails to address the question. "
|
||||
"Additionally, provide a detailed commentary highlighting both "
|
||||
"strengths and weaknesses in the response.\n"
|
||||
f'Question: "{question}"\nAnswer: "{answer}"'
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_msg},
|
||||
]
|
||||
|
||||
if attachment and task == 1:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": user_prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": attachment},
|
||||
},
|
||||
],
|
||||
})
|
||||
else:
|
||||
messages.append({"role": "user", "content": user_prompt})
|
||||
|
||||
result = self.ai.prediction(
|
||||
model=GPT_MODELS["grading"],
|
||||
messages=messages,
|
||||
temperature=TEMPERATURE["grading"],
|
||||
fields_to_check=["comment"],
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
result["fixed_text"] = self._get_fixed_text(answer)
|
||||
result["perfect_answer"] = self._get_perfect_answer(question)
|
||||
result["ai_detection"] = self._detect_ai(answer)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Speaking grading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def grade_speaking(self, task, qa_pairs):
|
||||
"""Grade speaking using GPT-4o with IELTS speaking rubric.
|
||||
|
||||
qa_pairs: list of dicts with 'question' and 'answer' keys.
|
||||
"""
|
||||
system_msg = (
|
||||
"You are a helpful assistant designed to output JSON "
|
||||
f"on this format: {SPEAKING_RESPONSE_TEMPLATE}"
|
||||
)
|
||||
|
||||
qa_text = "\n".join(
|
||||
f'Question: "{p["question"]}"\nAnswer: "{p["answer"]}"'
|
||||
for p in qa_pairs
|
||||
)
|
||||
task_instruction = SPEAKING_TASK_INSTRUCTIONS.get(task, "")
|
||||
|
||||
user_prompt = (
|
||||
f"Evaluate the given Speaking Part {task} response based on the "
|
||||
"IELTS grading system, ensuring a strict assessment that penalizes "
|
||||
"errors. Deduct points for deviations from the task, and assign a "
|
||||
"score of 0 if the response fails to address the question. "
|
||||
"Additionally, provide detailed commentary highlighting both "
|
||||
f"strengths and weaknesses in the response. {task_instruction}\n"
|
||||
f"{qa_text}"
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_msg},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
result = self.ai.prediction(
|
||||
model=GPT_MODELS["grading"],
|
||||
messages=messages,
|
||||
temperature=TEMPERATURE["grading"],
|
||||
fields_to_check=["comment"],
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
if task == 2 and qa_pairs:
|
||||
combined_answer = " ".join(p["answer"] for p in qa_pairs)
|
||||
result["fixed_text"] = self._get_fixed_text(combined_answer)
|
||||
result["perfect_answer"] = self._get_perfect_answer(
|
||||
qa_pairs[0]["question"]
|
||||
)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Short-answer grading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def grade_short_answers(self, text, questions, answers):
|
||||
"""Evaluate short answers against a reading/listening passage."""
|
||||
system_msg = (
|
||||
"You are a helpful assistant designed to output JSON "
|
||||
f"on this format: {SHORT_ANSWER_TEMPLATE}"
|
||||
)
|
||||
|
||||
qa_text = "\n".join(
|
||||
f'Q{i + 1}: "{q}" — Student answer: "{a}"'
|
||||
for i, (q, a) in enumerate(zip(questions, answers))
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
"Evaluate each student answer against the passage. For each answer, "
|
||||
"determine if it is correct and provide the correct answer.\n\n"
|
||||
f'Passage: "{text}"\n\n{qa_text}'
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_msg},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
return self.ai.prediction(
|
||||
model=GPT_MODELS["grading"],
|
||||
messages=messages,
|
||||
temperature=TEMPERATURE["grading"],
|
||||
check_blacklisted=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Grading summary
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def generate_grading_summary(self, sections):
|
||||
"""Generate a summary for an entire exam session using GPT-3.5-turbo."""
|
||||
system_msg = (
|
||||
"You are a helpful assistant designed to output JSON "
|
||||
f"on this format: {SUMMARY_TEMPLATE}"
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
"Generate a detailed evaluation summary for the following IELTS exam "
|
||||
"sections. For each section, provide an evaluation, suggestions for "
|
||||
"improvement, and key bullet points.\n\n"
|
||||
+ json.dumps(sections)
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_msg},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
return self.ai.prediction(
|
||||
model=GPT_MODELS["secondary"],
|
||||
messages=messages,
|
||||
temperature=TEMPERATURE["grading"],
|
||||
check_blacklisted=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AI detection (GPTZero)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_ai(self, text):
|
||||
"""Call GPTZero API to detect AI-generated text."""
|
||||
api_key = (
|
||||
self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param("encoach.gptzero_api_key", "")
|
||||
)
|
||||
if not api_key:
|
||||
_logger.warning("GPTZero API key not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
"https://api.gptzero.me/v2/predict/text",
|
||||
headers={
|
||||
"x-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"document": text,
|
||||
"version": "",
|
||||
"multilingual": False,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception:
|
||||
_logger.exception("GPTZero API call failed")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper prompts
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_perfect_answer(self, question):
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an IELTS writing expert."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Write a perfect answer for this IELTS writing task: "
|
||||
f'"{question}"'
|
||||
),
|
||||
},
|
||||
]
|
||||
result = self.ai.prediction(
|
||||
model=GPT_MODELS["secondary"],
|
||||
messages=messages,
|
||||
temperature=TEMPERATURE["grading"],
|
||||
response_format={"type": "json_object"},
|
||||
check_blacklisted=False,
|
||||
)
|
||||
if result and "answer" in result:
|
||||
return result["answer"]
|
||||
return result
|
||||
|
||||
def _get_fixed_text(self, text):
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a grammar correction assistant. Output JSON."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Fix the grammatical and spelling errors in this text, "
|
||||
f'keeping the original meaning: "{text}"'
|
||||
),
|
||||
},
|
||||
]
|
||||
result = self.ai.prediction(
|
||||
model=GPT_MODELS["secondary"],
|
||||
messages=messages,
|
||||
temperature=TEMPERATURE["grading"],
|
||||
response_format={"type": "json_object"},
|
||||
check_blacklisted=False,
|
||||
)
|
||||
if result and "text" in result:
|
||||
return result["text"]
|
||||
return result
|
||||
Reference in New Issue
Block a user