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:
Yamen Ahmad
2026-04-19 03:13:23 +04:00
parent 74d83af57f
commit 6ec68160c8
59 changed files with 9076 additions and 143 deletions

View File

@@ -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: