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:
2
encoach_ai_media/__init__.py
Normal file
2
encoach_ai_media/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import services
|
||||
17
encoach_ai_media/__manifest__.py
Normal file
17
encoach_ai_media/__manifest__.py
Normal 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"],
|
||||
},
|
||||
}
|
||||
1
encoach_ai_media/models/__init__.py
Normal file
1
encoach_ai_media/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import elai_avatar
|
||||
30
encoach_ai_media/models/elai_avatar.py
Normal file
30
encoach_ai_media/models/elai_avatar.py
Normal 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 "",
|
||||
}
|
||||
4
encoach_ai_media/security/ir.model.access.csv
Normal file
4
encoach_ai_media/security/ir.model.access.csv
Normal 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
|
||||
|
3
encoach_ai_media/services/__init__.py
Normal file
3
encoach_ai_media/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import polly_service
|
||||
from . import whisper_service
|
||||
from . import elai_service
|
||||
107
encoach_ai_media/services/elai_service.py
Normal file
107
encoach_ai_media/services/elai_service.py
Normal 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]
|
||||
129
encoach_ai_media/services/polly_service.py
Normal file
129
encoach_ai_media/services/polly_service.py
Normal 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
|
||||
167
encoach_ai_media/services/whisper_service.py
Normal file
167
encoach_ai_media/services/whisper_service.py
Normal 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
|
||||
49
encoach_ai_media/views/avatar_views.xml
Normal file
49
encoach_ai_media/views/avatar_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user