EnCoach Odoo 19 custom modules

Full backend implementation with custom Odoo modules:
- encoach_api: Core API, user management, JWT auth
- encoach_exam: Exam generation (reading, writing, listening, speaking)
- encoach_evaluate: AI-powered evaluation (writing, speaking)
- encoach_training: Training tips and walkthrough
- encoach_storage: File storage management
- encoach_payment: Stripe, PayPal, Paymob integration
- encoach_mail: Email notifications

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-14 16:46:46 +04:00
commit f5b627256f
168 changed files with 13428 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
from . import models
from . import services

View File

@@ -0,0 +1,17 @@
{
"name": "EnCoach AI Media",
"version": "19.0.1.0.0",
"category": "Education",
"summary": "TTS, STT, and video generation for EnCoach",
"description": "AWS Polly TTS, OpenAI Whisper STT, ELAI avatar video generation.",
"depends": ["encoach_ai"],
"data": [
"security/ir.model.access.csv",
"views/avatar_views.xml",
],
"installable": True,
"license": "LGPL-3",
"external_dependencies": {
"python": ["boto3"],
},
}

View File

@@ -0,0 +1 @@
from . import elai_avatar

View File

@@ -0,0 +1,30 @@
from odoo import models, fields
class EncoachElaiAvatar(models.Model):
_name = "encoach.elai.avatar"
_description = "ELAI Avatar Configuration"
name = fields.Char(required=True)
avatar_code = fields.Char(required=True)
avatar_url = fields.Char()
gender = fields.Selection(
[("male", "Male"), ("female", "Female")],
string="Gender",
)
canvas = fields.Char()
voice_id = fields.Char()
voice_provider = fields.Char()
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"name": self.name,
"avatarCode": self.avatar_code,
"avatarUrl": self.avatar_url or "",
"gender": self.gender or "",
"canvas": self.canvas or "",
"voiceId": self.voice_id or "",
"voiceProvider": self.voice_provider or "",
}

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_elai_avatar_student,encoach.elai.avatar.student,model_encoach_elai_avatar,encoach_core.group_encoach_student,1,0,0,0
access_encoach_elai_avatar_admin,encoach.elai.avatar.admin,model_encoach_elai_avatar,encoach_core.group_encoach_admin,1,1,1,1
access_encoach_elai_avatar_sysadmin,encoach.elai.avatar.sysadmin,model_encoach_elai_avatar,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_elai_avatar_student encoach.elai.avatar.student model_encoach_elai_avatar encoach_core.group_encoach_student 1 0 0 0
3 access_encoach_elai_avatar_admin encoach.elai.avatar.admin model_encoach_elai_avatar encoach_core.group_encoach_admin 1 1 1 1
4 access_encoach_elai_avatar_sysadmin encoach.elai.avatar.sysadmin model_encoach_elai_avatar base.group_system 1 1 1 1

View File

@@ -0,0 +1,3 @@
from . import polly_service
from . import whisper_service
from . import elai_service

View File

@@ -0,0 +1,107 @@
import logging
import httpx
_logger = logging.getLogger(__name__)
ELAI_BASE_URL = "https://apis.elai.io/api/v1/videos"
class EncoachElaiService:
def __init__(self, env):
self.env = env
self.token = (
env["ir.config_parameter"]
.sudo()
.get_param("encoach.elai_token", "")
)
def _headers(self):
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
def create_video(self, text, avatar_code):
"""Create and render an ELAI video.
Returns the video_id for status polling, or None on failure.
"""
avatar = (
self.env["encoach.elai.avatar"]
.sudo()
.search([("avatar_code", "=", avatar_code)], limit=1)
)
if not avatar:
_logger.error("Avatar not found: %s", avatar_code)
return None
payload = {
"name": f"EnCoach Speaking - {avatar.name}",
"slides": [
{
"avatar": {
"code": avatar.avatar_code,
"canvas": avatar.canvas or "default",
"voiceId": avatar.voice_id or "",
"voiceProvider": avatar.voice_provider or "",
},
"speech": text,
},
],
}
try:
resp = httpx.post(
ELAI_BASE_URL,
headers=self._headers(),
json=payload,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
video_id = data.get("_id") or data.get("id")
self._render_video(video_id)
return video_id
except Exception:
_logger.exception("ELAI video creation failed")
return None
def _render_video(self, video_id):
try:
httpx.post(
f"{ELAI_BASE_URL}/render/{video_id}",
headers=self._headers(),
timeout=30,
)
except Exception:
_logger.exception("ELAI render request failed for %s", video_id)
def poll_status(self, video_id):
"""Poll ELAI for video rendering status.
Returns dict with 'status' and optionally 'url' keys.
"""
try:
resp = httpx.get(
f"{ELAI_BASE_URL}/{video_id}",
headers=self._headers(),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
status = data.get("status", "unknown")
result = {"status": status}
if status == "ready":
result["url"] = data.get("url", "")
return result
except Exception:
_logger.exception("ELAI status poll failed for %s", video_id)
return {"status": "error"}
def get_avatars(self):
"""List all configured ELAI avatars."""
avatars = self.env["encoach.elai.avatar"].sudo().search([])
return [a.to_encoach_dict() for a in avatars]

View File

@@ -0,0 +1,129 @@
import io
import logging
import random
import re
import boto3
_logger = logging.getLogger(__name__)
POLLY_VOICES = {
"Danielle": {"gender": "Female", "accent": "US"},
"Gregory": {"gender": "Male", "accent": "US"},
"Kevin": {"gender": "Male", "accent": "US"},
"Ruth": {"gender": "Female", "accent": "US"},
"Stephen": {"gender": "Male", "accent": "US"},
"Arthur": {"gender": "Male", "accent": "GB"},
"Olivia": {"gender": "Female", "accent": "GB"},
"Ayanda": {"gender": "Female", "accent": "ZA"},
"Aria": {"gender": "Female", "accent": "NZ"},
"Kajal": {"gender": "Female", "accent": "IN"},
"Niamh": {"gender": "Female", "accent": "IE"},
}
FINAL_CUE_TEXT = (
"This audio recording, for the listening exercise, has finished."
)
FINAL_CUE_VOICE = "Stephen"
MAX_CHUNK_SIZE = 3000
class EncoachPollyService:
def __init__(self, env):
self.env = env
icp = env["ir.config_parameter"].sudo()
self.client = boto3.client(
"polly",
aws_access_key_id=icp.get_param("encoach.aws_access_key_id", ""),
aws_secret_access_key=icp.get_param("encoach.aws_secret_access_key", ""),
region_name=icp.get_param("encoach.aws_region", "us-east-1"),
)
def synthesize_speech(
self, text, voice, engine="neural", output_format="mp3"
):
"""Synthesize a single text block into MP3 bytes."""
chunks = self._chunk_text(text)
audio_parts = []
for chunk in chunks:
resp = self.client.synthesize_speech(
Text=chunk,
VoiceId=voice,
Engine=engine,
OutputFormat=output_format,
)
audio_parts.append(resp["AudioStream"].read())
return b"".join(audio_parts)
def text_to_speech(self, dialog, include_final_cue=True):
"""Generate full MP3 audio from a conversation or monologue.
dialog: either a string (monologue) or a list of dicts with
'name', 'gender', 'text' keys.
"""
audio_segments = []
if isinstance(dialog, str):
voice = random.choice(list(POLLY_VOICES.keys()))
audio_segments.append(self.synthesize_speech(dialog, voice))
else:
voice_map = self._assign_voices(dialog)
for line in dialog:
voice = voice_map.get(line["name"], "Stephen")
audio_segments.append(
self.synthesize_speech(line["text"], voice)
)
if include_final_cue:
audio_segments.append(
self.synthesize_speech(FINAL_CUE_TEXT, FINAL_CUE_VOICE)
)
return b"".join(audio_segments)
@staticmethod
def _assign_voices(dialog):
"""Assign distinct Polly voices to each speaker based on gender."""
speakers = {}
male_voices = [v for v, info in POLLY_VOICES.items() if info["gender"] == "Male"]
female_voices = [v for v, info in POLLY_VOICES.items() if info["gender"] == "Female"]
male_idx, female_idx = 0, 0
for line in dialog:
name = line.get("name", "")
if name in speakers:
continue
gender = (line.get("gender") or "").lower()
if gender == "female" and female_idx < len(female_voices):
speakers[name] = female_voices[female_idx]
female_idx += 1
elif male_idx < len(male_voices):
speakers[name] = male_voices[male_idx]
male_idx += 1
else:
speakers[name] = random.choice(list(POLLY_VOICES.keys()))
return speakers
@staticmethod
def _chunk_text(text, max_size=MAX_CHUNK_SIZE):
"""Split text at sentence boundaries respecting max chunk size."""
if len(text) <= max_size:
return [text]
sentences = re.split(r"(?<=[.!?])\s+", text)
chunks = []
current = ""
for sentence in sentences:
if len(current) + len(sentence) + 1 > max_size:
if current:
chunks.append(current.strip())
current = sentence
else:
current = f"{current} {sentence}" if current else sentence
if current:
chunks.append(current.strip())
return chunks

View File

@@ -0,0 +1,167 @@
import logging
from concurrent.futures import ThreadPoolExecutor
import numpy as np
from tenacity import retry, stop_after_attempt, wait_exponential
from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE
from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService
_logger = logging.getLogger(__name__)
SAMPLE_RATE = 16000
CHUNK_SAMPLES = 480000 # 30 seconds at 16 kHz
OVERLAP_RATIO = 0.25
WHISPER_OPTIONS = {
"fp16": False,
"language": "English",
"verbose": False,
}
OVERLAP_CLEANUP_PROMPT = (
"The following are two transcribed segments from the same audio. "
"They overlap at the boundary. Remove any duplicated words at the "
"junction and return the cleaned, combined text as JSON: "
'{"text": "cleaned text"}'
)
DIALOG_DETECTION_PROMPT = (
"You are a helpful assistant designed to output JSON on either one of "
"these formats:\n"
'1 - {"dialog": [{"name": "name", "gender": "gender", "text": "text"}]}\n'
'2 - {"dialog": "text"}\n\n'
"A transcription of an audio file will be provided to you. Based on that "
"transcription you will need to determine whether the transcription is a "
"conversation or a monologue. If it is a conversation, output format 1. "
"If it is a monologue, output format 2."
)
_model_pool = []
_pool_executor = None
_pool_lock = None
def _get_pool(num_workers=4):
global _model_pool, _pool_executor, _pool_lock
import threading
if _pool_lock is None:
_pool_lock = threading.Lock()
with _pool_lock:
if _pool_executor is None:
import whisper
_logger.info("Loading %d Whisper model instances...", num_workers)
_model_pool = [whisper.load_model("base") for _ in range(num_workers)]
_pool_executor = ThreadPoolExecutor(max_workers=num_workers)
_logger.info("Whisper pool ready with %d workers", num_workers)
return _pool_executor, _model_pool
class EncoachWhisperService:
def __init__(self, env):
self.env = env
self.ai = EncoachOpenAIService(env)
self._num_workers = int(
env["ir.config_parameter"].sudo().get_param("encoach.whisper_workers", "4")
)
self._worker_idx = 0
import threading
self._idx_lock = threading.Lock()
def _get_model(self):
executor, pool = _get_pool(self._num_workers)
with self._idx_lock:
idx = self._worker_idx % len(pool)
self._worker_idx += 1
return pool[idx]
def transcribe(self, audio_data):
if not isinstance(audio_data, np.ndarray):
audio_data = np.frombuffer(audio_data, dtype=np.float32)
audio_data = audio_data.astype(np.float32)
if audio_data.max() > 1.0:
audio_data = audio_data / np.abs(audio_data).max()
chunks = self._chunk_audio(audio_data)
if len(chunks) == 1:
return self._transcribe_chunk(chunks[0])
executor, pool = _get_pool(self._num_workers)
futures = []
for i, chunk in enumerate(chunks):
model = pool[i % len(pool)]
future = executor.submit(self._transcribe_chunk_with_model, chunk, model)
futures.append(future)
segments = [f.result() for f in futures]
return self._merge_segments(segments)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
def _transcribe_chunk(self, chunk):
model = self._get_model()
result = model.transcribe(chunk, **WHISPER_OPTIONS)
return result.get("text", "").strip()
@staticmethod
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
def _transcribe_chunk_with_model(chunk, model):
result = model.transcribe(chunk, **WHISPER_OPTIONS)
return result.get("text", "").strip()
def _merge_segments(self, segments):
if len(segments) <= 1:
return segments[0] if segments else ""
merged = segments[0]
for i in range(1, len(segments)):
result = self.ai.prediction(
model=GPT_MODELS["grading"],
messages=[
{"role": "system", "content": OVERLAP_CLEANUP_PROMPT},
{
"role": "user",
"content": (
f'Segment A (end): "...{merged[-200:]}"\n'
f'Segment B (start): "{segments[i][:200]}..."'
),
},
],
temperature=TEMPERATURE["grading"],
check_blacklisted=False,
)
if result and "text" in result:
merged = result["text"]
else:
merged = f"{merged} {segments[i]}"
return merged
def detect_dialog(self, transcript):
return self.ai.prediction(
model=GPT_MODELS["grading"],
messages=[
{"role": "system", "content": DIALOG_DETECTION_PROMPT},
{"role": "user", "content": transcript},
],
temperature=TEMPERATURE["grading"],
check_blacklisted=False,
)
@staticmethod
def _chunk_audio(audio, chunk_size=CHUNK_SAMPLES, overlap=OVERLAP_RATIO):
total = len(audio)
if total <= chunk_size:
return [audio]
step = int(chunk_size * (1 - overlap))
chunks = []
start = 0
while start < total:
end = min(start + chunk_size, total)
chunks.append(audio[start:end])
if end >= total:
break
start += step
return chunks

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_elai_avatar_list" model="ir.ui.view">
<field name="name">encoach.elai.avatar.list</field>
<field name="model">encoach.elai.avatar</field>
<field name="arch" type="xml">
<list string="ELAI Avatars">
<field name="name"/>
<field name="avatar_code"/>
<field name="gender"/>
<field name="voice_id"/>
<field name="voice_provider"/>
<field name="canvas"/>
</list>
</field>
</record>
<record id="view_encoach_elai_avatar_form" model="ir.ui.view">
<field name="name">encoach.elai.avatar.form</field>
<field name="model">encoach.elai.avatar</field>
<field name="arch" type="xml">
<form string="ELAI Avatar">
<sheet>
<group>
<field name="name"/>
<field name="avatar_code"/>
<field name="avatar_url" widget="url"/>
<field name="gender"/>
<field name="canvas"/>
<field name="voice_id"/>
<field name="voice_provider"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_encoach_elai_avatar" model="ir.actions.act_window">
<field name="name">ELAI Avatars</field>
<field name="res_model">encoach.elai.avatar</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_encoach_elai_avatar"
name="ELAI Avatars"
parent="encoach_core.menu_encoach_config"
action="action_encoach_elai_avatar"
sequence="10"/>
</odoo>