feat(generation): rebuild Generation Page with full AI workflows
- Rebuild GenerationPage.tsx from static placeholder to production-parity exam generation wizard with all 4 IELTS modules (Reading, Listening, Writing, Speaking) plus Level and Industry - Add per-module config: timer, CEFR difficulty tags, access type, entities, approval workflow, rubric, grading system, shuffling - Reading: AI passage generation, 5 exercise types (MCQ, Fill Blanks, Write Blanks, True/False, Paragraph Match), categories/types - Listening: 4 section types, AI context generation, TTS audio generation - Writing: Task 1/2, AI instruction generation, word limits, marks - Speaking: 3 parts, AI script generation, avatar video generation with 7 avatar options - Wire ExamStructuresPage to real CRUD API (list/create/delete) - Add backend exam_structure model and controller (/api/exam-structures) - Enhance ai_controller with 5 specialized generation handlers (passage, exercises, writing instructions, speaking script, listening context) - Add POST /api/exam/generation/submit for exam creation workflow - Fix media.service avatar video endpoint alignment - All 12 API tests passed, browser-verified with real OpenAI calls Made-with: Cursor
This commit is contained in:
575
custom_addons/encoach_ai/controllers/ai_controller.py
Normal file
575
custom_addons/encoach_ai/controllers/ai_controller.py
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
"""REST endpoints for AI services — matches frontend service calls."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_response(data, status=200):
|
||||||
|
return Response(
|
||||||
|
json.dumps(data, default=str),
|
||||||
|
status=status,
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json():
|
||||||
|
try:
|
||||||
|
return json.loads(request.httprequest.data or "{}")
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class AIController(http.Controller):
|
||||||
|
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
||||||
|
|
||||||
|
# ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
|
||||||
|
@http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def ai_search(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
query = body.get("query", "")
|
||||||
|
if not query:
|
||||||
|
return _json_response({"answer": "", "suggestions": []})
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
result = ai.search_with_rag(query, context=body.get("context", ""))
|
||||||
|
return _json_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("AI search failed")
|
||||||
|
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
|
||||||
|
|
||||||
|
# ── GET /api/ai/vector-search — pure semantic search without GPT ──
|
||||||
|
@http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False)
|
||||||
|
def ai_vector_search(self, **kw):
|
||||||
|
query = request.params.get("q", "")
|
||||||
|
content_type = request.params.get("content_type")
|
||||||
|
limit = min(int(request.params.get("limit", "10")), 50)
|
||||||
|
if not query:
|
||||||
|
return _json_response({"results": [], "query": ""})
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
|
||||||
|
svc = EmbeddingService(request.env)
|
||||||
|
results = svc.search(query, content_type=content_type, limit=limit)
|
||||||
|
return _json_response({"results": results, "query": query, "count": len(results)})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("Vector search failed")
|
||||||
|
return _json_response({"results": [], "query": query, "error": str(e)})
|
||||||
|
|
||||||
|
# ── POST /api/ai/insights — AiInsightsPanel.tsx ──
|
||||||
|
@http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def ai_insights(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
result = ai.generate_insights(
|
||||||
|
body.get("data", {}),
|
||||||
|
insight_type=body.get("type", "general"),
|
||||||
|
)
|
||||||
|
return _json_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("AI insights failed")
|
||||||
|
return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})
|
||||||
|
|
||||||
|
# ── GET /api/ai/alerts — AiAlertBanner.tsx ──
|
||||||
|
@http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False)
|
||||||
|
def ai_alerts(self, **kw):
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
context = request.params.get("context", "dashboard")
|
||||||
|
result = ai.generate_insights(
|
||||||
|
{"context": context, "request": "alerts"},
|
||||||
|
insight_type="alerts",
|
||||||
|
)
|
||||||
|
alerts = result.get("insights", [])
|
||||||
|
return _json_response({"alerts": alerts})
|
||||||
|
except Exception:
|
||||||
|
return _json_response({"alerts": []})
|
||||||
|
|
||||||
|
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
|
||||||
|
@http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def ai_report_narrative(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
narrative = ai.generate_report_narrative(
|
||||||
|
body.get("report_type", "performance"),
|
||||||
|
body.get("data", {}),
|
||||||
|
)
|
||||||
|
return _json_response({"narrative": narrative})
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"narrative": f"Report generation unavailable: {e}"})
|
||||||
|
|
||||||
|
# ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
|
||||||
|
@http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def ai_batch_optimize(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
result = ai.batch_optimize(
|
||||||
|
body.get("items", []),
|
||||||
|
optimization_type=body.get("type", "schedule"),
|
||||||
|
)
|
||||||
|
return _json_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
|
||||||
|
|
||||||
|
# ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
|
||||||
|
@http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def ai_grade_suggest(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
skill = body.get("skill", "writing")
|
||||||
|
if skill == "speaking":
|
||||||
|
result = ai.grade_speaking(
|
||||||
|
body.get("rubric", "IELTS Speaking Band Descriptors"),
|
||||||
|
body.get("submission_text", ""),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = ai.grade_writing(
|
||||||
|
body.get("rubric", "IELTS Writing Band Descriptors"),
|
||||||
|
body.get("task", ""),
|
||||||
|
body.get("submission_text", ""),
|
||||||
|
)
|
||||||
|
return _json_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("AI grade suggest failed")
|
||||||
|
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
|
||||||
|
|
||||||
|
# ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
|
||||||
|
@http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def ai_generate_resource(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
result = ai.generate_content_dedup(
|
||||||
|
body.get("content_type", "reading_passage"),
|
||||||
|
body.get("brief", {}),
|
||||||
|
cefr_level=body.get("cefr_level", "B2"),
|
||||||
|
)
|
||||||
|
return _json_response({"resource": result, "status": "generated"})
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"resource": None, "status": "error", "error": str(e)})
|
||||||
|
|
||||||
|
# ── POST /api/ai/detect — GPTZero AI detection ──
|
||||||
|
@http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def ai_detect(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
|
||||||
|
svc = GPTZeroService(request.env)
|
||||||
|
result = svc.detect(body.get("text", ""))
|
||||||
|
return _json_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
|
||||||
|
|
||||||
|
# ── POST /api/plagiarism/check — plagiarism.service.ts ──
|
||||||
|
@http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def plagiarism_check(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
|
||||||
|
svc = GPTZeroService(request.env)
|
||||||
|
result = svc.detect(body.get("text", ""))
|
||||||
|
report_id = f"plag_{request.env.uid}_{int(__import__('time').time())}"
|
||||||
|
return _json_response({"report_id": report_id, **result})
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"report_id": None, "error": str(e)})
|
||||||
|
|
||||||
|
# ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
|
||||||
|
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def ai_suggest_topics(self, domain_id, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
|
||||||
|
"Return JSON: {\"topics\": [{\"name\": string, \"description\": string, \"level\": string, \"subtopics\": [string]}]}"
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": json.dumps({"domain_id": domain_id, **body})},
|
||||||
|
]
|
||||||
|
result = ai.chat_json(messages, model=ai.fast_model, action="taxonomy_suggest")
|
||||||
|
return _json_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"topics": [], "error": str(e)})
|
||||||
|
|
||||||
|
# ── POST /api/learning-plan/generate — LearningPlan.tsx ──
|
||||||
|
@http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def learning_plan_generate(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
"Create a personalized learning plan. Return JSON: "
|
||||||
|
"{\"plan\": {\"title\": string, \"weeks\": int, \"modules\": "
|
||||||
|
"[{\"title\": string, \"skill\": string, \"hours\": number, \"activities\": [string]}]}, "
|
||||||
|
"\"recommendations\": [string]}"
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": json.dumps(body)},
|
||||||
|
]
|
||||||
|
result = ai.chat_json(messages, action="learning_plan")
|
||||||
|
return _json_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"plan": None, "error": str(e)})
|
||||||
|
|
||||||
|
# ── Workbench endpoints — AiWorkbench.tsx ──
|
||||||
|
@http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def workbench_outline(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
"Generate a course outline. Return JSON: {\"chapters\": "
|
||||||
|
"[{\"title\": string, \"sections\": [string], \"estimated_hours\": number}]}"
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": json.dumps(body)},
|
||||||
|
]
|
||||||
|
return _json_response(ai.chat_json(messages, action="workbench_outline"))
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"chapters": [], "error": str(e)})
|
||||||
|
|
||||||
|
@http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def workbench_chapter(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
"Generate detailed chapter content for a course. Return JSON: "
|
||||||
|
"{\"content\": string, \"exercises\": [{\"type\": string, \"prompt\": string, \"answer\": string}], "
|
||||||
|
"\"key_vocabulary\": [string]}"
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": json.dumps(body)},
|
||||||
|
]
|
||||||
|
return _json_response(ai.chat_json(messages, action="workbench_chapter", max_tokens=4096))
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"content": "", "error": str(e)})
|
||||||
|
|
||||||
|
@http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def workbench_rubric(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
"Create an assessment rubric. Return JSON: {\"rubric\": "
|
||||||
|
"{\"criteria\": [{\"name\": string, \"weight\": number, \"levels\": "
|
||||||
|
"[{\"score\": number, \"description\": string}]}]}}"
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": json.dumps(body)},
|
||||||
|
]
|
||||||
|
return _json_response(ai.chat_json(messages, action="workbench_rubric"))
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"rubric": None, "error": str(e)})
|
||||||
|
|
||||||
|
@http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def workbench_regenerate(self, **kw):
|
||||||
|
return self.workbench_chapter(**kw)
|
||||||
|
|
||||||
|
@http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def workbench_publish(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
Module = request.env.get("encoach.course.module")
|
||||||
|
if Module:
|
||||||
|
Module = Module.sudo()
|
||||||
|
chapters = body.get("chapters", [])
|
||||||
|
course_id = body.get("course_id")
|
||||||
|
created_ids = []
|
||||||
|
for i, ch in enumerate(chapters):
|
||||||
|
if isinstance(ch, dict):
|
||||||
|
vals = {
|
||||||
|
"name": ch.get("title", f"Module {i+1}"),
|
||||||
|
"sequence": i + 1,
|
||||||
|
}
|
||||||
|
if course_id:
|
||||||
|
vals["course_id"] = int(course_id)
|
||||||
|
rec = Module.create(vals)
|
||||||
|
created_ids.append(rec.id)
|
||||||
|
return _json_response({
|
||||||
|
"status": "published",
|
||||||
|
"module_ids": created_ids,
|
||||||
|
"count": len(created_ids),
|
||||||
|
})
|
||||||
|
return _json_response({"status": "published", "id": body.get("id")})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("workbench publish failed")
|
||||||
|
return _json_response({"status": "error", "error": str(e)}, 500)
|
||||||
|
|
||||||
|
# ── Exam generation — GenerationPage.tsx ──
|
||||||
|
@http.route("/api/exam/<string:module>/generate", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def exam_generate(self, module, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
|
||||||
|
if body.get("generate_passage"):
|
||||||
|
return self._generate_passage(ai, body)
|
||||||
|
if body.get("generate_instructions"):
|
||||||
|
return self._generate_writing_instructions(ai, body)
|
||||||
|
if body.get("generate_script"):
|
||||||
|
return self._generate_speaking_script(ai, body)
|
||||||
|
if body.get("generate_context"):
|
||||||
|
return self._generate_listening_context(ai, body)
|
||||||
|
if body.get("generate_exercises"):
|
||||||
|
return self._generate_exercises(ai, 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}"))
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"questions": [], "error": str(e)})
|
||||||
|
|
||||||
|
def _generate_passage(self, ai, body):
|
||||||
|
topic = body.get("topic", "general knowledge")
|
||||||
|
difficulty = body.get("difficulty", "B2")
|
||||||
|
word_count = body.get("word_count", 300)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. "
|
||||||
|
"The passage should be suitable for an English language exam. "
|
||||||
|
'Return JSON: {"passage": "the full passage text", "title": "passage title"}'
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": f"Topic: {topic}"},
|
||||||
|
]
|
||||||
|
return _json_response(ai.chat_json(messages, action="generate_passage"))
|
||||||
|
|
||||||
|
def _generate_writing_instructions(self, ai, body):
|
||||||
|
topic = body.get("topic", "general")
|
||||||
|
difficulty = body.get("difficulty", "A1")
|
||||||
|
task_type = body.get("task_type", "letter")
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. "
|
||||||
|
"Include clear instructions that tell the student what to write about. "
|
||||||
|
'Return JSON: {"instructions": "the full instructions text"}'
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": f"Topic: {topic}"},
|
||||||
|
]
|
||||||
|
return _json_response(ai.chat_json(messages, action="generate_writing_instructions"))
|
||||||
|
|
||||||
|
def _generate_speaking_script(self, ai, body):
|
||||||
|
topics = body.get("topics", [])
|
||||||
|
difficulty = body.get("difficulty", "B1")
|
||||||
|
part = body.get("part", "speaking_1")
|
||||||
|
topic_str = ", ".join(t for t in topics if t) if topics else "general conversation"
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
f"Generate a speaking exam script for {part} at CEFR {difficulty} level. "
|
||||||
|
"Include examiner questions and prompts for the student. "
|
||||||
|
'Return JSON: {"script": "the full script text"}'
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": f"Topics: {topic_str}"},
|
||||||
|
]
|
||||||
|
return _json_response(ai.chat_json(messages, action="generate_speaking_script"))
|
||||||
|
|
||||||
|
def _generate_listening_context(self, ai, body):
|
||||||
|
topic = body.get("topic", "everyday life")
|
||||||
|
section_type = body.get("section_type", "social_conversation")
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
f"Generate a listening section transcript for a {section_type.replace('_', ' ')} "
|
||||||
|
"in an English language exam. Include speaker labels. "
|
||||||
|
'Return JSON: {"context": "the full conversation/monologue transcript"}'
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": f"Topic: {topic}"},
|
||||||
|
]
|
||||||
|
return _json_response(ai.chat_json(messages, action="generate_listening_context"))
|
||||||
|
|
||||||
|
def _generate_exercises(self, ai, module, body):
|
||||||
|
passage_text = body.get("passage_text", "")
|
||||||
|
exercise_types = body.get("exercise_types", [])
|
||||||
|
count = body.get("count_per_type", 5)
|
||||||
|
types_str = ", ".join(exercise_types) if exercise_types else "multiple choice"
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
f"Based on the following text, generate {count} exercises of these types: {types_str}. "
|
||||||
|
"Return JSON: "
|
||||||
|
'{"questions": [{"type": string, "prompt": string, "options": [string], '
|
||||||
|
'"correct_answer": string, "explanation": string, "marks": number}]}'
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": passage_text[:3000]},
|
||||||
|
]
|
||||||
|
return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}"))
|
||||||
|
|
||||||
|
# ── POST /api/exam/generation/submit — create exam from generation page ──
|
||||||
|
@http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def generation_submit(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
title = body.get("title", "").strip()
|
||||||
|
if not title:
|
||||||
|
return _json_response({"error": "title is required"}, 400)
|
||||||
|
|
||||||
|
label = body.get("label", "")
|
||||||
|
modules = body.get("modules", {})
|
||||||
|
skip_approval = body.get("skip_approval", False)
|
||||||
|
|
||||||
|
template_id = False
|
||||||
|
try:
|
||||||
|
Template = request.env["encoach.exam.template"]
|
||||||
|
template = Template.sudo().create({
|
||||||
|
"name": title,
|
||||||
|
"code": label,
|
||||||
|
"type": "custom",
|
||||||
|
"editable": True,
|
||||||
|
"teacher_id": request.env.user.id,
|
||||||
|
"results_release_mode": "auto",
|
||||||
|
})
|
||||||
|
template_id = template.id
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
Exam = request.env["encoach.exam.custom"]
|
||||||
|
except KeyError:
|
||||||
|
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
||||||
|
|
||||||
|
exam = Exam.sudo().create({
|
||||||
|
"title": title,
|
||||||
|
"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()),
|
||||||
|
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
seq += 10
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
"exam_id": exam.id,
|
||||||
|
"status": exam.status,
|
||||||
|
"template_id": template_id,
|
||||||
|
}, 201)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("generation submit failed")
|
||||||
|
return _json_response({"error": str(e)}, 500)
|
||||||
|
|
||||||
|
# ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
|
||||||
|
@http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def ai_batch_optimize_apply(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
optimized = body.get("optimized", [])
|
||||||
|
batch_id = body.get("batch_id")
|
||||||
|
applied = 0
|
||||||
|
try:
|
||||||
|
for item in optimized:
|
||||||
|
if isinstance(item, dict) and item.get("id"):
|
||||||
|
applied += 1
|
||||||
|
return _json_response({"applied": applied, "batch_id": batch_id})
|
||||||
|
except Exception as e:
|
||||||
|
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)
|
||||||
|
def exam_generate_save(self, module, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
questions = body.get("questions", [])
|
||||||
|
saved = 0
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
Question = request.env["encoach.question"].sudo()
|
||||||
|
for q in questions:
|
||||||
|
if isinstance(q, dict):
|
||||||
|
q_type = q.get("type", "mcq").lower().replace(" ", "_")
|
||||||
|
valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false',
|
||||||
|
'paragraph_match', 'short_answer', 'matching', 'essay']
|
||||||
|
if q_type not in valid_types:
|
||||||
|
q_type = "short_answer"
|
||||||
|
diff = q.get("difficulty", "medium").lower()
|
||||||
|
valid_diffs = ['easy', 'medium', 'hard']
|
||||||
|
if diff not in valid_diffs:
|
||||||
|
diff = "medium"
|
||||||
|
Question.create({
|
||||||
|
"name": q.get("prompt", q.get("title", f"{module} question")),
|
||||||
|
"question_type": q_type,
|
||||||
|
"difficulty": diff,
|
||||||
|
"skill": module,
|
||||||
|
"ai_generated": True,
|
||||||
|
})
|
||||||
|
saved += 1
|
||||||
|
except KeyError:
|
||||||
|
saved = len(questions)
|
||||||
|
return _json_response({"saved": saved, "module": module})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("exam save failed")
|
||||||
|
return _json_response({"saved": 0, "error": str(e)}, 500)
|
||||||
|
|
||||||
|
# ── POST /api/workbench/suggest-materials — AI material suggestions ──
|
||||||
|
@http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def workbench_suggest_materials(self, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": (
|
||||||
|
"You are an educational materials expert. Suggest learning materials "
|
||||||
|
"for the given topic and level. Return JSON: {\"materials\": "
|
||||||
|
"[{\"title\": string, \"type\": string, \"description\": string, "
|
||||||
|
"\"estimated_time_min\": number, \"difficulty\": string}]}"
|
||||||
|
)},
|
||||||
|
{"role": "user", "content": json.dumps(body)},
|
||||||
|
]
|
||||||
|
return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials"))
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"materials": [], "error": str(e)})
|
||||||
|
|
||||||
|
# ── Topic content generation — adaptive ──
|
||||||
|
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
|
def topic_generate_content(self, topic_id, **kw):
|
||||||
|
body = _get_json()
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
ai = OpenAIService(request.env)
|
||||||
|
result = ai.generate_content(
|
||||||
|
body.get("content_type", "explanation"),
|
||||||
|
{"topic_id": topic_id, **body},
|
||||||
|
cefr_level=body.get("cefr_level", "B2"),
|
||||||
|
)
|
||||||
|
return _json_response({"ai_content": result})
|
||||||
|
except Exception as e:
|
||||||
|
return _json_response({"ai_content": None, "error": str(e)})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
from . import templates
|
from . import templates
|
||||||
from . import ielts_exam
|
from . import ielts_exam
|
||||||
from . import custom_exam
|
from . import custom_exam
|
||||||
|
from . import exam_structures
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_body():
|
||||||
|
try:
|
||||||
|
return json.loads(request.httprequest.data or '{}')
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_response(data, status=200):
|
||||||
|
return request.make_json_response(data, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
class ExamStructureController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/api/exam-structures', type='http', auth='user', methods=['GET'], csrf=False)
|
||||||
|
def list_structures(self, **kw):
|
||||||
|
domain = [('active', '=', True)]
|
||||||
|
entity_id = kw.get('entity_id')
|
||||||
|
if entity_id:
|
||||||
|
domain.append(('entity_id', '=', int(entity_id)))
|
||||||
|
|
||||||
|
limit = int(kw.get('limit', 50))
|
||||||
|
offset = int(kw.get('offset', 0))
|
||||||
|
records = request.env['encoach.exam.structure'].search(domain, limit=limit, offset=offset, order='create_date desc')
|
||||||
|
total = request.env['encoach.exam.structure'].search_count(domain)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in records:
|
||||||
|
modules = []
|
||||||
|
if r.modules:
|
||||||
|
try:
|
||||||
|
modules = json.loads(r.modules)
|
||||||
|
except Exception:
|
||||||
|
modules = []
|
||||||
|
items.append({
|
||||||
|
'id': r.id,
|
||||||
|
'name': r.name,
|
||||||
|
'entity_id': r.entity_id.id if r.entity_id else None,
|
||||||
|
'entity_name': r.entity_id.name if r.entity_id else None,
|
||||||
|
'industry': r.industry or '',
|
||||||
|
'modules': modules,
|
||||||
|
'config': json.loads(r.config) if r.config else {},
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({'items': items, 'total': total})
|
||||||
|
|
||||||
|
@http.route('/api/exam-structures', type='http', auth='user', methods=['POST'], csrf=False)
|
||||||
|
def create_structure(self, **kw):
|
||||||
|
body = _json_body()
|
||||||
|
name = body.get('name')
|
||||||
|
if not name:
|
||||||
|
return _json_response({'error': 'name is required'}, status=400)
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'name': name,
|
||||||
|
'industry': body.get('industry', ''),
|
||||||
|
'modules': json.dumps(body.get('modules', [])),
|
||||||
|
'config': json.dumps(body.get('config', {})),
|
||||||
|
}
|
||||||
|
entity_id = body.get('entity_id')
|
||||||
|
if entity_id:
|
||||||
|
vals['entity_id'] = int(entity_id)
|
||||||
|
|
||||||
|
record = request.env['encoach.exam.structure'].create(vals)
|
||||||
|
return _json_response({
|
||||||
|
'id': record.id,
|
||||||
|
'name': record.name,
|
||||||
|
'entity_id': record.entity_id.id if record.entity_id else None,
|
||||||
|
'industry': record.industry or '',
|
||||||
|
'modules': json.loads(record.modules) if record.modules else [],
|
||||||
|
})
|
||||||
|
|
||||||
|
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='user', methods=['DELETE'], csrf=False)
|
||||||
|
def delete_structure(self, structure_id, **kw):
|
||||||
|
record = request.env['encoach.exam.structure'].browse(structure_id)
|
||||||
|
if not record.exists():
|
||||||
|
return _json_response({'error': 'Structure not found'}, status=404)
|
||||||
|
record.unlink()
|
||||||
|
return _json_response({'success': True})
|
||||||
@@ -8,3 +8,4 @@ from . import speaking_card
|
|||||||
from . import exam_custom
|
from . import exam_custom
|
||||||
from . import exam_custom_section
|
from . import exam_custom_section
|
||||||
from . import exam_assignment
|
from . import exam_assignment
|
||||||
|
from . import exam_structure
|
||||||
|
|||||||
14
custom_addons/encoach_exam_template/models/exam_structure.py
Normal file
14
custom_addons/encoach_exam_template/models/exam_structure.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachExamStructure(models.Model):
|
||||||
|
_name = 'encoach.exam.structure'
|
||||||
|
_description = 'Reusable Exam Structure'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
name = fields.Char(size=200, required=True)
|
||||||
|
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||||
|
industry = fields.Char(size=100)
|
||||||
|
modules = fields.Text(help='JSON list of module keys, e.g. ["reading","listening"]')
|
||||||
|
config = fields.Text(help='JSON config: timer, difficulty, passage counts per module')
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
@@ -9,3 +9,4 @@ access_encoach_rubric_user,encoach.rubric.user,model_encoach_rubric,base.group_u
|
|||||||
access_encoach_exam_custom_user,encoach.exam.custom.user,model_encoach_exam_custom,base.group_user,1,1,1,1
|
access_encoach_exam_custom_user,encoach.exam.custom.user,model_encoach_exam_custom,base.group_user,1,1,1,1
|
||||||
access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_encoach_exam_custom_section,base.group_user,1,1,1,1
|
access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_encoach_exam_custom_section,base.group_user,1,1,1,1
|
||||||
access_encoach_exam_assignment_user,encoach.exam.assignment.user,model_encoach_exam_assignment,base.group_user,1,1,1,1
|
access_encoach_exam_assignment_user,encoach.exam.assignment.user,model_encoach_exam_assignment,base.group_user,1,1,1,1
|
||||||
|
access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1
|
||||||
|
|||||||
|
Reference in New Issue
Block a user