Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs. Backend (new `encoach_lms_api` addon + existing addons): - Institutional: academic years/terms, departments, admission registers & admissions, courses/batches, lessons, fees (terms + student fees + invoicing with income-account auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset), student leave, result templates + marksheets (incl. delete-with-cascade). - Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived from `op.student.fees.details` and `account.move`; platform settings backed by `encoach.code` and `ir.config_parameter` (packages + grading config). - Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models) with CRUD, pagination, search/level filters, and upsert-style progress endpoints. Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`; `ValidationError`/`UserError` mapped to HTTP 400. Frontend: - Rewire institutional admin pages (Academic Year Manager, Admissions, Courses, Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets, Taxonomy, Resources) to real APIs with React Query invalidation and dialogs. - New typed services: `payments.service.ts`, `platformSettings.service.ts`, `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/ resources/student-progress/generation` services + related types. - Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`, `TicketsPage` to consume live data with search/filter/progress/CRUD flows. - New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`. - Favicons/branding assets and misc. UX polish across teacher/student pages. Tooling & QA: - Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent, covers institutional + support + training fixtures incl. income-account wiring). - API write-flow test suites: `test_write_flows.py` (institutional), `test_support_flows.py` (support), `test_training_flows.py` (training), `test_ai_full.py`. All suites pass end-to-end. - Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA. - `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`, `frontend/node_modules/`. Made-with: Cursor
1558 lines
81 KiB
Python
1558 lines
81 KiB
Python
"""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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", methods=["POST"], csrf=False)
|
|
def workbench_regenerate(self, **kw):
|
|
return self.workbench_chapter(**kw)
|
|
|
|
@http.route("/api/workbench/publish", type="http", auth="public", 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)
|
|
|
|
# ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ──
|
|
@http.route("/api/ai/suggest-rubric-criteria", type="http", auth="public", 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="public", 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"):
|
|
if has_ai:
|
|
return self._generate_passage(ai, body)
|
|
return _json_response(self._fallback_passage(body))
|
|
if body.get("generate_instructions"):
|
|
if has_ai:
|
|
return self._generate_writing_instructions(ai, body)
|
|
return _json_response(self._fallback_writing_instructions(body))
|
|
if body.get("generate_script"):
|
|
if has_ai:
|
|
return self._generate_speaking_script(ai, body)
|
|
return _json_response(self._fallback_speaking_script(body))
|
|
if body.get("generate_context"):
|
|
if has_ai:
|
|
return self._generate_listening_context(ai, body)
|
|
return _json_response(self._fallback_listening_context(body))
|
|
if body.get("generate_exercises"):
|
|
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
|
|
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)
|
|
|
|
def _get_material_context(self, ai, query, course_id=None, subject_id=None, entity_id=None):
|
|
"""Fetch relevant course material / resource context from the vector store for
|
|
RAG-enhanced generation.
|
|
|
|
Results are re-ranked to prefer (in order):
|
|
same course_id → same subject_id → same entity_id → everything else.
|
|
"""
|
|
try:
|
|
context_results = ai._get_vector_context(
|
|
query,
|
|
content_types=['material', 'resource', 'course', 'topic', 'learning_objective'],
|
|
limit=8,
|
|
)
|
|
if not context_results:
|
|
return ""
|
|
|
|
def _score(r):
|
|
meta = r.get('metadata', {}) or {}
|
|
s = 0
|
|
if course_id and meta.get('course_id') == course_id:
|
|
s += 100
|
|
if subject_id and meta.get('subject_id') == subject_id:
|
|
s += 50
|
|
if entity_id and meta.get('entity_id') == entity_id:
|
|
s += 25
|
|
s += float(r.get('similarity', 0) or 0) * 10
|
|
return -s
|
|
context_results.sort(key=_score)
|
|
return ai._format_context(context_results[:6])
|
|
except Exception:
|
|
return ""
|
|
|
|
# ── Persona / CEFR helpers for exam generation ────────────────────
|
|
# Concise CEFR descriptors used to give GPT a mental model of each band.
|
|
# Keep these short — they are prepended to every prompt.
|
|
_CEFR_DESCRIPTORS = {
|
|
"A1": "A1 (Breakthrough) — very basic personal information; concrete vocabulary; short fixed phrases; simple present tense; text ~100-200 words with predictable structure.",
|
|
"A2": "A2 (Elementary) — familiar everyday topics; simple past / future; concrete connectors (and, but, because); text ~200-300 words.",
|
|
"B1": "B1 (Threshold) — familiar matters at work/school/leisure; clear standard language; can follow main points; text ~300-400 words; some abstract ideas.",
|
|
"B2": "B2 (Vantage) — complex texts on concrete and abstract topics; including technical discussions in speciality; text ~400-600 words; clear detailed argument.",
|
|
"C1": "C1 (Effective Operational Proficiency) — wide range of demanding long texts; implicit meaning; flexible and effective language use; nuanced argument; text ~600-900 words; idiomatic expressions; complex sub-clauses.",
|
|
"C2": "C2 (Mastery) — understand virtually everything with ease; reconstruct arguments; precise shades of meaning; text ~800-1200 words; sophisticated stylistic choices.",
|
|
}
|
|
|
|
# Examiner / item-writer personas keyed by module + exam_mode.
|
|
_EXAM_MODE_LABEL = {
|
|
"official": "official high-stakes IELTS exam (summative, ranked Band 0-9)",
|
|
"practice": "practice / formative exam (low-stakes, used for learner feedback)",
|
|
}
|
|
|
|
def _persona_for(self, module, exam_mode="official", exam_type="academic"):
|
|
mode = self._EXAM_MODE_LABEL.get(exam_mode, "standardised English exam")
|
|
et = (exam_type or "academic").lower()
|
|
et_pretty = {
|
|
"academic": "IELTS Academic",
|
|
"general": "IELTS General Training",
|
|
"general_training": "IELTS General Training",
|
|
"business": "Business English",
|
|
"ket": "Cambridge KET", "pet": "Cambridge PET",
|
|
"fce": "Cambridge FCE", "cae": "Cambridge CAE", "cpe": "Cambridge CPE",
|
|
}.get(et, et.title())
|
|
base = f"You are a senior {et_pretty} item writer preparing content for a {mode}."
|
|
extras = {
|
|
"reading": " You specialise in CEFR-aligned reading passages whose lexical range, grammatical complexity, cohesion devices, and topic sophistication match the target band exactly.",
|
|
"listening": " You specialise in CEFR-aligned listening scripts with realistic features (hesitations, discourse markers, accents) while staying lexically and grammatically appropriate to the target band.",
|
|
"writing": " You specialise in CEFR-aligned writing prompts that elicit the task response, lexical range, grammatical range and coherence expected at the target band.",
|
|
"speaking": " You specialise in CEFR-aligned speaking cue cards and examiner scripts whose follow-up questions elicit the fluency, lexical resource, grammatical range and pronunciation expected at the target band.",
|
|
}
|
|
return base + extras.get(module, "")
|
|
|
|
def _build_persona_context(self, body):
|
|
"""Format the user-captured parameters into a readable 'exam brief' block
|
|
that the LLM sees as a dedicated system message."""
|
|
rows = []
|
|
|
|
def _add(label, value):
|
|
if value in (None, "", [], {}):
|
|
return
|
|
if isinstance(value, list):
|
|
value = ", ".join(str(v) for v in value if v not in (None, ""))
|
|
if not value:
|
|
return
|
|
rows.append(f"- {label}: {value}")
|
|
|
|
_add("Exam title", body.get("exam_title") or body.get("title"))
|
|
_add("Exam label", body.get("exam_label") or body.get("label"))
|
|
_add("Exam mode", body.get("exam_mode"))
|
|
_add("Exam structure", body.get("structure_name") or body.get("structure"))
|
|
_add("Module", body.get("module"))
|
|
_add("CEFR target level", body.get("difficulty"))
|
|
_add("Passage category", body.get("category"))
|
|
_add("Passage type", body.get("passage_type") or body.get("type"))
|
|
_add("Task type", body.get("task_type"))
|
|
_add("Speaking part", body.get("part"))
|
|
_add("Listening section type", body.get("section_type"))
|
|
_add("Target word count", body.get("word_count"))
|
|
_add("Subject", body.get("subject_name") or body.get("subject"))
|
|
_add("Topic", body.get("topic") or body.get("topics"))
|
|
_add("Entity / Organisation", body.get("entity_name"))
|
|
_add("Rubric", body.get("rubric_name"))
|
|
_add("Grading system", body.get("grading_system"))
|
|
_add("Access type", body.get("access_type"))
|
|
|
|
if not rows:
|
|
return ""
|
|
return "EXAM BRIEF — follow these parameters strictly:\n" + "\n".join(rows)
|
|
|
|
def _build_rag_query(self, body, fallback=""):
|
|
"""Combine topic / category / type / subject / objective into a single
|
|
semantic query for vector RAG (much more specific than just 'topic')."""
|
|
parts = []
|
|
for key in ("topic", "topics", "category", "passage_type", "type",
|
|
"task_type", "section_type", "subject_name", "module"):
|
|
v = body.get(key)
|
|
if isinstance(v, list):
|
|
parts.extend(str(x) for x in v if x)
|
|
elif v:
|
|
parts.append(str(v))
|
|
if not parts and fallback:
|
|
parts.append(fallback)
|
|
return " ".join(parts)[:400]
|
|
|
|
def _generate_passage(self, ai, body):
|
|
topic = body.get("topic", "general knowledge")
|
|
difficulty = body.get("difficulty", "B2")
|
|
word_count = int(body.get("word_count") or 300)
|
|
passage_type = (body.get("passage_type") or body.get("type") or "academic").lower()
|
|
category = body.get("category", "")
|
|
exam_mode = body.get("exam_mode", "official")
|
|
course_id = body.get("course_id")
|
|
subject_id = body.get("subject_id")
|
|
entity_id = body.get("entity_id")
|
|
|
|
persona = self._persona_for("reading", exam_mode, passage_type)
|
|
persona_ctx = self._build_persona_context({**body, "module": "reading"})
|
|
cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")
|
|
|
|
rag_query = self._build_rag_query(body, fallback=topic)
|
|
material_context = self._get_material_context(
|
|
ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
|
|
)
|
|
|
|
system_parts = [persona]
|
|
if cefr:
|
|
system_parts.append("CEFR TARGET LEVEL\n" + cefr)
|
|
system_parts.append(
|
|
f"TASK\nWrite ONE {passage_type} reading passage of approximately {word_count} words "
|
|
f"(±10%). The passage must be authentic-feeling, coherent, and pitched exactly at "
|
|
f"CEFR {difficulty}. Sentence length, clause complexity, lexical range and cohesion "
|
|
f"markers must match the target band. Avoid regional slang. Do NOT mention CEFR, IELTS "
|
|
f"or grading inside the passage itself."
|
|
)
|
|
if category:
|
|
system_parts.append(f"DOMAIN / CATEGORY\nThe passage must be grounded in the domain: '{category}'.")
|
|
system_parts.append(
|
|
"OUTPUT CONTRACT — return ONLY this JSON:\n"
|
|
'{"title": "short catchy title (<=10 words)",'
|
|
' "passage": "the full passage text, use \\n\\n between paragraphs",'
|
|
' "paragraph_count": int,'
|
|
' "approx_word_count": int,'
|
|
' "key_vocabulary": [string, ...],'
|
|
' "rhetorical_structure": "e.g. problem-solution / compare-contrast / chronological",'
|
|
' "cefr_level": "' + difficulty + '"}'
|
|
)
|
|
|
|
messages = [{"role": "system", "content": "\n\n".join(system_parts)}]
|
|
if persona_ctx:
|
|
messages.append({"role": "system", "content": persona_ctx})
|
|
if material_context:
|
|
messages.append({"role": "system", "content": (
|
|
"REFERENCE MATERIAL — use these curated EnCoach resources to ground the "
|
|
"passage in what learners have been studying. Match terminology, examples and "
|
|
"scope; DO NOT copy verbatim:\n\n" + material_context
|
|
)})
|
|
messages.append({"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", "B2")
|
|
task_type = body.get("task_type", "essay")
|
|
exam_mode = body.get("exam_mode", "official")
|
|
exam_type = (body.get("passage_type") or body.get("type") or "academic").lower()
|
|
word_limit = int(body.get("word_limit") or (250 if task_type == "essay" else 150))
|
|
course_id = body.get("course_id")
|
|
subject_id = body.get("subject_id")
|
|
entity_id = body.get("entity_id")
|
|
rubric_name = body.get("rubric_name", "")
|
|
|
|
persona = self._persona_for("writing", exam_mode, exam_type)
|
|
persona_ctx = self._build_persona_context({**body, "module": "writing"})
|
|
cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")
|
|
|
|
rag_query = self._build_rag_query(body, fallback=topic)
|
|
material_context = self._get_material_context(
|
|
ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
|
|
)
|
|
|
|
sys = [persona]
|
|
if cefr:
|
|
sys.append("CEFR TARGET LEVEL\n" + cefr)
|
|
sys.append(
|
|
f"TASK\nWrite the STUDENT-FACING instructions for a '{task_type}' writing task at "
|
|
f"CEFR {difficulty}. The task must be completable in roughly {word_limit} words. "
|
|
"Use a neutral, authoritative exam register. Include any bullet-point sub-prompts a "
|
|
"student needs to address (e.g. 'explain', 'describe', 'suggest'). Do NOT write the "
|
|
"model answer — only the instructions."
|
|
)
|
|
if rubric_name:
|
|
sys.append(
|
|
f"RUBRIC AWARENESS\nThe student answer will be graded with the rubric "
|
|
f"'{rubric_name}'. Frame the instructions so that the task naturally elicits the "
|
|
"criteria that rubric evaluates (task response, coherence, lexical resource, "
|
|
"grammatical range)."
|
|
)
|
|
sys.append(
|
|
"OUTPUT CONTRACT — return ONLY this JSON:\n"
|
|
'{"instructions": "the full student-facing instructions (plain text, \\n for line breaks)",'
|
|
' "suggested_word_limit": int,'
|
|
' "evaluation_focus": [string, ...],'
|
|
' "cefr_level": "' + difficulty + '"}'
|
|
)
|
|
|
|
messages = [{"role": "system", "content": "\n\n".join(sys)}]
|
|
if persona_ctx:
|
|
messages.append({"role": "system", "content": persona_ctx})
|
|
if material_context:
|
|
messages.append({"role": "system", "content": (
|
|
"REFERENCE MATERIAL — ground the task in topics students have studied:\n\n"
|
|
+ material_context
|
|
)})
|
|
messages.append({"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")
|
|
exam_mode = body.get("exam_mode", "official")
|
|
exam_type = (body.get("passage_type") or body.get("type") or "academic").lower()
|
|
course_id = body.get("course_id")
|
|
subject_id = body.get("subject_id")
|
|
entity_id = body.get("entity_id")
|
|
|
|
topic_str = ", ".join(t for t in topics if t) if topics else (body.get("topic") or "general conversation")
|
|
|
|
persona = self._persona_for("speaking", exam_mode, exam_type)
|
|
persona_ctx = self._build_persona_context({**body, "module": "speaking", "topics": topics, "topic": topic_str})
|
|
cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")
|
|
|
|
rag_query = self._build_rag_query(body, fallback=topic_str)
|
|
material_context = self._get_material_context(
|
|
ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
|
|
)
|
|
|
|
part_guidance = {
|
|
"speaking_1": "Part 1 — Introduction & Interview: 4-6 short personal warm-up questions (15-30s answers).",
|
|
"speaking_2": "Part 2 — Individual Long Turn: ONE cue card with 3-4 bullet points; 1 min prep, 1-2 min monologue; 2-3 examiner follow-ups.",
|
|
"speaking_3": "Part 3 — Two-way Discussion: 4-6 abstract/analytical questions linked to Part 2 theme (45-60s answers).",
|
|
"interactive": "Interactive role-play: a realistic conversational scenario with turns labelled.",
|
|
}.get(part, "Mixed speaking prompts")
|
|
|
|
sys = [persona]
|
|
if cefr:
|
|
sys.append("CEFR TARGET LEVEL\n" + cefr)
|
|
sys.append("TASK\n" + part_guidance + (
|
|
f" Level-pitch the questions so a {difficulty} candidate is genuinely stretched "
|
|
"but not overwhelmed. Label every line as 'Examiner:' or 'Candidate:' (for examples) "
|
|
"or keep to 'Examiner:' only if you prefer. Use neutral British English register."
|
|
))
|
|
sys.append(
|
|
"OUTPUT CONTRACT — return ONLY this JSON:\n"
|
|
'{"script": "the full examiner script (plain text, use \\n for line breaks)",'
|
|
' "follow_up_questions": [string, ...],'
|
|
' "cue_card_bullets": [string, ...] // only for speaking_2, else [],'
|
|
' "cefr_level": "' + difficulty + '"}'
|
|
)
|
|
|
|
messages = [{"role": "system", "content": "\n\n".join(sys)}]
|
|
if persona_ctx:
|
|
messages.append({"role": "system", "content": persona_ctx})
|
|
if material_context:
|
|
messages.append({"role": "system", "content": (
|
|
"REFERENCE MATERIAL — anchor the prompts in themes the candidate has studied:\n\n"
|
|
+ material_context
|
|
)})
|
|
messages.append({"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")
|
|
difficulty = body.get("difficulty", "B1")
|
|
exam_mode = body.get("exam_mode", "official")
|
|
exam_type = (body.get("passage_type") or body.get("type") or "academic").lower()
|
|
course_id = body.get("course_id")
|
|
subject_id = body.get("subject_id")
|
|
entity_id = body.get("entity_id")
|
|
|
|
persona = self._persona_for("listening", exam_mode, exam_type)
|
|
persona_ctx = self._build_persona_context({**body, "module": "listening"})
|
|
cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")
|
|
|
|
rag_query = self._build_rag_query(body, fallback=topic)
|
|
material_context = self._get_material_context(
|
|
ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
|
|
)
|
|
|
|
section_guidance = {
|
|
"social_conversation": "two speakers in a day-to-day social context (2-4 min); include natural discourse markers, hesitations and back-channels.",
|
|
"social_monologue": "one speaker in a non-academic context, e.g. tour, announcement, instructions (2-3 min).",
|
|
"academic_conversation": "2-4 speakers in a university / study context (seminar, tutorial).",
|
|
"academic_lecture": "one lecturer delivering a ~4 min academic talk with clear signposting.",
|
|
}.get(section_type, f"a {section_type.replace('_', ' ')}")
|
|
|
|
sys = [persona]
|
|
if cefr:
|
|
sys.append("CEFR TARGET LEVEL\n" + cefr)
|
|
sys.append(
|
|
"TASK\nWrite a listening transcript for " + section_guidance +
|
|
f" Pitch vocabulary, speed implications and idiomaticity to CEFR {difficulty}. "
|
|
"Label EVERY turn with a speaker tag (e.g. 'Presenter:', 'Student A:', 'Tutor:'). "
|
|
"Include realistic features like fillers ('er', 'you know'), self-corrections, "
|
|
"and conversational overlap where appropriate — but stay intelligible."
|
|
)
|
|
sys.append(
|
|
"OUTPUT CONTRACT — return ONLY this JSON:\n"
|
|
'{"context": "the full labelled transcript",'
|
|
' "speakers": [{"label": string, "description": string}],'
|
|
' "approx_duration_seconds": int,'
|
|
' "cefr_level": "' + difficulty + '"}'
|
|
)
|
|
|
|
messages = [{"role": "system", "content": "\n\n".join(sys)}]
|
|
if persona_ctx:
|
|
messages.append({"role": "system", "content": persona_ctx})
|
|
if material_context:
|
|
messages.append({"role": "system", "content": (
|
|
"REFERENCE MATERIAL — weave in concepts students have encountered:\n\n"
|
|
+ material_context
|
|
)})
|
|
messages.append({"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", [])
|
|
type_counts = body.get("type_counts", {})
|
|
type_instructions = body.get("type_instructions", {})
|
|
type_difficulties = body.get("type_difficulties", {}) or {}
|
|
default_count = body.get("count_per_type", 5)
|
|
difficulty = body.get("difficulty", "B2")
|
|
exam_mode = body.get("exam_mode", "official")
|
|
exam_type = (body.get("passage_type") or body.get("type") or "academic").lower()
|
|
course_id = body.get("course_id")
|
|
subject_id = body.get("subject_id")
|
|
entity_id = body.get("entity_id")
|
|
|
|
persona = self._persona_for(module if module in ("reading", "listening") else "reading",
|
|
exam_mode, exam_type)
|
|
persona_ctx = self._build_persona_context({**body, "module": module})
|
|
cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")
|
|
|
|
rag_query = self._build_rag_query(body, fallback=passage_text[:200])
|
|
material_context = self._get_material_context(
|
|
ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
|
|
)
|
|
|
|
type_specs = []
|
|
total = 0
|
|
level_set = set()
|
|
for et in exercise_types:
|
|
c = int(type_counts.get(et, default_count))
|
|
instr = type_instructions.get(et, "")
|
|
lvl = type_difficulties.get(et) or difficulty
|
|
level_set.add(lvl)
|
|
spec_line = f"- EXACTLY {c} questions of type \"{et}\" pitched at CEFR {lvl}"
|
|
if instr:
|
|
spec_line += f"\n Student instructions: \"{instr}\""
|
|
type_specs.append(spec_line)
|
|
total += c
|
|
spec_str = "\n".join(type_specs) if type_specs else f"- {default_count} multiple choice questions at CEFR {difficulty}"
|
|
|
|
extra_cefr_block = ""
|
|
if len(level_set) > 1:
|
|
extra_cefr_block = "PER-TYPE CEFR LEVELS\n" + "\n".join(
|
|
f"- {lvl}: {self._CEFR_DESCRIPTORS.get(lvl, '')}" for lvl in sorted(level_set)
|
|
)
|
|
|
|
sys = [persona]
|
|
if cefr:
|
|
sys.append("CEFR TARGET LEVEL\n" + cefr)
|
|
if extra_cefr_block:
|
|
sys.append(extra_cefr_block)
|
|
# How many items should require inferential/higher-order thinking
|
|
higher_order_target = 0
|
|
if difficulty in ("B2", "C1", "C2"):
|
|
higher_order_target = max(1, total // 3)
|
|
sys.append(
|
|
f"TASK\nWrite EXACTLY {total} exam questions based strictly on the source text below. "
|
|
"Every question MUST be answerable from that text alone — no outside knowledge. "
|
|
"Distractors for MCQs must be plausible, drawn from or consistent with the text, and of "
|
|
"roughly equal length and grammatical form. Do not repeat stems. Vary cognitive load: "
|
|
"include both literal-retrieval and inferential items."
|
|
+ (f" At least {higher_order_target} of the {total} items MUST require inference, "
|
|
"implication or synthesis rather than literal lookup." if higher_order_target else "")
|
|
+ f"\n\nREQUIRED BREAKDOWN (respect counts and per-type CEFR exactly):\n{spec_str}"
|
|
)
|
|
sys.append(
|
|
"TYPE RULES\n"
|
|
"1. mcq: 4 options, exactly one correct, 'options' is an array of strings, "
|
|
"'correct_answer' is the exact string of the correct option.\n"
|
|
"2. true_false: options = ['TRUE','FALSE','NOT GIVEN']; answer is one of those.\n"
|
|
"3. fill_blanks / write_blanks: put '___' inside 'prompt' where the blank is, "
|
|
"'correct_answer' is the filler word(s) taken verbatim from the text, 'options' = [].\n"
|
|
"4. paragraph_match / matching_headings: 'prompt' is the statement/heading, "
|
|
"'options' is the list of paragraph labels, 'correct_answer' is the correct label.\n"
|
|
"5. short_answer / summary_completion: 'options' = [], 'correct_answer' is the expected answer.\n"
|
|
"6. Every question MUST include 'source_paragraph' (1-indexed number of the paragraph "
|
|
"the answer is drawn from) and a non-empty 'explanation' that quotes or paraphrases "
|
|
"the supporting span from that paragraph."
|
|
)
|
|
sys.append(
|
|
"OUTPUT CONTRACT — return ONLY this JSON:\n"
|
|
'{"questions": [{"type": string, "instructions": string, "prompt": string, '
|
|
'"options": [string], "correct_answer": string, "explanation": string, '
|
|
'"source_paragraph": int, "cognitive_level": "literal"|"inferential"|"evaluative", '
|
|
'"marks": number, "cefr_level": string}]}'
|
|
)
|
|
|
|
messages = [{"role": "system", "content": "\n\n".join(sys)}]
|
|
if persona_ctx:
|
|
messages.append({"role": "system", "content": persona_ctx})
|
|
if material_context:
|
|
messages.append({"role": "system", "content": (
|
|
"REFERENCE MATERIAL — keep terminology and scope consistent with curriculum:\n\n"
|
|
+ material_context
|
|
)})
|
|
messages.append({
|
|
"role": "user",
|
|
"content": "SOURCE TEXT (authoritative — every answer must come from here):\n\n"
|
|
+ (passage_text[:3000] or "(no source text provided)")
|
|
})
|
|
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="public", 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()
|
|
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)
|
|
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:
|
|
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_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)
|
|
|
|
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 = []
|
|
|
|
def _q_difficulty_for(ex):
|
|
# Honor per-question CEFR if the AI emitted one (per-type difficulty).
|
|
ex_cefr = (ex.get("cefr_level") or "").strip().upper()
|
|
return CEFR_TO_DIFFICULTY.get(ex_cefr, q_difficulty) if ex_cefr else q_difficulty
|
|
|
|
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", "") or "",
|
|
"marks": float(ex.get("marks", 1)),
|
|
"difficulty": _q_difficulty_for(ex),
|
|
"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", "") or "",
|
|
"marks": float(ex.get("marks", 1)),
|
|
"difficulty": _q_difficulty_for(ex),
|
|
"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,
|
|
})
|
|
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")
|
|
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="public", 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="public", 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
|
|
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="public", 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="public", 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)})
|