feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements

- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-12 14:26:39 +04:00
parent 6a62a43d61
commit ca91544acd
20 changed files with 682 additions and 75 deletions

View File

@@ -16,6 +16,9 @@
""",
"author": "EnCoach",
"depends": ["base", "encoach_core"],
"external_dependencies": {
"python": ["openai", "boto3"],
},
"data": [
"security/ir.model.access.csv",
"views/ai_settings_views.xml",

View File

@@ -347,7 +347,8 @@ class AIController(http.Controller):
]
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
except Exception as e:
return _json_response({"questions": [], "error": str(e)})
_logger.exception("exam_generate %s failed: %s", module, e)
return _json_response({"questions": [], "error": str(e)}, 500)
def _generate_passage(self, ai, body):
topic = body.get("topic", "general knowledge")

View File

@@ -125,54 +125,6 @@ class MediaController(http.Controller):
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):

View File

@@ -12,6 +12,14 @@ except ImportError:
ELAI_BASE = "https://apis.elai.io/api/v1"
AVATAR_PRESETS = {
"vadim": {"code": "vadim.casual", "gender": "male", "voice": "en-GB-RyanNeural"},
"gia": {"code": "gia.casual", "gender": "female", "voice": "en-US-AriaNeural"},
"zara": {"code": "zara.regular", "gender": "female", "voice": "en-US-JennyNeural"},
"mason": {"code": "mason.casual", "gender": "male", "voice": "en-US-GuyNeural"},
"default": {"code": "gia.casual", "gender": "female", "voice": "en-US-AriaNeural"},
}
class ElaiService:
"""Generate avatar videos for listening exercises and instructional content."""
@@ -33,6 +41,7 @@ class ElaiService:
return {
"Authorization": f"Bearer {self._get_token()}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def _log(self, action, latency, status="success", error=None):
@@ -47,6 +56,14 @@ class ElaiService:
except Exception:
pass
def _resolve_avatar(self, avatar_id):
if not avatar_id or avatar_id == "default":
return AVATAR_PRESETS["default"]
key = avatar_id.split(".")[0].lower() if "." in avatar_id else avatar_id.lower()
if key in AVATAR_PRESETS:
return AVATAR_PRESETS[key]
return {"code": avatar_id, "gender": "female", "voice": "en-US-AriaNeural"}
def list_avatars(self):
"""List available ELAI avatars."""
if not _requests:
@@ -55,23 +72,84 @@ class ElaiService:
resp.raise_for_status()
return resp.json()
@staticmethod
def _split_script(script, max_chars=500):
"""Split a long script into chunks that stay under ELAI's 60s/slide limit.
Uses ~10 chars/second heuristic, so 500 chars ~ 50s (with safety margin).
Splits on sentence boundaries ('. ', '? ', '! ', newlines).
"""
if len(script) <= max_chars:
return [script]
import re
sentences = re.split(r'(?<=[.!?])\s+|\n+', script)
chunks, current = [], ""
for sent in sentences:
if not sent.strip():
continue
if current and len(current) + len(sent) + 1 > max_chars:
chunks.append(current.strip())
current = sent
else:
current = f"{current} {sent}" if current else sent
if current.strip():
chunks.append(current.strip())
return chunks or [script]
def create_video(self, script, *, avatar_id=None, title="EnCoach Video", language="en"):
"""Create an avatar video from a script.
Automatically splits long scripts into multiple slides to stay under
ELAI's 60-second-per-slide limit.
Returns:
dict with 'video_id', 'status'
"""
if not _requests:
raise RuntimeError("requests package not installed")
avatar_preset = self._resolve_avatar(avatar_id)
avatar_code = avatar_preset["code"]
avatar_src = f"https://elai-avatars.s3.us-east-2.amazonaws.com/common/{avatar_code.replace('.', '/')}/{avatar_code.replace('.', '_')}.png"
chunks = self._split_script(script)
slides = []
for idx, chunk in enumerate(chunks, 1):
slides.append({
"id": idx,
"speech": chunk,
"language": "English",
"voice": avatar_preset["voice"],
"voiceType": "text",
"voiceProvider": "azure",
"avatar": {
"code": avatar_code,
"gender": avatar_preset["gender"],
},
"canvas": {
"version": "4.4.0",
"background": "#ffffff",
"objects": [
{
"type": "avatar",
"left": 151.5,
"top": 36,
"fill": "#4868FF",
"scaleX": 0.3,
"scaleY": 0.3,
"height": 1080,
"width": 1080,
"src": avatar_src,
}
],
},
})
_logger.info("ELAI: creating video with %d slide(s) from %d chars", len(slides), len(script))
payload = {
"name": title,
"slides": [
{
"speech": script,
"avatar": avatar_id or "default",
"language": language,
}
],
"slides": slides,
"tags": ["encoach"],
}
t0 = time.time()
try:
@@ -83,8 +161,31 @@ class ElaiService:
)
resp.raise_for_status()
data = resp.json()
video_id = data.get("_id", data.get("id"))
# Step 2: trigger rendering -- ELAI requires a separate render call
try:
render_resp = _requests.post(
f"{ELAI_BASE}/videos/render/{video_id}",
headers=self._headers(),
timeout=30,
)
render_resp.raise_for_status()
_logger.info("ELAI render triggered for video %s", video_id)
except Exception as render_err:
_logger.warning("ELAI render trigger failed for %s: %s (video created but not rendered)", video_id, render_err)
self._log("create_video", int((time.time() - t0) * 1000))
return {"video_id": data.get("_id", data.get("id")), "status": data.get("status", "pending")}
return {"video_id": video_id, "status": "rendering"}
except _requests.exceptions.HTTPError as exc:
body = ""
try:
body = exc.response.text[:500]
except Exception:
pass
_logger.error("ELAI create_video failed: %s%s", exc, body)
self._log("create_video", int((time.time() - t0) * 1000), "error", f"{exc} | {body}")
raise RuntimeError(f"ELAI API error: {exc.response.status_code}{body}") from exc
except Exception as exc:
self._log("create_video", int((time.time() - t0) * 1000), "error", str(exc))
raise
@@ -100,9 +201,11 @@ class ElaiService:
)
resp.raise_for_status()
data = resp.json()
video_url = data.get("url", "") or data.get("video_url", "")
return {
"video_id": video_id,
"status": data.get("status", "unknown"),
"url": data.get("url", ""),
"url": video_url,
"video_url": video_url,
"duration": data.get("duration"),
}