feat: complete exam lifecycle — AI generation, submission, student session, and results
- Backend: AI generation fallbacks when OpenAI not configured, full exam submission saving all params (difficulty, rubric, entity, grading system, approval workflow) and creating linked question records per section - Backend: new exam session controller with get_session, autosave, submit, status, and results endpoints; student attempt/answer/score models - Backend: new controllers for entities, approval workflows, exam schedules - Frontend: exam session split-layout with passage panel, question types (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog - Frontend: results page with percentage score, per-answer breakdown table - Frontend: generation page dynamic dropdowns, full payload submission - Frontend: updated types for ExamSessionSection, ExamQuestion options Made-with: Cursor
This commit is contained in:
@@ -314,38 +314,194 @@ class AIController(http.Controller):
|
||||
_logger.exception("workbench publish failed")
|
||||
return _json_response({"status": "error", "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ──
|
||||
@http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def ai_suggest_rubric_criteria(self, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _json_response({"error": "Authentication required"}, 401)
|
||||
request.update_env(user=user.id)
|
||||
|
||||
body = _get_json()
|
||||
name = body.get("name", "")
|
||||
skill = body.get("skill", "writing")
|
||||
exam_type = body.get("exam_type", "academic")
|
||||
levels = body.get("levels", ["A1", "A2", "B1", "B2", "C1", "C2"])
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
if not ai.client:
|
||||
raise RuntimeError("OpenAI not configured")
|
||||
|
||||
band_keys = ", ".join(f'"{lv}"' for lv in levels)
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"You are an expert in English language assessment rubric design. "
|
||||
"Generate scoring criteria for a rubric. Return 3-6 criteria.\n\n"
|
||||
"Each criterion must have:\n"
|
||||
"- name: short name (e.g. 'Task Achievement')\n"
|
||||
"- weight: percentage weight (all weights must sum to 100)\n"
|
||||
"- descriptors: an object mapping ONLY these band levels to a 1-sentence description of expected performance at that level\n\n"
|
||||
f"The ONLY allowed band level keys are: {band_keys}\n\n"
|
||||
"Return ONLY this JSON structure:\n"
|
||||
'{"criteria": [{"name": "string", "weight": number, '
|
||||
'"descriptors": {"LEVEL": "one sentence description", ...}}]}'
|
||||
)},
|
||||
{"role": "user", "content": json.dumps({
|
||||
"rubric_name": name,
|
||||
"skill": skill,
|
||||
"exam_type": exam_type,
|
||||
"target_levels": levels,
|
||||
})},
|
||||
]
|
||||
result = ai.chat_json(messages, action="suggest_rubric_criteria")
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
_logger.warning("AI unavailable (%s), using template criteria for %s/%s", e, skill, exam_type)
|
||||
return _json_response({"criteria": self._fallback_criteria(skill, levels)})
|
||||
|
||||
@staticmethod
|
||||
def _fallback_criteria(skill, levels):
|
||||
"""Return pre-built criteria templates when OpenAI is unavailable."""
|
||||
def _desc(level_map, levels):
|
||||
return {lv: level_map.get(lv, "") for lv in levels}
|
||||
|
||||
templates = {
|
||||
"writing": [
|
||||
{"name": "Task Achievement", "weight": 25, "descriptors_map": {
|
||||
"C2": "Fully addresses all parts with a well-developed position",
|
||||
"C1": "Addresses all parts with a clear position throughout",
|
||||
"B2": "Addresses all parts, though some more fully than others",
|
||||
"B1": "Addresses the task only partially with limited development",
|
||||
"A2": "Barely responds to the task with very limited ideas",
|
||||
"A1": "Does not adequately address the task requirements",
|
||||
}},
|
||||
{"name": "Coherence & Cohesion", "weight": 25, "descriptors_map": {
|
||||
"C2": "Skillfully manages paragraphing with seamless cohesion",
|
||||
"C1": "Logically organizes information with clear progression",
|
||||
"B2": "Arranges information coherently with some cohesive devices",
|
||||
"B1": "Presents information with some organization but may lack clarity",
|
||||
"A2": "Limited ability to organize ideas; unclear progression",
|
||||
"A1": "No apparent logical organization of ideas",
|
||||
}},
|
||||
{"name": "Lexical Resource", "weight": 25, "descriptors_map": {
|
||||
"C2": "Uses a wide range of vocabulary with very natural and sophisticated control",
|
||||
"C1": "Uses a sufficient range of vocabulary to allow flexibility and precision",
|
||||
"B2": "Uses an adequate range of vocabulary for the task with some errors",
|
||||
"B1": "Uses a limited range of vocabulary with noticeable errors",
|
||||
"A2": "Uses only basic vocabulary with frequent errors in word choice",
|
||||
"A1": "Extremely limited vocabulary; barely able to convey meaning",
|
||||
}},
|
||||
{"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": {
|
||||
"C2": "Wide range of structures with full flexibility and accuracy",
|
||||
"C1": "Uses a variety of complex structures with good control",
|
||||
"B2": "Uses a mix of simple and complex sentences with some errors",
|
||||
"B1": "Attempts complex sentences but errors are frequent",
|
||||
"A2": "Uses only simple sentences with many errors",
|
||||
"A1": "Cannot use sentence forms except in memorized phrases",
|
||||
}},
|
||||
],
|
||||
"speaking": [
|
||||
{"name": "Fluency & Coherence", "weight": 25, "descriptors_map": {
|
||||
"C2": "Speaks effortlessly with natural flow and fully coherent speech",
|
||||
"C1": "Speaks at length without noticeable effort or loss of coherence",
|
||||
"B2": "Speaks with some hesitation but maintains coherent speech",
|
||||
"B1": "Can keep going but pauses frequently to plan and correct",
|
||||
"A2": "Produces simple utterances with long pauses",
|
||||
"A1": "Speech is extremely slow with very long pauses",
|
||||
}},
|
||||
{"name": "Lexical Resource", "weight": 25, "descriptors_map": {
|
||||
"C2": "Uses vocabulary with full flexibility and precision in all topics",
|
||||
"C1": "Uses vocabulary flexibly to discuss a variety of topics",
|
||||
"B2": "Has a wide enough vocabulary to discuss topics at length",
|
||||
"B1": "Uses sufficient vocabulary for familiar topics",
|
||||
"A2": "Uses basic vocabulary for personal information and routine situations",
|
||||
"A1": "Can only produce isolated words and memorized phrases",
|
||||
}},
|
||||
{"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": {
|
||||
"C2": "Maintains consistent use of a wide range of accurate structures",
|
||||
"C1": "Uses a wide range of structures with a majority of error-free sentences",
|
||||
"B2": "Uses a range of structures with reasonable accuracy",
|
||||
"B1": "Produces basic sentence forms with reasonable accuracy",
|
||||
"A2": "Produces basic sentences with frequent errors",
|
||||
"A1": "Cannot produce basic sentence forms",
|
||||
}},
|
||||
{"name": "Pronunciation", "weight": 25, "descriptors_map": {
|
||||
"C2": "Is effortless to understand with natural pronunciation features",
|
||||
"C1": "Uses a wide range of pronunciation features with fine control",
|
||||
"B2": "Is generally easy to understand with some L1 influence",
|
||||
"B1": "Shows some effective use of features but may be inconsistent",
|
||||
"A2": "Pronunciation is generally understood but often faulty",
|
||||
"A1": "Speech is often unintelligible due to pronunciation errors",
|
||||
}},
|
||||
],
|
||||
}
|
||||
|
||||
base = templates.get(skill, templates["writing"])
|
||||
return [
|
||||
{
|
||||
"name": c["name"],
|
||||
"weight": c["weight"],
|
||||
"descriptors": _desc(c["descriptors_map"], levels),
|
||||
}
|
||||
for c in base
|
||||
]
|
||||
|
||||
# ── Exam generation — GenerationPage.tsx ──
|
||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def exam_generate(self, module, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _json_response({"error": "Authentication required"}, 401)
|
||||
request.update_env(user=user.id)
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
has_ai = bool(ai.client)
|
||||
except Exception:
|
||||
ai, has_ai = None, False
|
||||
|
||||
try:
|
||||
if body.get("generate_passage"):
|
||||
return self._generate_passage(ai, body)
|
||||
if has_ai:
|
||||
return self._generate_passage(ai, body)
|
||||
return _json_response(self._fallback_passage(body))
|
||||
if body.get("generate_instructions"):
|
||||
return self._generate_writing_instructions(ai, body)
|
||||
if has_ai:
|
||||
return self._generate_writing_instructions(ai, body)
|
||||
return _json_response(self._fallback_writing_instructions(body))
|
||||
if body.get("generate_script"):
|
||||
return self._generate_speaking_script(ai, body)
|
||||
if has_ai:
|
||||
return self._generate_speaking_script(ai, body)
|
||||
return _json_response(self._fallback_speaking_script(body))
|
||||
if body.get("generate_context"):
|
||||
return self._generate_listening_context(ai, body)
|
||||
if has_ai:
|
||||
return self._generate_listening_context(ai, body)
|
||||
return _json_response(self._fallback_listening_context(body))
|
||||
if body.get("generate_exercises"):
|
||||
return self._generate_exercises(ai, module, body)
|
||||
if has_ai:
|
||||
return self._generate_exercises(ai, module, body)
|
||||
return _json_response(self._fallback_exercises(module, body))
|
||||
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
topic = body.get("topic", "")
|
||||
count = body.get("count") or body.get("question_count") or 5
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
|
||||
f"Return JSON: "
|
||||
'{"questions": [{"type": string, "prompt": string, "options": [string], '
|
||||
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
|
||||
)},
|
||||
{"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
|
||||
if has_ai:
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
|
||||
f"Return JSON: "
|
||||
'{"questions": [{"type": string, "prompt": string, "options": [string], '
|
||||
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
|
||||
)},
|
||||
{"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
|
||||
return _json_response(self._fallback_questions(module, body))
|
||||
except Exception as e:
|
||||
_logger.exception("exam_generate %s failed: %s", module, e)
|
||||
return _json_response({"questions": [], "error": str(e)}, 500)
|
||||
@@ -449,9 +605,314 @@ class AIController(http.Controller):
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}"))
|
||||
|
||||
# ── Fallback generators (no OpenAI needed) ──
|
||||
|
||||
@staticmethod
|
||||
def _fallback_passage(body):
|
||||
topic = body.get("topic", "travel")
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
templates = {
|
||||
"travel": {
|
||||
"title": "The Rise of Sustainable Tourism",
|
||||
"passage": (
|
||||
"In recent years, sustainable tourism has emerged as a powerful movement reshaping how "
|
||||
"people explore the world. Unlike traditional mass tourism, which often prioritises "
|
||||
"convenience and cost over environmental impact, sustainable tourism encourages travellers "
|
||||
"to consider their ecological footprint and cultural sensitivity.\n\n"
|
||||
"The concept gained significant traction after international organisations highlighted the "
|
||||
"devastating effects of unchecked tourism on fragile ecosystems. Coral reefs in Southeast "
|
||||
"Asia, ancient ruins in South America, and wildlife reserves in Africa have all suffered "
|
||||
"from overcrowding, pollution, and habitat destruction caused by the influx of visitors.\n\n"
|
||||
"Governments and local communities have responded by implementing measures such as visitor "
|
||||
"caps, eco-certification programmes, and community-based tourism initiatives. In Bhutan, "
|
||||
"for example, the government charges a daily sustainable development fee to limit tourist "
|
||||
"numbers while funding conservation efforts and education.\n\n"
|
||||
"Tour operators have also adapted their business models. Many now offer carbon-offset "
|
||||
"programmes, partner with local artisans and guides, and design itineraries that minimise "
|
||||
"environmental disruption. Accommodation providers have invested in solar energy, rainwater "
|
||||
"harvesting, and waste-reduction systems.\n\n"
|
||||
"Despite these positive developments, challenges remain. Critics argue that sustainable "
|
||||
"tourism can be exclusionary, pricing out budget travellers and local residents. Others "
|
||||
"point out that certification schemes vary widely in rigour and transparency. Nevertheless, "
|
||||
"the growing awareness among travellers suggests that the industry is moving in the right "
|
||||
"direction, balancing economic growth with environmental stewardship."
|
||||
),
|
||||
},
|
||||
"technology": {
|
||||
"title": "Artificial Intelligence in Everyday Life",
|
||||
"passage": (
|
||||
"Artificial intelligence, once confined to research laboratories and science fiction, has "
|
||||
"become an integral part of daily life. From voice-activated assistants on smartphones to "
|
||||
"recommendation algorithms on streaming platforms, AI systems now influence many of the "
|
||||
"choices people make without them even realising it.\n\n"
|
||||
"One of the most visible applications of AI is in healthcare. Machine learning algorithms "
|
||||
"can now analyse medical images with remarkable accuracy, sometimes identifying conditions "
|
||||
"such as early-stage cancers that human radiologists might miss. Hospitals around the world "
|
||||
"are adopting AI-powered tools for patient triage, drug discovery, and personalised "
|
||||
"treatment planning.\n\n"
|
||||
"In education, AI-driven platforms adapt learning content to individual student needs. These "
|
||||
"systems monitor a learner's progress and adjust the difficulty and style of materials "
|
||||
"accordingly, providing a customised experience that traditional classroom settings often "
|
||||
"cannot match.\n\n"
|
||||
"However, the rapid adoption of AI has raised important ethical questions. Issues of data "
|
||||
"privacy, algorithmic bias, and job displacement have sparked intense debate among "
|
||||
"policymakers, technologists, and the general public. There are growing calls for "
|
||||
"regulations that ensure AI systems are transparent, fair, and accountable.\n\n"
|
||||
"As AI continues to evolve, its impact on society will only deepen. The challenge lies in "
|
||||
"harnessing its potential for good while mitigating the risks it poses to privacy, "
|
||||
"employment, and social equity."
|
||||
),
|
||||
},
|
||||
}
|
||||
t = topic.lower().strip()
|
||||
if t in templates:
|
||||
return templates[t]
|
||||
default = templates["travel"]
|
||||
default["title"] = f"{topic.title()} — A {difficulty} Level Reading Passage"
|
||||
default["passage"] = default["passage"].replace("sustainable tourism", topic.lower())
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _fallback_writing_instructions(body):
|
||||
topic = body.get("topic", "general")
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
task_type = body.get("task_type", "essay")
|
||||
templates = {
|
||||
"essay": (
|
||||
f"Write an essay of at least 250 words on the following topic:\n\n"
|
||||
f"\"{topic.title()}\"\n\n"
|
||||
"You should:\n"
|
||||
"• present a clear position on the issue\n"
|
||||
"• support your arguments with relevant examples\n"
|
||||
"• organise your ideas logically with clear paragraphing\n"
|
||||
"• use a range of vocabulary and grammatical structures\n\n"
|
||||
"Write at least 250 words."
|
||||
),
|
||||
"report": (
|
||||
f"The chart/graph below shows information about {topic.lower()}.\n\n"
|
||||
"Summarise the information by selecting and reporting the main features, "
|
||||
"and make comparisons where relevant.\n\n"
|
||||
"Write at least 150 words."
|
||||
),
|
||||
"letter": (
|
||||
f"You recently had an experience related to {topic.lower()}. "
|
||||
"Write a letter to a friend describing what happened.\n\n"
|
||||
"In your letter:\n"
|
||||
"• explain the situation\n"
|
||||
"• describe how you felt\n"
|
||||
"• suggest what your friend should do in a similar situation\n\n"
|
||||
"Write at least 150 words. You do NOT need to write any addresses."
|
||||
),
|
||||
}
|
||||
return {"instructions": templates.get(task_type, templates["essay"])}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_speaking_script(body):
|
||||
part = body.get("part", "speaking_1")
|
||||
topics = body.get("topics", [])
|
||||
topic_str = ", ".join(t for t in topics if t) or "general conversation"
|
||||
|
||||
scripts = {
|
||||
"speaking_1": (
|
||||
f"Part 1 — Introduction and Interview\n\n"
|
||||
f"Examiner: Good morning/afternoon. My name is [Examiner]. "
|
||||
f"Can you tell me your full name, please?\n\n"
|
||||
f"Now I'd like to ask you some questions about {topic_str}.\n\n"
|
||||
f"1. Can you tell me about your experience with {topic_str}?\n"
|
||||
f"2. How important is {topic_str} in your daily life?\n"
|
||||
f"3. Has your interest in {topic_str} changed over the years?\n"
|
||||
f"4. What do most people in your country think about {topic_str}?\n"
|
||||
),
|
||||
"speaking_2": (
|
||||
f"Part 2 — Individual Long Turn\n\n"
|
||||
f"Examiner: Now I'm going to give you a topic, and I'd like you to talk "
|
||||
f"about it for one to two minutes. You have one minute to prepare.\n\n"
|
||||
f"Describe a time when you experienced something related to {topic_str}.\n\n"
|
||||
f"You should say:\n"
|
||||
f"• what happened\n"
|
||||
f"• when and where it happened\n"
|
||||
f"• who was involved\n"
|
||||
f"and explain how you felt about it.\n"
|
||||
),
|
||||
"speaking_3": (
|
||||
f"Part 3 — Two-way Discussion\n\n"
|
||||
f"Examiner: We've been talking about {topic_str}, and now I'd like to "
|
||||
f"discuss some broader questions related to this topic.\n\n"
|
||||
f"1. How has {topic_str} changed in your country in recent years?\n"
|
||||
f"2. Do you think {topic_str} will be more or less important in the future? Why?\n"
|
||||
f"3. What are the advantages and disadvantages of {topic_str}?\n"
|
||||
f"4. How might governments address challenges related to {topic_str}?\n"
|
||||
),
|
||||
}
|
||||
return {"script": scripts.get(part, scripts["speaking_1"])}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_listening_context(body):
|
||||
topic = body.get("topic", "everyday life")
|
||||
section_type = body.get("section_type", "social_conversation")
|
||||
|
||||
transcripts = {
|
||||
"social_conversation": (
|
||||
f"[A conversation between two friends about {topic}]\n\n"
|
||||
"Speaker A: Hi! I haven't seen you in ages. How have you been?\n\n"
|
||||
"Speaker B: I've been great, thanks. Actually, I've been quite busy lately "
|
||||
f"because I've been working on something related to {topic}.\n\n"
|
||||
"Speaker A: Oh really? That sounds interesting. Tell me more about it.\n\n"
|
||||
f"Speaker B: Well, it started about three months ago when I decided to "
|
||||
f"explore {topic} more seriously. I joined a local group and we meet every "
|
||||
f"Tuesday evening to discuss different aspects of it.\n\n"
|
||||
"Speaker A: That sounds fantastic. Have you learned a lot?\n\n"
|
||||
"Speaker B: Absolutely. I've discovered that there's much more to it than "
|
||||
"I originally thought. For instance, did you know that most experts "
|
||||
"recommend starting with the basics before moving to advanced topics?\n\n"
|
||||
"Speaker A: I didn't know that. Maybe I should join your group too.\n\n"
|
||||
"Speaker B: You'd be very welcome! The next meeting is this Tuesday at "
|
||||
"seven o'clock in the community centre on Park Road."
|
||||
),
|
||||
"academic_lecture": (
|
||||
f"[An academic lecture about {topic}]\n\n"
|
||||
f"Professor: Good morning, everyone. Today we'll be discussing {topic} "
|
||||
"and its significance in the modern world.\n\n"
|
||||
f"As you may already know, research into {topic} has expanded significantly "
|
||||
"over the past decade. Recent studies have shown that understanding this "
|
||||
"area can have far-reaching implications for both theory and practice.\n\n"
|
||||
"Let me begin by outlining the three main approaches that researchers "
|
||||
"have taken. The first approach focuses on quantitative analysis, "
|
||||
"using large datasets to identify patterns. The second emphasises "
|
||||
"qualitative methods, drawing on interviews and case studies. The third, "
|
||||
"and perhaps most promising, combines both methodologies.\n\n"
|
||||
"Now, I'd like to draw your attention to a landmark study published "
|
||||
"in 2023 by Dr. Chen and her colleagues. Their findings suggested that "
|
||||
"a combined approach yielded results that were 40% more reliable than "
|
||||
"either method used in isolation."
|
||||
),
|
||||
}
|
||||
return {"context": transcripts.get(section_type, transcripts["social_conversation"])}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_exercises(module, body):
|
||||
exercise_types = body.get("exercise_types", ["mcq"])
|
||||
type_counts = body.get("type_counts", {})
|
||||
default_count = body.get("count_per_type", 5)
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
questions = []
|
||||
|
||||
q_templates = {
|
||||
"mcq": lambda i: {
|
||||
"type": "mcq",
|
||||
"instructions": "Choose the correct answer for each question.",
|
||||
"prompt": f"According to the passage, what is the main idea discussed in paragraph {i + 1}?",
|
||||
"options": [
|
||||
"The historical background of the topic",
|
||||
"The current challenges being faced",
|
||||
"Future predictions and recommendations",
|
||||
"A comparison of different approaches",
|
||||
],
|
||||
"correct_answer": "The current challenges being faced",
|
||||
"explanation": "The paragraph primarily discusses the challenges faced in this area.",
|
||||
"marks": 1,
|
||||
},
|
||||
"true_false": lambda i: {
|
||||
"type": "true_false",
|
||||
"instructions": "Do the following statements agree with the information given in the passage? Write TRUE, FALSE, or NOT GIVEN.",
|
||||
"prompt": [
|
||||
"The author supports the idea that the topic will become more important.",
|
||||
"Research in this area began more than fifty years ago.",
|
||||
"All experts agree on the best approach to address this issue.",
|
||||
"The text mentions several countries where changes have occurred.",
|
||||
"The writer believes that current measures are sufficient.",
|
||||
][i % 5],
|
||||
"options": ["TRUE", "FALSE", "NOT GIVEN"],
|
||||
"correct_answer": ["TRUE", "FALSE", "NOT GIVEN", "TRUE", "FALSE"][i % 5],
|
||||
"explanation": "Based on the information provided in the passage.",
|
||||
"marks": 1,
|
||||
},
|
||||
"fill_blanks": lambda i: {
|
||||
"type": "fill_blanks",
|
||||
"instructions": "Complete the sentences below. Choose NO MORE THAN TWO WORDS from the passage for each answer.",
|
||||
"prompt": [
|
||||
"The main factor contributing to changes in this area is ___.",
|
||||
"Experts recommend that people should first focus on ___.",
|
||||
"The study found that combined methods were ___ more effective.",
|
||||
"Local communities have responded by implementing ___.",
|
||||
"The primary concern raised by critics is the issue of ___.",
|
||||
][i % 5],
|
||||
"options": [],
|
||||
"correct_answer": [
|
||||
"growing awareness", "basic principles", "significantly",
|
||||
"new measures", "accessibility",
|
||||
][i % 5],
|
||||
"explanation": "This answer can be found in the relevant paragraph of the passage.",
|
||||
"marks": 1,
|
||||
},
|
||||
"matching_headings": lambda i: {
|
||||
"type": "matching_headings",
|
||||
"instructions": "Choose the correct heading for each paragraph from the list below.",
|
||||
"prompt": f"Paragraph {i + 1}",
|
||||
"options": [
|
||||
"A. An overview of the current situation",
|
||||
"B. Historical development",
|
||||
"C. Future challenges and opportunities",
|
||||
"D. Government responses",
|
||||
"E. Expert opinions and analysis",
|
||||
],
|
||||
"correct_answer": ["A", "B", "C", "D", "E"][i % 5],
|
||||
"explanation": f"Paragraph {i + 1} primarily deals with this topic.",
|
||||
"marks": 1,
|
||||
},
|
||||
"paragraph_match": lambda i: {
|
||||
"type": "paragraph_match",
|
||||
"instructions": "Which paragraph contains the following information?",
|
||||
"prompt": [
|
||||
"a reference to research findings",
|
||||
"a mention of financial concerns",
|
||||
"an example from a specific country",
|
||||
"a description of community initiatives",
|
||||
"a prediction about the future",
|
||||
][i % 5],
|
||||
"options": ["A", "B", "C", "D", "E"],
|
||||
"correct_answer": ["C", "D", "B", "A", "E"][i % 5],
|
||||
"explanation": "This information can be found in the specified paragraph.",
|
||||
"marks": 1,
|
||||
},
|
||||
}
|
||||
|
||||
for et in (exercise_types or ["mcq"]):
|
||||
count = int(type_counts.get(et, default_count))
|
||||
gen_fn = q_templates.get(et, q_templates["mcq"])
|
||||
for i in range(count):
|
||||
q = gen_fn(i)
|
||||
q["difficulty"] = difficulty
|
||||
questions.append(q)
|
||||
|
||||
return {"questions": questions}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_questions(module, body):
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
count = int(body.get("count") or body.get("question_count") or 5)
|
||||
questions = []
|
||||
for i in range(count):
|
||||
questions.append({
|
||||
"type": "mcq",
|
||||
"prompt": f"Sample {module} question {i + 1} at {difficulty} level. "
|
||||
"Which of the following best describes the main concept?",
|
||||
"options": ["Option A", "Option B", "Option C", "Option D"],
|
||||
"correct_answer": "Option A",
|
||||
"explanation": f"This is a sample question for the {module} module.",
|
||||
"difficulty": difficulty,
|
||||
"marks": 1,
|
||||
})
|
||||
return {"questions": questions}
|
||||
|
||||
# ── POST /api/exam/generation/submit — create exam from generation page ──
|
||||
@http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def generation_submit(self, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _json_response({"error": "Authentication required"}, 401)
|
||||
request.update_env(user=user.id)
|
||||
body = _get_json()
|
||||
try:
|
||||
title = body.get("title", "").strip()
|
||||
@@ -461,6 +922,21 @@ class AIController(http.Controller):
|
||||
label = body.get("label", "")
|
||||
modules = body.get("modules", {})
|
||||
skip_approval = body.get("skip_approval", False)
|
||||
exam_mode = body.get("exam_mode", "official")
|
||||
structure_id = body.get("structure_id")
|
||||
|
||||
first_mod = next(iter(modules.values()), {}) if modules else {}
|
||||
entity_val = first_mod.get("entity", "none")
|
||||
entity_id = int(entity_val) if entity_val and entity_val != "none" else False
|
||||
rubric_raw = first_mod.get("rubricId", "")
|
||||
rubric_id = False
|
||||
if rubric_raw and rubric_raw.startswith("rubric-"):
|
||||
try:
|
||||
rubric_id = int(rubric_raw.split("-", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
workflow_val = first_mod.get("approvalWorkflow", "none")
|
||||
workflow_id = int(workflow_val) if workflow_val and workflow_val != "none" else 0
|
||||
|
||||
template_id = False
|
||||
try:
|
||||
@@ -482,35 +958,157 @@ class AIController(http.Controller):
|
||||
except KeyError:
|
||||
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
||||
|
||||
exam = Exam.sudo().create({
|
||||
exam_vals = {
|
||||
"title": title,
|
||||
"label": label,
|
||||
"exam_mode": exam_mode,
|
||||
"teacher_id": request.env.user.id,
|
||||
"template_id": template_id,
|
||||
"status": "published" if skip_approval else "draft",
|
||||
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
|
||||
"total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()),
|
||||
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
|
||||
})
|
||||
"grading_system": first_mod.get("gradingSystem", "ielts"),
|
||||
"access_type": first_mod.get("accessType", "private"),
|
||||
"approval_workflow_id": workflow_id,
|
||||
}
|
||||
if entity_id:
|
||||
exam_vals["entity_id"] = entity_id
|
||||
if rubric_id:
|
||||
exam_vals["rubric_id"] = rubric_id
|
||||
if structure_id:
|
||||
exam_vals["structure_id"] = int(structure_id)
|
||||
|
||||
try:
|
||||
Section = request.env["encoach.exam.custom.section"]
|
||||
seq = 10
|
||||
for mod_key, mod_data in modules.items():
|
||||
Section.sudo().create({
|
||||
"exam_id": exam.id,
|
||||
"title": mod_key.capitalize(),
|
||||
"skill": mod_key,
|
||||
"time_limit_min": mod_data.get("timer", 0),
|
||||
"scoring_method": "auto",
|
||||
"sequence": seq,
|
||||
exam = Exam.sudo().create(exam_vals)
|
||||
|
||||
Section = request.env["encoach.exam.custom.section"].sudo()
|
||||
Question = request.env["encoach.question"].sudo()
|
||||
|
||||
CEFR_TO_DIFFICULTY = {
|
||||
"A1": "easy", "A2": "easy",
|
||||
"B1": "medium", "B2": "medium",
|
||||
"C1": "hard", "C2": "hard",
|
||||
}
|
||||
QUESTION_TYPE_MAP = {
|
||||
"mcq": "mcq", "true_false": "tfng", "fill_blanks": "gap_fill",
|
||||
"matching_headings": "heading_matching", "paragraph_match": "matching",
|
||||
"short_answer": "short_answer", "summary_completion": "summary_completion",
|
||||
"multiple_choice": "mcq", "sentence_completion": "gap_fill",
|
||||
"matching_information": "matching", "note_completion": "note_completion",
|
||||
"form_completion": "form_completion", "map_labelling": "map_labelling",
|
||||
}
|
||||
|
||||
seq = 10
|
||||
total_questions = 0
|
||||
for mod_key, mod_data in modules.items():
|
||||
difficulty_list = mod_data.get("difficulty", ["B2"])
|
||||
cefr_level = difficulty_list[0] if isinstance(difficulty_list, list) and difficulty_list else "B2"
|
||||
q_difficulty = CEFR_TO_DIFFICULTY.get(cefr_level, "medium")
|
||||
|
||||
section = Section.create({
|
||||
"exam_id": exam.id,
|
||||
"title": mod_key.capitalize(),
|
||||
"skill": mod_key,
|
||||
"difficulty": cefr_level,
|
||||
"time_limit_min": mod_data.get("timer", 0),
|
||||
"total_marks": float(mod_data.get("totalMarks", 0)),
|
||||
"scoring_method": "rubric" if mod_key in ("writing", "speaking") else "auto",
|
||||
"sequence": seq,
|
||||
"content_json": json.dumps({
|
||||
k: mod_data[k] for k in ("passages", "sections", "tasks", "parts")
|
||||
if k in mod_data and mod_data[k]
|
||||
}),
|
||||
})
|
||||
seq += 10
|
||||
|
||||
question_ids = []
|
||||
|
||||
passages = mod_data.get("passages") or []
|
||||
for p_idx, passage in enumerate(passages):
|
||||
if passage.get("text"):
|
||||
section.sudo().write({"passage_text": passage["text"]})
|
||||
for ex in (passage.get("exercises") or []):
|
||||
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
||||
opts = ex.get("options", [])
|
||||
q = Question.create({
|
||||
"skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading",
|
||||
"source_type": "passage",
|
||||
"question_type": q_type,
|
||||
"stem": ex.get("prompt", "") or ex.get("instructions", ""),
|
||||
"options": json.dumps(opts) if opts else "[]",
|
||||
"correct_answer": ex.get("correct_answer", ""),
|
||||
"marks": float(ex.get("marks", 1)),
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
question_ids.append(q.id)
|
||||
|
||||
sections_data = mod_data.get("sections") or []
|
||||
for s_data in sections_data:
|
||||
if s_data.get("context"):
|
||||
section.sudo().write({"passage_text": s_data["context"]})
|
||||
for ex in (s_data.get("exercises") or []):
|
||||
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
||||
opts = ex.get("options", [])
|
||||
q = Question.create({
|
||||
"skill": "listening",
|
||||
"source_type": "audio",
|
||||
"question_type": q_type,
|
||||
"stem": ex.get("prompt", "") or ex.get("instructions", ""),
|
||||
"options": json.dumps(opts) if opts else "[]",
|
||||
"correct_answer": ex.get("correct_answer", ""),
|
||||
"marks": float(ex.get("marks", 1)),
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
question_ids.append(q.id)
|
||||
|
||||
tasks = mod_data.get("tasks") or []
|
||||
for t_idx, task in enumerate(tasks):
|
||||
q = Question.create({
|
||||
"skill": "writing",
|
||||
"source_type": "writing_prompt",
|
||||
"question_type": "short_answer",
|
||||
"stem": task.get("instructions", f"Writing Task {t_idx + 1}"),
|
||||
"options": "[]",
|
||||
"correct_answer": "",
|
||||
"marks": float(task.get("marks", 0)),
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
seq += 10
|
||||
except KeyError:
|
||||
pass
|
||||
question_ids.append(q.id)
|
||||
|
||||
parts = mod_data.get("parts") or []
|
||||
for p_idx, part in enumerate(parts):
|
||||
q = Question.create({
|
||||
"skill": "speaking",
|
||||
"source_type": "speaking_card",
|
||||
"question_type": "short_answer",
|
||||
"stem": part.get("script", f"Speaking Part {p_idx + 1}"),
|
||||
"options": "[]",
|
||||
"correct_answer": "",
|
||||
"marks": float(part.get("marks", 0)),
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
question_ids.append(q.id)
|
||||
|
||||
if question_ids:
|
||||
section.sudo().write({
|
||||
"question_ids": [(6, 0, question_ids)],
|
||||
"question_count": len(question_ids),
|
||||
})
|
||||
total_questions += len(question_ids)
|
||||
|
||||
return _json_response({
|
||||
"exam_id": exam.id,
|
||||
"status": exam.status,
|
||||
"template_id": template_id,
|
||||
"total_questions": total_questions,
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
_logger.exception("generation submit failed")
|
||||
@@ -532,8 +1130,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"applied": 0, "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
|
||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def exam_generate_save(self, module, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _json_response({"error": "Authentication required"}, 401)
|
||||
request.update_env(user=user.id)
|
||||
body = _get_json()
|
||||
questions = body.get("questions", [])
|
||||
saved = 0
|
||||
|
||||
Reference in New Issue
Block a user