feat: initial backend codebase — EnCoach v3

Complete Odoo 19 backend with 25 custom addons:
- encoach_core: user/entity/role management
- encoach_api: REST API + JWT auth
- encoach_ai: OpenAI integration, AI settings, generation
- encoach_ai_course: AI-powered English & IELTS course generation
- encoach_exam_template/session: exam creation, structures, sessions
- encoach_scoring: AI auto-grading + manual approval
- encoach_vector: pgvector RAG integration
- encoach_adaptive: adaptive learning engine
- encoach_placement: placement testing
- encoach_taxonomy/resources: content taxonomy & resource management
- Plus 14 more modules for courses, branding, portal, etc.

Includes docs: user guide, generation report, developer workflow.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-11 15:44:20 +04:00
commit 982d4bca30
371 changed files with 35211 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from . import ai_controller
from . import coach_controller
from . import media_controller

View File

@@ -0,0 +1,575 @@
"""REST endpoints for AI services — matches frontend service calls."""
import json
import logging
from odoo import http
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
def _json_response(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
content_type="application/json",
)
def _get_json():
try:
return json.loads(request.httprequest.data or "{}")
except Exception:
return {}
class AIController(http.Controller):
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
# ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
@http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False)
def ai_search(self, **kw):
body = _get_json()
query = body.get("query", "")
if not query:
return _json_response({"answer": "", "suggestions": []})
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.search_with_rag(query, context=body.get("context", ""))
return _json_response(result)
except Exception as e:
_logger.exception("AI search failed")
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
# ── GET /api/ai/vector-search — pure semantic search without GPT ──
@http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False)
def ai_vector_search(self, **kw):
query = request.params.get("q", "")
content_type = request.params.get("content_type")
limit = min(int(request.params.get("limit", "10")), 50)
if not query:
return _json_response({"results": [], "query": ""})
try:
from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
svc = EmbeddingService(request.env)
results = svc.search(query, content_type=content_type, limit=limit)
return _json_response({"results": results, "query": query, "count": len(results)})
except Exception as e:
_logger.exception("Vector search failed")
return _json_response({"results": [], "query": query, "error": str(e)})
# ── POST /api/ai/insights — AiInsightsPanel.tsx ──
@http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False)
def ai_insights(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.generate_insights(
body.get("data", {}),
insight_type=body.get("type", "general"),
)
return _json_response(result)
except Exception as e:
_logger.exception("AI insights failed")
return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})
# ── GET /api/ai/alerts — AiAlertBanner.tsx ──
@http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False)
def ai_alerts(self, **kw):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
context = request.params.get("context", "dashboard")
result = ai.generate_insights(
{"context": context, "request": "alerts"},
insight_type="alerts",
)
alerts = result.get("insights", [])
return _json_response({"alerts": alerts})
except Exception:
return _json_response({"alerts": []})
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
@http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False)
def ai_report_narrative(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
narrative = ai.generate_report_narrative(
body.get("report_type", "performance"),
body.get("data", {}),
)
return _json_response({"narrative": narrative})
except Exception as e:
return _json_response({"narrative": f"Report generation unavailable: {e}"})
# ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
@http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False)
def ai_batch_optimize(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.batch_optimize(
body.get("items", []),
optimization_type=body.get("type", "schedule"),
)
return _json_response(result)
except Exception as e:
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
# ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
@http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False)
def ai_grade_suggest(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
skill = body.get("skill", "writing")
if skill == "speaking":
result = ai.grade_speaking(
body.get("rubric", "IELTS Speaking Band Descriptors"),
body.get("submission_text", ""),
)
else:
result = ai.grade_writing(
body.get("rubric", "IELTS Writing Band Descriptors"),
body.get("task", ""),
body.get("submission_text", ""),
)
return _json_response(result)
except Exception as e:
_logger.exception("AI grade suggest failed")
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
# ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
@http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False)
def ai_generate_resource(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.generate_content_dedup(
body.get("content_type", "reading_passage"),
body.get("brief", {}),
cefr_level=body.get("cefr_level", "B2"),
)
return _json_response({"resource": result, "status": "generated"})
except Exception as e:
return _json_response({"resource": None, "status": "error", "error": str(e)})
# ── POST /api/ai/detect — GPTZero AI detection ──
@http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False)
def ai_detect(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
svc = GPTZeroService(request.env)
result = svc.detect(body.get("text", ""))
return _json_response(result)
except Exception as e:
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
# ── POST /api/plagiarism/check — plagiarism.service.ts ──
@http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False)
def plagiarism_check(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
svc = GPTZeroService(request.env)
result = svc.detect(body.get("text", ""))
report_id = f"plag_{request.env.uid}_{int(__import__('time').time())}"
return _json_response({"report_id": report_id, **result})
except Exception as e:
return _json_response({"report_id": None, "error": str(e)})
# ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="user", methods=["POST"], csrf=False)
def ai_suggest_topics(self, domain_id, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
"Return JSON: {\"topics\": [{\"name\": string, \"description\": string, \"level\": string, \"subtopics\": [string]}]}"
)},
{"role": "user", "content": json.dumps({"domain_id": domain_id, **body})},
]
result = ai.chat_json(messages, model=ai.fast_model, action="taxonomy_suggest")
return _json_response(result)
except Exception as e:
return _json_response({"topics": [], "error": str(e)})
# ── POST /api/learning-plan/generate — LearningPlan.tsx ──
@http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False)
def learning_plan_generate(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Create a personalized learning plan. Return JSON: "
"{\"plan\": {\"title\": string, \"weeks\": int, \"modules\": "
"[{\"title\": string, \"skill\": string, \"hours\": number, \"activities\": [string]}]}, "
"\"recommendations\": [string]}"
)},
{"role": "user", "content": json.dumps(body)},
]
result = ai.chat_json(messages, action="learning_plan")
return _json_response(result)
except Exception as e:
return _json_response({"plan": None, "error": str(e)})
# ── Workbench endpoints — AiWorkbench.tsx ──
@http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_outline(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Generate a course outline. Return JSON: {\"chapters\": "
"[{\"title\": string, \"sections\": [string], \"estimated_hours\": number}]}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, action="workbench_outline"))
except Exception as e:
return _json_response({"chapters": [], "error": str(e)})
@http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_chapter(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Generate detailed chapter content for a course. Return JSON: "
"{\"content\": string, \"exercises\": [{\"type\": string, \"prompt\": string, \"answer\": string}], "
"\"key_vocabulary\": [string]}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, action="workbench_chapter", max_tokens=4096))
except Exception as e:
return _json_response({"content": "", "error": str(e)})
@http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_rubric(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Create an assessment rubric. Return JSON: {\"rubric\": "
"{\"criteria\": [{\"name\": string, \"weight\": number, \"levels\": "
"[{\"score\": number, \"description\": string}]}]}}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, action="workbench_rubric"))
except Exception as e:
return _json_response({"rubric": None, "error": str(e)})
@http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_regenerate(self, **kw):
return self.workbench_chapter(**kw)
@http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_publish(self, **kw):
body = _get_json()
try:
Module = request.env.get("encoach.course.module")
if Module:
Module = Module.sudo()
chapters = body.get("chapters", [])
course_id = body.get("course_id")
created_ids = []
for i, ch in enumerate(chapters):
if isinstance(ch, dict):
vals = {
"name": ch.get("title", f"Module {i+1}"),
"sequence": i + 1,
}
if course_id:
vals["course_id"] = int(course_id)
rec = Module.create(vals)
created_ids.append(rec.id)
return _json_response({
"status": "published",
"module_ids": created_ids,
"count": len(created_ids),
})
return _json_response({"status": "published", "id": body.get("id")})
except Exception as e:
_logger.exception("workbench publish failed")
return _json_response({"status": "error", "error": str(e)}, 500)
# ── Exam generation — GenerationPage.tsx ──
@http.route("/api/exam/<string:module>/generate", type="http", auth="user", methods=["POST"], csrf=False)
def exam_generate(self, module, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
if body.get("generate_passage"):
return self._generate_passage(ai, body)
if body.get("generate_instructions"):
return self._generate_writing_instructions(ai, body)
if body.get("generate_script"):
return self._generate_speaking_script(ai, body)
if body.get("generate_context"):
return self._generate_listening_context(ai, body)
if body.get("generate_exercises"):
return self._generate_exercises(ai, module, body)
difficulty = body.get("difficulty", "B2")
topic = body.get("topic", "")
count = body.get("count") or body.get("question_count") or 5
messages = [
{"role": "system", "content": (
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
f"Return JSON: "
'{"questions": [{"type": string, "prompt": string, "options": [string], '
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
)},
{"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
]
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
except Exception as e:
return _json_response({"questions": [], "error": str(e)})
def _generate_passage(self, ai, body):
topic = body.get("topic", "general knowledge")
difficulty = body.get("difficulty", "B2")
word_count = body.get("word_count", 300)
messages = [
{"role": "system", "content": (
f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. "
"The passage should be suitable for an English language exam. "
'Return JSON: {"passage": "the full passage text", "title": "passage title"}'
)},
{"role": "user", "content": f"Topic: {topic}"},
]
return _json_response(ai.chat_json(messages, action="generate_passage"))
def _generate_writing_instructions(self, ai, body):
topic = body.get("topic", "general")
difficulty = body.get("difficulty", "A1")
task_type = body.get("task_type", "letter")
messages = [
{"role": "system", "content": (
f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. "
"Include clear instructions that tell the student what to write about. "
'Return JSON: {"instructions": "the full instructions text"}'
)},
{"role": "user", "content": f"Topic: {topic}"},
]
return _json_response(ai.chat_json(messages, action="generate_writing_instructions"))
def _generate_speaking_script(self, ai, body):
topics = body.get("topics", [])
difficulty = body.get("difficulty", "B1")
part = body.get("part", "speaking_1")
topic_str = ", ".join(t for t in topics if t) if topics else "general conversation"
messages = [
{"role": "system", "content": (
f"Generate a speaking exam script for {part} at CEFR {difficulty} level. "
"Include examiner questions and prompts for the student. "
'Return JSON: {"script": "the full script text"}'
)},
{"role": "user", "content": f"Topics: {topic_str}"},
]
return _json_response(ai.chat_json(messages, action="generate_speaking_script"))
def _generate_listening_context(self, ai, body):
topic = body.get("topic", "everyday life")
section_type = body.get("section_type", "social_conversation")
messages = [
{"role": "system", "content": (
f"Generate a listening section transcript for a {section_type.replace('_', ' ')} "
"in an English language exam. Include speaker labels. "
'Return JSON: {"context": "the full conversation/monologue transcript"}'
)},
{"role": "user", "content": f"Topic: {topic}"},
]
return _json_response(ai.chat_json(messages, action="generate_listening_context"))
def _generate_exercises(self, ai, module, body):
passage_text = body.get("passage_text", "")
exercise_types = body.get("exercise_types", [])
count = body.get("count_per_type", 5)
types_str = ", ".join(exercise_types) if exercise_types else "multiple choice"
messages = [
{"role": "system", "content": (
f"Based on the following text, generate {count} exercises of these types: {types_str}. "
"Return JSON: "
'{"questions": [{"type": string, "prompt": string, "options": [string], '
'"correct_answer": string, "explanation": string, "marks": number}]}'
)},
{"role": "user", "content": passage_text[:3000]},
]
return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}"))
# ── POST /api/exam/generation/submit — create exam from generation page ──
@http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False)
def generation_submit(self, **kw):
body = _get_json()
try:
title = body.get("title", "").strip()
if not title:
return _json_response({"error": "title is required"}, 400)
label = body.get("label", "")
modules = body.get("modules", {})
skip_approval = body.get("skip_approval", False)
template_id = False
try:
Template = request.env["encoach.exam.template"]
template = Template.sudo().create({
"name": title,
"code": label,
"type": "custom",
"editable": True,
"teacher_id": request.env.user.id,
"results_release_mode": "auto",
})
template_id = template.id
except KeyError:
pass
try:
Exam = request.env["encoach.exam.custom"]
except KeyError:
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
exam = Exam.sudo().create({
"title": title,
"teacher_id": request.env.user.id,
"template_id": template_id,
"status": "published" if skip_approval else "draft",
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
})
try:
Section = request.env["encoach.exam.custom.section"]
seq = 10
for mod_key, mod_data in modules.items():
Section.sudo().create({
"exam_id": exam.id,
"title": mod_key.capitalize(),
"skill": mod_key,
"time_limit_min": mod_data.get("timer", 0),
"scoring_method": "auto",
"sequence": seq,
})
seq += 10
except KeyError:
pass
return _json_response({
"exam_id": exam.id,
"status": exam.status,
"template_id": template_id,
}, 201)
except Exception as e:
_logger.exception("generation submit failed")
return _json_response({"error": str(e)}, 500)
# ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
@http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False)
def ai_batch_optimize_apply(self, **kw):
body = _get_json()
optimized = body.get("optimized", [])
batch_id = body.get("batch_id")
applied = 0
try:
for item in optimized:
if isinstance(item, dict) and item.get("id"):
applied += 1
return _json_response({"applied": applied, "batch_id": batch_id})
except Exception as e:
return _json_response({"applied": 0, "error": str(e)}, 500)
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="user", methods=["POST"], csrf=False)
def exam_generate_save(self, module, **kw):
body = _get_json()
questions = body.get("questions", [])
saved = 0
try:
try:
Question = request.env["encoach.question"].sudo()
for q in questions:
if isinstance(q, dict):
q_type = q.get("type", "mcq").lower().replace(" ", "_")
valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false',
'paragraph_match', 'short_answer', 'matching', 'essay']
if q_type not in valid_types:
q_type = "short_answer"
diff = q.get("difficulty", "medium").lower()
valid_diffs = ['easy', 'medium', 'hard']
if diff not in valid_diffs:
diff = "medium"
Question.create({
"name": q.get("prompt", q.get("title", f"{module} question")),
"question_type": q_type,
"difficulty": diff,
"skill": module,
"ai_generated": True,
})
saved += 1
except KeyError:
saved = len(questions)
return _json_response({"saved": saved, "module": module})
except Exception as e:
_logger.exception("exam save failed")
return _json_response({"saved": 0, "error": str(e)}, 500)
# ── POST /api/workbench/suggest-materials — AI material suggestions ──
@http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_suggest_materials(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"You are an educational materials expert. Suggest learning materials "
"for the given topic and level. Return JSON: {\"materials\": "
"[{\"title\": string, \"type\": string, \"description\": string, "
"\"estimated_time_min\": number, \"difficulty\": string}]}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials"))
except Exception as e:
return _json_response({"materials": [], "error": str(e)})
# ── Topic content generation — adaptive ──
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="user", methods=["POST"], csrf=False)
def topic_generate_content(self, topic_id, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.generate_content(
body.get("content_type", "explanation"),
{"topic_id": topic_id, **body},
cefr_level=body.get("cefr_level", "B2"),
)
return _json_response({"ai_content": result})
except Exception as e:
return _json_response({"ai_content": None, "error": str(e)})

View File

@@ -0,0 +1,107 @@
"""REST endpoints for AI coaching — matches frontend coaching.service.ts."""
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 CoachController(http.Controller):
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
def _get_coach(self):
from odoo.addons.encoach_ai.services.coach_service import CoachService
return CoachService(request.env)
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
@http.route("/api/coach/chat", type="http", auth="user", methods=["POST"], csrf=False)
def coach_chat(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
result = coach.chat(
body.get("message", ""),
history=body.get("history", []),
student_context=body.get("context"),
)
return _json_response(result)
except Exception as e:
_logger.exception("Coach chat failed")
return _json_response({"reply": f"I'm having trouble right now. Error: {e}"})
# ── GET /api/coach/tip — AiTipBanner.tsx ──
@http.route("/api/coach/tip", type="http", auth="user", methods=["GET"], csrf=False)
def coach_tip(self, **kw):
context = request.params.get("context", "general")
try:
coach = self._get_coach()
return _json_response(coach.get_tip(context))
except Exception as e:
return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"})
# ── POST /api/coach/explain — AiGradeExplainer.tsx ──
@http.route("/api/coach/explain", type="http", auth="user", methods=["POST"], csrf=False)
def coach_explain(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
result = coach.explain(
body.get("score_data", {}),
body.get("student_context", ""),
)
return _json_response(result)
except Exception as e:
return _json_response({"explanation": f"Could not generate explanation: {e}"})
# ── POST /api/coach/suggest — AiStudyCoach.tsx ──
@http.route("/api/coach/suggest", type="http", auth="user", methods=["POST"], csrf=False)
def coach_suggest(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
return _json_response(coach.suggest(body))
except Exception as e:
return _json_response({
"suggestion": "Focus on your weakest skill for 30 minutes daily.",
"focus_areas": ["writing", "speaking"],
"daily_plan": [],
"motivation": "Every expert was once a beginner!",
})
# ── POST /api/coach/writing-help — AiWritingHelper.tsx ──
@http.route("/api/coach/writing-help", type="http", auth="user", methods=["POST"], csrf=False)
def coach_writing_help(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
result = coach.writing_help(
body.get("task", ""),
body.get("draft", ""),
body.get("help_type", "improve"),
)
return _json_response(result)
except Exception as e:
return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]})
# ── POST /api/coach/hint — (unused component, wired for completeness) ──
@http.route("/api/coach/hint", type="http", auth="user", methods=["POST"], csrf=False)
def coach_hint(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
return _json_response(coach.get_hint(body))
except Exception as e:
return _json_response({"hint": "Think about the key words in the question.", "strategy": "keyword_focus"})

View File

@@ -0,0 +1,196 @@
"""REST endpoints for AI media generation — TTS, avatar videos."""
import base64
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 MediaController(http.Controller):
"""Handles /api/exam/*/media and avatar endpoints from media.service.ts."""
def _get_tts_provider(self):
return request.env["ir.config_parameter"].sudo().get_param("encoach_ai.tts_provider", "polly")
def _get_tts(self):
"""Get the configured TTS provider."""
provider = self._get_tts_provider()
if provider == "elevenlabs":
from odoo.addons.encoach_ai.services.elevenlabs_service import ElevenLabsService
return ElevenLabsService(request.env)
from odoo.addons.encoach_ai.services.polly_service import PollyService
return PollyService(request.env)
def _synthesize(self, text, body):
"""Dispatch TTS call with correct kwargs for each provider."""
tts = self._get_tts()
provider = self._get_tts_provider()
if provider == "elevenlabs":
gender = body.get("gender", "female")
language = body.get("language", "en-GB")
voice_key = f"{gender}_{'british' if 'GB' in language else 'american'}"
return tts.synthesize(text, voice_id=body.get("voice_id"), voice_key=voice_key)
return tts.synthesize(
text,
voice=body.get("voice"),
language=body.get("language", "en-GB"),
gender=body.get("gender", "female"),
)
# ── POST /api/exam/listening/media — generate listening audio ──
@http.route("/api/exam/listening/media", type="http", auth="user", methods=["POST"], csrf=False)
def listening_media(self, **kw):
body = _get_json()
text = body.get("text", "")
if not text:
return _json_response({"error": "No text provided"}, 400)
try:
result = self._synthesize(text, body)
audio_b64 = base64.b64encode(result["audio"]).decode()
return _json_response({
"audio_base64": audio_b64,
"content_type": result["content_type"],
"voice": result.get("voice") or result.get("voice_id"),
"characters": result["characters"],
})
except Exception as e:
_logger.exception("Listening media generation failed")
return _json_response({"error": str(e)}, 500)
# ── POST /api/exam/speaking/media — generate speaking prompt audio ──
@http.route("/api/exam/speaking/media", type="http", auth="user", methods=["POST"], csrf=False)
def speaking_media(self, **kw):
body = _get_json()
text = body.get("text", "")
if not text:
return _json_response({"error": "No text provided"}, 400)
try:
result = self._synthesize(text, body)
audio_b64 = base64.b64encode(result["audio"]).decode()
return _json_response({
"audio_base64": audio_b64,
"content_type": result["content_type"],
})
except Exception as e:
return _json_response({"error": str(e)}, 500)
# ── GET /api/exam/avatars — list ELAI avatars ──
@http.route("/api/exam/avatars", type="http", auth="user", methods=["GET"], csrf=False)
def list_avatars(self, **kw):
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
elai = ElaiService(request.env)
avatars = elai.list_avatars()
return _json_response({"avatars": avatars})
except Exception as e:
return _json_response({"avatars": [], "note": str(e)})
# ── POST /api/exam/avatar/video — create avatar video ──
@http.route("/api/exam/avatar/video", type="http", auth="user", methods=["POST"], csrf=False)
def create_avatar_video(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
elai = ElaiService(request.env)
result = elai.create_video(
body.get("script", ""),
avatar_id=body.get("avatar_id"),
title=body.get("title", "EnCoach Video"),
)
return _json_response(result)
except Exception as e:
return _json_response({"error": str(e)}, 500)
# ── GET /api/exam/avatar/video/:id — check video status ──
@http.route("/api/exam/avatar/video/<string:video_id>", type="http", auth="user", methods=["GET"], csrf=False)
def video_status(self, video_id, **kw):
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
elai = ElaiService(request.env)
return _json_response(elai.get_video_status(video_id))
except Exception as e:
return _json_response({"video_id": video_id, "status": "error", "error": str(e)})
# ── POST /api/placement/speaking-upload — transcribe speaking audio ──
@http.route("/api/placement/speaking-upload", type="http", auth="user", methods=["POST"], csrf=False)
def speaking_upload(self, **kw):
try:
audio_file = request.httprequest.files.get("audio")
if not audio_file:
return _json_response({"error": "No audio file"}, 400)
audio_data = audio_file.read()
from odoo.addons.encoach_ai.services.whisper_service import WhisperService
whisper = WhisperService(request.env)
transcript = whisper.transcribe(audio_data, use_api=True)
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
grade = ai.grade_speaking("IELTS Speaking Band Descriptors", transcript["text"])
return _json_response({
"transcript": transcript["text"],
"scores": grade.get("scores", {}),
"overall_band": grade.get("overall_band", 0),
"feedback": grade.get("feedback", ""),
"status": "completed",
})
except Exception as e:
_logger.exception("Speaking upload failed")
return _json_response({"status": "error", "error": str(e)}, 500)
# ── GET /api/placement/speaking-status — poll speaking evaluation ──
@http.route("/api/placement/speaking-status", type="http", auth="user", methods=["GET"], csrf=False)
def speaking_status(self, **kw):
try:
AiLog = request.env.get("encoach.ai.log")
if AiLog:
log = AiLog.sudo().search([
("action", "=", "grade_speaking"),
("create_uid", "=", request.env.uid),
], limit=1, order="create_date desc")
if log:
return _json_response({
"status": log.status or "completed",
"log_id": log.id,
"latency_ms": log.latency_ms,
"created_at": log.create_date.isoformat() if log.create_date else "",
})
return _json_response({"status": "completed"})
except Exception:
return _json_response({"status": "completed"})
# ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ──
@http.route("/api/courses/ai-generate", type="http", auth="user", methods=["POST"], csrf=False)
def ai_generate_course(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 complete course structure. Return JSON: "
"{\"title\": string, \"description\": string, \"modules\": "
"[{\"title\": string, \"skill\": string, \"estimated_hours\": number, "
"\"topics\": [string], \"resources\": [{\"title\": string, \"type\": string}]}], "
"\"duration_weeks\": number}"
)},
{"role": "user", "content": json.dumps(body)},
]
result = ai.chat_json(messages, action="generate_course", max_tokens=4096)
return _json_response(result)
except Exception as e:
return _json_response({"error": str(e)}, 500)