- 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
212 lines
7.7 KiB
Python
212 lines
7.7 KiB
Python
"""ELAI avatar video generation service."""
|
|
|
|
import logging
|
|
import time
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import requests as _requests
|
|
except ImportError:
|
|
_requests = None
|
|
|
|
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."""
|
|
|
|
def __init__(self, env):
|
|
self.env = env
|
|
self._get_param = env["ir.config_parameter"].sudo().get_param
|
|
|
|
def _get_token(self):
|
|
token = self._get_param("encoach_ai.elai_token", "")
|
|
if not token:
|
|
import os
|
|
token = os.environ.get("ELAI_TOKEN", "")
|
|
if not token:
|
|
raise RuntimeError("ELAI token not configured — set in AI Settings")
|
|
return token
|
|
|
|
def _headers(self):
|
|
return {
|
|
"Authorization": f"Bearer {self._get_token()}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
def _log(self, action, latency, status="success", error=None):
|
|
try:
|
|
self.env["encoach.ai.log"].sudo().create({
|
|
"service": "elai",
|
|
"action": action,
|
|
"latency_ms": latency,
|
|
"status": status,
|
|
"error_message": error,
|
|
})
|
|
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:
|
|
raise RuntimeError("requests package not installed")
|
|
resp = _requests.get(f"{ELAI_BASE}/avatars", headers=self._headers(), timeout=15)
|
|
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": slides,
|
|
"tags": ["encoach"],
|
|
}
|
|
t0 = time.time()
|
|
try:
|
|
resp = _requests.post(
|
|
f"{ELAI_BASE}/videos",
|
|
json=payload,
|
|
headers=self._headers(),
|
|
timeout=30,
|
|
)
|
|
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": 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
|
|
|
|
def get_video_status(self, video_id):
|
|
"""Check video generation status."""
|
|
if not _requests:
|
|
raise RuntimeError("requests package not installed")
|
|
resp = _requests.get(
|
|
f"{ELAI_BASE}/videos/{video_id}",
|
|
headers=self._headers(),
|
|
timeout=15,
|
|
)
|
|
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": video_url,
|
|
"video_url": video_url,
|
|
"duration": data.get("duration"),
|
|
}
|