Generation Page (complete rebuild): - Full production-parity exam generation wizard with 4 IELTS modules - Reading: AI passage gen, 5 exercise types (MCQ, Fill, Write, T/F, Match) - Listening: 4 section types, AI context gen, TTS audio gen (ElevenLabs) - Writing: Task 1/2, AI instruction gen, word limits, marks - Speaking: 3 parts, AI script gen, avatar video gen (7 avatars) - Per-module config: timer, CEFR difficulty, access, approval, rubrics - Exam submission workflow (draft/published) Exam Structures: - New encoach.exam.structure model + CRUD controller - ExamStructuresPage wired to real API AI Module (encoach_ai): - OpenAI service, ElevenLabs TTS, AWS Polly, ELAI avatars - AI settings model with Odoo config parameters - 7 generation endpoints (passage, exercises, instructions, scripts, context) Vector Module (encoach_vector): - pgvector integration for RAG-based content search - Embedding service with sentence-transformers Exam Session Fixes: - Fixed ExamSession.tsx field mapping (question_type→type, exam_title→title) - Fixed submit payload to include attempt_id and answers - Fixed normalizeType to handle null/undefined Tested: 12/12 API tests passed, browser-verified with real OpenAI calls Made-with: Cursor
109 lines
3.3 KiB
Python
109 lines
3.3 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"
|
|
|
|
|
|
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",
|
|
}
|
|
|
|
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 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()
|
|
|
|
def create_video(self, script, *, avatar_id=None, title="EnCoach Video", language="en"):
|
|
"""Create an avatar video from a script.
|
|
|
|
Returns:
|
|
dict with 'video_id', 'status'
|
|
"""
|
|
if not _requests:
|
|
raise RuntimeError("requests package not installed")
|
|
payload = {
|
|
"name": title,
|
|
"slides": [
|
|
{
|
|
"speech": script,
|
|
"avatar": avatar_id or "default",
|
|
"language": language,
|
|
}
|
|
],
|
|
}
|
|
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()
|
|
self._log("create_video", int((time.time() - t0) * 1000))
|
|
return {"video_id": data.get("_id", data.get("id")), "status": data.get("status", "pending")}
|
|
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()
|
|
return {
|
|
"video_id": video_id,
|
|
"status": data.get("status", "unknown"),
|
|
"url": data.get("url", ""),
|
|
"duration": data.get("duration"),
|
|
}
|