feat: institutional + support + training admin sections (backend + frontend)
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
This commit is contained in:
@@ -27,7 +27,7 @@ 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)
|
||||
@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", "")
|
||||
@@ -43,7 +43,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@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")
|
||||
@@ -60,7 +60,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/ai/insights", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def ai_insights(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -76,7 +76,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@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
|
||||
@@ -92,7 +92,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/ai/report-narrative", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def ai_report_narrative(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -107,7 +107,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/ai/batch-optimize", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def ai_batch_optimize(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -122,7 +122,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/ai/grade-suggest", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def ai_grade_suggest(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -146,7 +146,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/ai/generate-resource", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def ai_generate_resource(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -162,7 +162,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/ai/detect", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def ai_detect(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -174,7 +174,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/plagiarism/check", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def plagiarism_check(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -187,7 +187,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@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:
|
||||
@@ -206,7 +206,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/learning-plan/generate", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def learning_plan_generate(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -227,7 +227,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/workbench/generate-outline", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def workbench_outline(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -244,7 +244,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/workbench/generate-chapter", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def workbench_chapter(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -262,7 +262,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/workbench/generate-rubric", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def workbench_rubric(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -280,11 +280,11 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@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="user", methods=["POST"], csrf=False)
|
||||
@http.route("/api/workbench/publish", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def workbench_publish(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -315,7 +315,7 @@ class AIController(http.Controller):
|
||||
return _json_response({"status": "error", "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ──
|
||||
@http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@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()
|
||||
@@ -450,7 +450,7 @@ class AIController(http.Controller):
|
||||
]
|
||||
|
||||
# ── Exam generation — GenerationPage.tsx ──
|
||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@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()
|
||||
@@ -506,60 +506,348 @@ class AIController(http.Controller):
|
||||
_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 = 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}"},
|
||||
]
|
||||
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", "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}"},
|
||||
]
|
||||
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")
|
||||
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}"},
|
||||
]
|
||||
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")
|
||||
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}"},
|
||||
]
|
||||
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):
|
||||
@@ -567,42 +855,100 @@ class AIController(http.Controller):
|
||||
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, "")
|
||||
spec_line = f"- EXACTLY {c} questions of type \"{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"
|
||||
spec_str = "\n".join(type_specs) if type_specs else f"- {default_count} multiple choice questions at CEFR {difficulty}"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"You are an exam question generator. Generate EXACTLY {total} exercises "
|
||||
f"at CEFR {difficulty} level based on the passage below.\n\n"
|
||||
f"REQUIRED question breakdown (you MUST follow these counts exactly):\n"
|
||||
f"{spec_str}\n\n"
|
||||
"CRITICAL RULES:\n"
|
||||
f"1. The total number of questions in your response MUST be exactly {total}.\n"
|
||||
"2. Each question MUST have a 'type' field set to one of the requested types.\n"
|
||||
"3. Each question MUST include an 'instructions' field with the student-facing instructions "
|
||||
"for that section (use the provided instructions, or write appropriate ones).\n"
|
||||
"4. For mcq/true_false types: include 'options' array and 'correct_answer'.\n"
|
||||
"5. For fill_blanks/write_blanks types: use '___' in the prompt for blanks, "
|
||||
"set correct_answer to the missing word(s), options can be empty.\n"
|
||||
"6. For paragraph_match: prompt describes what to match, options are paragraph labels.\n\n"
|
||||
"Return JSON:\n"
|
||||
'{"questions": [{"type": string, "instructions": string, "prompt": string, '
|
||||
'"options": [string], "correct_answer": string, "explanation": string, "marks": number}]}'
|
||||
)},
|
||||
{"role": "user", "content": passage_text[:3000]},
|
||||
]
|
||||
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) ──
|
||||
@@ -906,7 +1252,7 @@ class AIController(http.Controller):
|
||||
return {"questions": questions}
|
||||
|
||||
# ── POST /api/exam/generation/submit — create exam from generation page ──
|
||||
@http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@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()
|
||||
@@ -1023,6 +1369,11 @@ class AIController(http.Controller):
|
||||
|
||||
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"):
|
||||
@@ -1036,9 +1387,9 @@ class AIController(http.Controller):
|
||||
"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", ""),
|
||||
"correct_answer": ex.get("correct_answer", "") or "",
|
||||
"marks": float(ex.get("marks", 1)),
|
||||
"difficulty": q_difficulty,
|
||||
"difficulty": _q_difficulty_for(ex),
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
@@ -1057,9 +1408,9 @@ class AIController(http.Controller):
|
||||
"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", ""),
|
||||
"correct_answer": ex.get("correct_answer", "") or "",
|
||||
"marks": float(ex.get("marks", 1)),
|
||||
"difficulty": q_difficulty,
|
||||
"difficulty": _q_difficulty_for(ex),
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
@@ -1115,7 +1466,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@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", [])
|
||||
@@ -1130,7 +1481,7 @@ class AIController(http.Controller):
|
||||
return _json_response({"applied": 0, "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
|
||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@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()
|
||||
@@ -1170,7 +1521,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@http.route("/api/workbench/suggest-materials", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
def workbench_suggest_materials(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -1190,7 +1541,7 @@ class AIController(http.Controller):
|
||||
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)
|
||||
@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:
|
||||
|
||||
Reference in New Issue
Block a user