feat: initial backend codebase — EnCoach v3

Complete Odoo 19 backend with 25 custom addons:
- encoach_core: user/entity/role management
- encoach_api: REST API + JWT auth
- encoach_ai: OpenAI integration, AI settings, generation
- encoach_ai_course: AI-powered English & IELTS course generation
- encoach_exam_template/session: exam creation, structures, sessions
- encoach_scoring: AI auto-grading + manual approval
- encoach_vector: pgvector RAG integration
- encoach_adaptive: adaptive learning engine
- encoach_placement: placement testing
- encoach_taxonomy/resources: content taxonomy & resource management
- Plus 14 more modules for courses, branding, portal, etc.

Includes docs: user guide, generation report, developer workflow.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-11 15:44:20 +04:00
commit 982d4bca30
371 changed files with 35211 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
from .openai_service import OpenAIService
from .whisper_service import WhisperService
from .polly_service import PollyService
from .elevenlabs_service import ElevenLabsService
from .gptzero_service import GPTZeroService
from .elai_service import ElaiService
from .coach_service import CoachService

View File

@@ -0,0 +1,116 @@
"""AI Coaching service — conversational assistant, tips, study suggestions."""
import json
import logging
_logger = logging.getLogger(__name__)
class CoachService:
"""High-level AI coaching: chat, tips, explanations, writing help, study plans."""
def __init__(self, env):
from .openai_service import OpenAIService
self.env = env
self.ai = OpenAIService(env)
def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "coach",
"action": action,
"latency_ms": latency_ms,
"status": status,
"error_message": error,
"input_preview": (inp or "")[:500],
"output_preview": (out or "")[:500],
})
except Exception:
_logger.warning("Failed to log coach call", exc_info=True)
def chat(self, message, *, history=None, student_context=None):
"""Multi-turn coaching conversation with RAG context."""
import time
t0 = time.time()
messages = [
{"role": "system", "content": (
"You are EnCoach AI — a friendly, expert IELTS and English learning coach. "
"You help students with study strategies, explain concepts, motivate them, "
"and answer questions about their learning journey. "
"Be encouraging but honest. Keep responses concise (under 150 words). "
"If asked about scores or progress, reference the student context provided."
)},
]
if student_context:
messages.append({"role": "system", "content": f"Student context: {json.dumps(student_context)}"})
for h in (history or []):
messages.append({"role": h.get("role", "user"), "content": h["content"]})
messages.append({"role": "user", "content": message})
reply = self.ai.chat_with_context(
messages, message,
content_types=["course", "resource", "module", "feedback"],
model=self.ai.fast_model, action="coach_chat", max_tokens=512,
)
self._log("coach_chat", int((time.time() - t0) * 1000), inp=message[:500], out=reply[:500])
return {"reply": reply}
def get_tip(self, context="general"):
"""Get a contextual learning tip, enriched with knowledge base content."""
import time
t0 = time.time()
vector_context = self.ai._get_vector_context(context, content_types=["resource", "feedback"], limit=3)
kb_text = self.ai._format_context(vector_context) if vector_context else ""
system_prompt = (
"Generate a single, practical English learning or IELTS preparation tip. "
"Make it specific and actionable. Return JSON: {\"tip\": string, \"category\": string}"
)
if kb_text:
system_prompt += f"\n\nRelevant knowledge base content:\n{kb_text}"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Context: {context}"},
]
result = self.ai.chat_json(messages, model=self.ai.fast_model, action="coach_tip", max_tokens=256)
self._log("coach_tip", int((time.time() - t0) * 1000), inp=context, out=json.dumps(result)[:500])
return result
def explain(self, score_data, student_context=""):
"""Explain a grade or assessment result."""
import time
t0 = time.time()
explanation = self.ai.explain_grade(score_data, student_context)
self._log("coach_explain", int((time.time() - t0) * 1000), out=explanation[:500])
return {"explanation": explanation}
def suggest(self, student_profile):
"""Suggest next study actions."""
import time
t0 = time.time()
result = self.ai.suggest_study_plan(student_profile)
self._log("coach_suggest", int((time.time() - t0) * 1000), out=json.dumps(result)[:500])
return result
def writing_help(self, task, draft, help_type="improve"):
"""Help with writing tasks."""
import time
t0 = time.time()
result = self.ai.writing_help(task, draft, help_type)
self._log("coach_writing", int((time.time() - t0) * 1000), inp=draft[:200], out=json.dumps(result)[:500])
return result
def get_hint(self, question_context):
"""Give a hint for a question without revealing the answer."""
import time
t0 = time.time()
messages = [
{"role": "system", "content": (
"Give a helpful hint for this question WITHOUT revealing the answer. "
"Guide the student's thinking. Return JSON: {\"hint\": string, \"strategy\": string}"
)},
{"role": "user", "content": json.dumps(question_context)},
]
result = self.ai.chat_json(messages, model=self.ai.fast_model, action="coach_hint", max_tokens=256)
self._log("coach_hint", int((time.time() - t0) * 1000), out=json.dumps(result)[:500])
return result

View File

@@ -0,0 +1,108 @@
"""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"),
}

View File

@@ -0,0 +1,103 @@
"""ElevenLabs text-to-speech service."""
import logging
import time
_logger = logging.getLogger(__name__)
try:
import requests as _requests
except ImportError:
_requests = None
ELEVENLABS_BASE = "https://api.elevenlabs.io/v1"
DEFAULT_VOICES = {
"female_british": "21m00Tcm4TlvDq8ikWAM", # Rachel
"male_british": "VR6AewLTigWG4xSOukaG", # Arnold
"female_american": "EXAVITQu4vr4xnSDxMaL", # Bella
"male_american": "TxGEqnHWrfWFTfGW9XjX", # Josh
}
class ElevenLabsService:
"""ElevenLabs TTS — higher quality multilingual voices."""
def __init__(self, env):
self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param
def _get_key(self):
key = self._get_param("encoach_ai.elevenlabs_api_key", "")
if not key:
import os
key = os.environ.get("ELEVENLABS_API_KEY", "")
if not key:
raise RuntimeError("ElevenLabs API key not configured — set in AI Settings")
return key
def _log(self, action, latency, status="success", error=None):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "elevenlabs",
"action": action,
"latency_ms": latency,
"status": status,
"error_message": error,
})
except Exception:
pass
def synthesize(self, text, *, voice_id=None, voice_key="female_british",
model=None, output_format="mp3_44100_128"):
"""Convert text to speech using ElevenLabs.
Returns:
dict with 'audio' (bytes), 'content_type', 'voice_id', 'characters'
"""
if not _requests:
raise RuntimeError("requests package not installed")
key = self._get_key()
voice_id = voice_id or DEFAULT_VOICES.get(voice_key, list(DEFAULT_VOICES.values())[0])
model = model or self._get_param("encoach_ai.elevenlabs_model", "eleven_multilingual_v2")
url = f"{ELEVENLABS_BASE}/text-to-speech/{voice_id}"
t0 = time.time()
try:
resp = _requests.post(
url,
json={
"text": text,
"model_id": model,
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
},
headers={"xi-api-key": key, "Accept": "audio/mpeg"},
params={"output_format": output_format},
timeout=60,
)
resp.raise_for_status()
latency = int((time.time() - t0) * 1000)
self._log("synthesize", latency)
return {
"audio": resp.content,
"content_type": "audio/mpeg",
"voice_id": voice_id,
"characters": len(text),
}
except Exception as exc:
self._log("synthesize", int((time.time() - t0) * 1000), "error", str(exc))
raise
def list_voices(self):
"""List available ElevenLabs voices."""
key = self._get_key()
resp = _requests.get(
f"{ELEVENLABS_BASE}/voices",
headers={"xi-api-key": key},
timeout=15,
)
resp.raise_for_status()
return [
{"voice_id": v["voice_id"], "name": v["name"], "labels": v.get("labels", {})}
for v in resp.json().get("voices", [])
]

View File

@@ -0,0 +1,87 @@
"""GPTZero AI content detection service."""
import logging
import time
_logger = logging.getLogger(__name__)
try:
import requests as _requests
except ImportError:
_requests = None
GPTZERO_BASE = "https://api.gptzero.me/v2"
class GPTZeroService:
"""Detect AI-generated content in student submissions."""
def __init__(self, env):
self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param
def _get_key(self):
key = self._get_param("encoach_ai.gptzero_api_key", "")
if not key:
import os
key = os.environ.get("GPT_ZERO_API_KEY", "")
if not key:
raise RuntimeError("GPTZero API key not configured — set in AI Settings")
return key
def _log(self, action, latency, status="success", error=None):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "gptzero",
"action": action,
"latency_ms": latency,
"status": status,
"error_message": error,
})
except Exception:
pass
def detect(self, text):
"""Check if text is AI-generated.
Returns:
dict with 'is_ai_generated' (bool), 'ai_probability' (float 0-1),
'human_probability' (float), 'sentences' (list of per-sentence scores)
"""
if not _requests:
raise RuntimeError("requests package not installed")
key = self._get_key()
t0 = time.time()
try:
resp = _requests.post(
f"{GPTZERO_BASE}/predict/text",
json={"document": text},
headers={"x-api-key": key, "Content-Type": "application/json"},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
doc = data.get("documents", [{}])[0] if data.get("documents") else {}
result = {
"is_ai_generated": doc.get("completely_generated_prob", 0) > 0.5,
"ai_probability": doc.get("completely_generated_prob", 0),
"human_probability": 1 - doc.get("completely_generated_prob", 0),
"mixed_probability": doc.get("average_generated_prob", 0),
"sentences": [
{
"text": s.get("sentence", ""),
"ai_probability": s.get("generated_prob", 0),
"is_ai": s.get("generated_prob", 0) > 0.5,
}
for s in doc.get("sentences", [])
],
}
self._log("detect", int((time.time() - t0) * 1000))
return result
except Exception as exc:
self._log("detect", int((time.time() - t0) * 1000), "error", str(exc))
raise
def detect_batch(self, texts):
"""Check multiple texts for AI generation."""
return [self.detect(t) for t in texts]

View File

@@ -0,0 +1,343 @@
"""OpenAI GPT service — chat completions, JSON mode, structured generation."""
import json
import logging
import time
_logger = logging.getLogger(__name__)
try:
import openai as _openai_mod
except ImportError:
_openai_mod = None
class OpenAIService:
"""Wraps the OpenAI Python SDK with Odoo settings and logging."""
def __init__(self, env):
self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
self.max_retries = int(self._get_param("encoach_ai.max_retries", "3"))
api_key = self._get_param("encoach_ai.openai_api_key", "")
if not api_key:
import os
api_key = os.environ.get("OPENAI_API_KEY", "")
if _openai_mod and api_key:
self.client = _openai_mod.OpenAI(api_key=api_key)
else:
self.client = None
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-3.5-turbo")
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "openai",
"action": action,
"model_used": model,
"prompt_tokens": getattr(usage, "prompt_tokens", 0) if usage else 0,
"completion_tokens": getattr(usage, "completion_tokens", 0) if usage else 0,
"total_tokens": getattr(usage, "total_tokens", 0) if usage else 0,
"latency_ms": latency,
"status": status,
"error_message": error,
"input_preview": (inp or "")[:500],
"output_preview": (out or "")[:500],
})
except Exception:
_logger.warning("Failed to log AI call", exc_info=True)
def _check_enabled(self):
if not self.enabled:
raise RuntimeError("AI is disabled — enable in Settings > AI Configuration")
def _retry_with_backoff(self, fn, action, model):
"""Execute fn with exponential backoff retries."""
last_exc = None
for attempt in range(self.max_retries):
try:
return fn()
except Exception as exc:
last_exc = exc
err_str = str(exc).lower()
is_rate_limit = "rate" in err_str or "429" in err_str
is_server_error = "500" in err_str or "502" in err_str or "503" in err_str
if not (is_rate_limit or is_server_error) or attempt == self.max_retries - 1:
raise
wait = min(2 ** attempt, 16)
_logger.warning("AI retry %d/%d for %s (wait %ds): %s",
attempt + 1, self.max_retries, action, wait, exc)
time.sleep(wait)
raise last_exc
def chat(self, messages, *, model=None, temperature=0.7, max_tokens=2048, action="chat"):
"""Standard chat completion. Returns the assistant message content string."""
self._check_enabled()
if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model
t0 = time.time()
try:
def _call():
return self.client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
resp = self._retry_with_backoff(_call, action, model)
content = resp.choices[0].message.content
self._log(action, model, resp.usage, int((time.time() - t0) * 1000),
inp=json.dumps(messages[-1:])[:500], out=content[:500])
return content
except Exception as exc:
self._log(action, model, None, int((time.time() - t0) * 1000),
status="error", error=str(exc))
raise
def chat_json(self, messages, *, model=None, temperature=0.3, max_tokens=4096, action="chat_json"):
"""Chat completion with JSON response format. Returns parsed dict/list."""
self._check_enabled()
if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model
t0 = time.time()
try:
def _call():
return self.client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
response_format={"type": "json_object"},
)
resp = self._retry_with_backoff(_call, action, model)
raw = resp.choices[0].message.content
self._log(action, model, resp.usage, int((time.time() - t0) * 1000),
inp=json.dumps(messages[-1:])[:500], out=raw[:500])
return json.loads(raw)
except Exception as exc:
self._log(action, model, None, int((time.time() - t0) * 1000),
status="error", error=str(exc))
raise
def chat_fast(self, messages, **kwargs):
"""Use the fast/cheap model for classification, tagging, simple tasks."""
return self.chat(messages, model=self.fast_model, **kwargs)
def grade_writing(self, rubric, task_text, response_text):
"""Grade a writing response using GPT with a rubric."""
messages = [
{"role": "system", "content": (
"You are an expert IELTS examiner. Grade the following response using the rubric provided. "
"Return JSON: {\"scores\": {\"task_achievement\": float, \"coherence_cohesion\": float, "
"\"lexical_resource\": float, \"grammatical_range\": float}, "
"\"overall_band\": float, \"feedback\": string, \"suggestions\": [string]}"
)},
{"role": "user", "content": f"## Rubric\n{rubric}\n\n## Task\n{task_text}\n\n## Student Response\n{response_text}"},
]
return self.chat_json(messages, action="grade_writing")
def grade_speaking(self, rubric, transcript):
"""Grade a speaking transcript using GPT."""
messages = [
{"role": "system", "content": (
"You are an expert IELTS Speaking examiner. Grade the transcript. "
"Return JSON: {\"scores\": {\"fluency_coherence\": float, \"lexical_resource\": float, "
"\"grammatical_range\": float, \"pronunciation\": float}, "
"\"overall_band\": float, \"feedback\": string, \"suggestions\": [string]}"
)},
{"role": "user", "content": f"## Rubric\n{rubric}\n\n## Transcript\n{transcript}"},
]
return self.chat_json(messages, action="grade_speaking")
def generate_content(self, content_type, brief, *, cefr_level="B2"):
"""Generate educational content (reading passage, grammar exercise, etc.)."""
messages = [
{"role": "system", "content": (
f"You are an expert EFL content creator. Generate a {content_type} "
f"at CEFR {cefr_level} level. Return well-structured JSON with the content, "
"questions/exercises if applicable, answer keys, and metadata."
)},
{"role": "user", "content": json.dumps(brief)},
]
return self.chat_json(messages, action=f"generate_{content_type}", max_tokens=4096)
def explain_grade(self, score_data, student_context=""):
"""Explain a grade to a student in simple terms."""
messages = [
{"role": "system", "content": (
"You are a supportive English learning coach. Explain the grade to the student "
"in an encouraging way. Highlight strengths, then areas for improvement with "
"concrete tips. Keep it under 200 words."
)},
{"role": "user", "content": f"Score data: {json.dumps(score_data)}\nContext: {student_context}"},
]
return self.chat(messages, model=self.fast_model, action="explain_grade")
def search_answer(self, query, context=""):
"""Answer a natural language search query about the platform."""
messages = [
{"role": "system", "content": (
"You are an intelligent assistant for the EnCoach IELTS & English learning platform. "
"Answer the query based on available context. Be concise and helpful. "
"Return JSON: {\"answer\": string, \"suggestions\": [string], \"related_actions\": [{\"label\": string, \"action\": string}]}"
)},
{"role": "user", "content": f"Query: {query}\nContext: {context}"},
]
return self.chat_json(messages, model=self.fast_model, action="search")
def generate_insights(self, data_summary, insight_type="general"):
"""Generate AI insights from data."""
messages = [
{"role": "system", "content": (
f"You are a data analyst for an education platform. Generate {insight_type} insights. "
"Return JSON: {\"insights\": [{\"title\": string, \"description\": string, "
"\"severity\": \"info\"|\"warning\"|\"critical\", \"recommendation\": string}]}"
)},
{"role": "user", "content": json.dumps(data_summary)},
]
return self.chat_json(messages, model=self.fast_model, action="insights")
def generate_report_narrative(self, report_type, data):
"""Generate a human-readable narrative for a report."""
messages = [
{"role": "system", "content": (
f"Write a concise professional narrative summary for a {report_type} report. "
"2-3 paragraphs. Highlight key trends, concerns, and recommendations."
)},
{"role": "user", "content": json.dumps(data)},
]
return self.chat(messages, model=self.fast_model, action="report_narrative")
def suggest_study_plan(self, student_profile):
"""Suggest a personalized study plan."""
messages = [
{"role": "system", "content": (
"You are an IELTS preparation expert coach. Create a personalized study suggestion. "
"Return JSON: {\"suggestion\": string, \"focus_areas\": [string], "
"\"daily_plan\": [{\"activity\": string, \"duration_min\": int, \"skill\": string}], "
"\"motivation\": string}"
)},
{"role": "user", "content": json.dumps(student_profile)},
]
return self.chat_json(messages, model=self.fast_model, action="study_suggest")
def writing_help(self, task, draft, help_type="improve"):
"""Provide writing assistance."""
messages = [
{"role": "system", "content": (
f"You are a writing tutor. Help the student {help_type} their draft. "
"Return JSON: {\"improved_text\": string, \"changes\": [{\"original\": string, "
"\"revised\": string, \"reason\": string}], \"tips\": [string]}"
)},
{"role": "user", "content": f"Task: {task}\n\nDraft:\n{draft}"},
]
return self.chat_json(messages, action="writing_help")
def batch_optimize(self, items, optimization_type="schedule"):
"""Optimize a batch of items (schedule, grouping, etc.)."""
messages = [
{"role": "system", "content": (
f"You are an optimization specialist. Optimize these items for {optimization_type}. "
"Return JSON: {\"optimized\": [items with suggested changes], \"summary\": string, \"impact\": string}"
)},
{"role": "user", "content": json.dumps(items)},
]
return self.chat_json(messages, action="batch_optimize")
# ── RAG-enhanced methods ─────────────────────────────────────────
def _get_vector_context(self, query, *, content_types=None, limit=5):
"""Retrieve relevant context from the vector store."""
try:
from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
svc = EmbeddingService(self.env)
if content_types:
results = []
for ct in content_types:
results.extend(svc.search(query, content_type=ct, limit=limit))
results.sort(key=lambda r: r['similarity'], reverse=True)
return results[:limit]
return svc.search(query, limit=limit)
except Exception:
_logger.debug("Vector search unavailable, proceeding without RAG", exc_info=True)
return []
def _format_context(self, vector_results):
"""Format vector search results as context for the LLM."""
if not vector_results:
return ""
parts = []
for r in vector_results:
text = (r.get('text') or '')[:500]
meta = r.get('metadata', {})
label = f"[{r['content_type']}#{r['content_id']}]"
if meta:
label += f" ({', '.join(f'{k}={v}' for k, v in meta.items())})"
parts.append(f"{label}\n{text}")
return "\n---\n".join(parts)
def chat_with_context(self, messages, query, *, content_types=None, limit=5, **kwargs):
"""RAG-enhanced chat: search vectors, inject context, then call GPT."""
context_results = self._get_vector_context(query, content_types=content_types, limit=limit)
if context_results:
context_text = self._format_context(context_results)
rag_msg = {
"role": "system",
"content": (
"The following relevant content was found in the knowledge base. "
"Use it to provide accurate, contextual answers:\n\n" + context_text
),
}
messages = [messages[0], rag_msg] + messages[1:]
kwargs.setdefault("action", "chat_rag")
return self.chat(messages, **kwargs)
def search_with_rag(self, query, context=""):
"""RAG-enhanced search: vector search + GPT synthesis."""
vector_results = self._get_vector_context(query, limit=8)
context_text = self._format_context(vector_results)
messages = [
{"role": "system", "content": (
"You are an intelligent assistant for the EnCoach IELTS & English learning platform. "
"Answer the query based on the knowledge base content provided below. "
"Be concise, accurate, and cite specific content when possible. "
"Return JSON: {\"answer\": string, \"suggestions\": [string], "
"\"related_actions\": [{\"label\": string, \"action\": string}], "
"\"sources\": [{\"type\": string, \"id\": number}]}"
)},
]
if context_text:
messages.append({"role": "system", "content": f"Knowledge base:\n{context_text}"})
if context:
messages.append({"role": "system", "content": f"Additional context: {context}"})
messages.append({"role": "user", "content": f"Query: {query}"})
return self.chat_json(messages, model=self.fast_model, action="search_rag")
def generate_content_dedup(self, content_type, brief, *, cefr_level="B2"):
"""Generate content with dedup-awareness: checks for similar existing content."""
brief_text = json.dumps(brief) if isinstance(brief, dict) else str(brief)
similar = self._get_vector_context(brief_text, content_types=[content_type], limit=3)
messages = [
{"role": "system", "content": (
f"You are an expert EFL content creator. Generate a {content_type} "
f"at CEFR {cefr_level} level. Return well-structured JSON with the content, "
"questions/exercises if applicable, answer keys, and metadata."
)},
]
if similar:
context_text = self._format_context(similar)
messages.append({"role": "system", "content": (
"IMPORTANT: The following similar content already exists. "
"Make your output DISTINCT — different angles, examples, or approaches. "
"Do NOT duplicate existing content:\n\n" + context_text
)})
messages.append({"role": "user", "content": brief_text})
return self.chat_json(messages, action=f"generate_{content_type}_dedup", max_tokens=4096)

View File

@@ -0,0 +1,102 @@
"""AWS Polly text-to-speech service."""
import logging
import time
_logger = logging.getLogger(__name__)
try:
import boto3 as _boto3
except ImportError:
_boto3 = None
VOICE_MAP = {
"en-GB": {"female": "Amy", "male": "Brian"},
"en-US": {"female": "Joanna", "male": "Matthew"},
"en-AU": {"female": "Nicole", "male": "Russell"},
"en-IN": {"female": "Aditi", "male": "Aditi"},
}
class PollyService:
"""AWS Polly TTS for generating listening exam audio."""
def __init__(self, env):
self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param
self._client = None
def _get_client(self):
if self._client:
return self._client
if not _boto3:
raise RuntimeError("boto3 not installed — run: pip install boto3")
access_key = self._get_param("encoach_ai.aws_access_key", "")
secret_key = self._get_param("encoach_ai.aws_secret_key", "")
region = self._get_param("encoach_ai.aws_region", "eu-west-1")
if not access_key or not secret_key:
import os
access_key = access_key or os.environ.get("AWS_ACCESS_KEY_ID", "")
secret_key = secret_key or os.environ.get("AWS_SECRET_ACCESS_KEY", "")
if not access_key:
raise RuntimeError("AWS credentials not configured — set in AI Settings")
self._client = _boto3.client(
"polly",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
)
return self._client
def _log(self, action, latency, status="success", error=None):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "polly",
"action": action,
"latency_ms": latency,
"status": status,
"error_message": error,
})
except Exception:
pass
def synthesize(self, text, *, voice=None, language="en-GB", gender="female",
engine="neural", output_format="mp3"):
"""Convert text to speech audio bytes.
Returns:
dict with 'audio' (bytes), 'content_type', 'voice', 'characters'
"""
client = self._get_client()
if not voice:
voice = VOICE_MAP.get(language, VOICE_MAP["en-GB"]).get(gender, "Amy")
t0 = time.time()
try:
resp = client.synthesize_speech(
Text=text,
OutputFormat=output_format,
VoiceId=voice,
Engine=engine,
LanguageCode=language,
)
audio = resp["AudioStream"].read()
latency = int((time.time() - t0) * 1000)
self._log("synthesize", latency)
return {
"audio": audio,
"content_type": resp["ContentType"],
"voice": voice,
"characters": len(text),
}
except Exception as exc:
self._log("synthesize", int((time.time() - t0) * 1000), "error", str(exc))
raise
def list_voices(self, language="en-GB"):
"""List available voices for a language."""
client = self._get_client()
resp = client.describe_voices(LanguageCode=language)
return [
{"id": v["Id"], "name": v["Name"], "gender": v["Gender"], "engine": v.get("SupportedEngines", [])}
for v in resp.get("Voices", [])
]

View File

@@ -0,0 +1,110 @@
"""OpenAI Whisper speech-to-text service."""
import logging
import tempfile
import time
_logger = logging.getLogger(__name__)
try:
import whisper as _whisper_mod
except ImportError:
_whisper_mod = None
try:
import openai as _openai_mod
except ImportError:
_openai_mod = None
class WhisperService:
"""Speech-to-text via local Whisper model or OpenAI Whisper API."""
def __init__(self, env):
self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param
self._local_model = None
api_key = self._get_param("encoach_ai.openai_api_key", "")
if not api_key:
import os
api_key = os.environ.get("OPENAI_API_KEY", "")
self._api_key = api_key
def _get_local_model(self):
if not _whisper_mod:
return None
if self._local_model is None:
self._local_model = _whisper_mod.load_model("base")
return self._local_model
def _log(self, action, latency, status="success", error=None):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "whisper",
"action": action,
"latency_ms": latency,
"status": status,
"error_message": error,
})
except Exception:
pass
def transcribe(self, audio_data, *, language="en", use_api=False):
"""Transcribe audio bytes to text.
Args:
audio_data: Raw audio bytes (wav, mp3, webm, etc.)
language: Language code
use_api: If True, use OpenAI Whisper API instead of local model
Returns:
dict with 'text', 'language', 'segments' keys
"""
t0 = time.time()
if use_api and self._api_key and _openai_mod:
return self._transcribe_api(audio_data, language, t0)
model = self._get_local_model()
if model:
return self._transcribe_local(model, audio_data, language, t0)
if self._api_key and _openai_mod:
return self._transcribe_api(audio_data, language, t0)
raise RuntimeError("Whisper not available — install whisper package or set OpenAI API key")
def _transcribe_local(self, model, audio_data, language, t0):
with tempfile.NamedTemporaryFile(suffix=".webm", delete=True) as tmp:
tmp.write(audio_data)
tmp.flush()
result = model.transcribe(tmp.name, language=language)
latency = int((time.time() - t0) * 1000)
self._log("transcribe_local", latency)
return {
"text": result["text"].strip(),
"language": result.get("language", language),
"segments": [
{"start": s["start"], "end": s["end"], "text": s["text"]}
for s in result.get("segments", [])
],
}
def _transcribe_api(self, audio_data, language, t0):
client = _openai_mod.OpenAI(api_key=self._api_key)
with tempfile.NamedTemporaryFile(suffix=".webm", delete=True) as tmp:
tmp.write(audio_data)
tmp.flush()
tmp.seek(0)
result = client.audio.transcriptions.create(
model="whisper-1",
file=tmp,
language=language,
response_format="verbose_json",
)
latency = int((time.time() - t0) * 1000)
self._log("transcribe_api", latency)
return {
"text": result.text.strip() if hasattr(result, "text") else str(result),
"language": language,
"segments": getattr(result, "segments", []),
}