diff --git a/.gitignore b/.gitignore
index 321926c2..a7e10a12 100644
--- a/.gitignore
+++ b/.gitignore
@@ -105,3 +105,6 @@ htmlcov/
# Poetry
poetry.lock
+
+# Odoo DB backups (local only, not source-controlled)
+backups/
diff --git a/backend/custom_addons/encoach_ai/controllers/ai_controller.py b/backend/custom_addons/encoach_ai/controllers/ai_controller.py
index 18655c9f..fdfeca8b 100644
--- a/backend/custom_addons/encoach_ai/controllers/ai_controller.py
+++ b/backend/custom_addons/encoach_ai/controllers/ai_controller.py
@@ -24,6 +24,25 @@ def _get_json():
return {}
+def _request_language():
+ """Return the caller's UI language from ``Accept-Language``.
+
+ The frontend ``api-client`` forwards the active i18n language (e.g. ``ar``
+ or ``en``) via this header so AI-generated natural-language strings can
+ be returned in the same language as the UI chrome.
+ """
+ try:
+ return request.httprequest.headers.get("Accept-Language", "") or ""
+ except Exception:
+ return ""
+
+
+def _openai_for_request():
+ """Construct an OpenAIService bound to the caller's UI language."""
+ from odoo.addons.encoach_ai.services.openai_service import OpenAIService
+ return OpenAIService(request.env, language=_request_language())
+
+
class AIController(http.Controller):
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
@@ -37,7 +56,7 @@ class AIController(http.Controller):
return _json_response({"answer": "", "suggestions": []})
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
result = ai.search_with_rag(query, context=body.get("context", ""))
return _json_response(result)
except Exception as e:
@@ -69,7 +88,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
result = ai.generate_insights(
body.get("data", {}),
insight_type=body.get("type", "general"),
@@ -85,7 +104,7 @@ class AIController(http.Controller):
def ai_alerts(self, **kw):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
context = request.params.get("context", "dashboard")
result = ai.generate_insights(
{"context": context, "request": "alerts"},
@@ -103,7 +122,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
narrative = ai.generate_report_narrative(
body.get("report_type", "performance"),
body.get("data", {}),
@@ -119,7 +138,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
result = ai.batch_optimize(
body.get("items", []),
optimization_type=body.get("type", "schedule"),
@@ -135,7 +154,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
skill = body.get("skill", "writing")
if skill == "speaking":
result = ai.grade_speaking(
@@ -160,7 +179,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
result = ai.generate_content_dedup(
body.get("content_type", "reading_passage"),
body.get("brief", {}),
@@ -204,7 +223,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
@@ -224,7 +243,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Create a personalized learning plan. Return JSON: "
@@ -246,7 +265,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Generate a course outline. Return JSON: {\"chapters\": "
@@ -264,7 +283,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Generate detailed chapter content for a course. Return JSON: "
@@ -283,7 +302,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Create an assessment rubric. Return JSON: {\"rubric\": "
@@ -350,7 +369,7 @@ class AIController(http.Controller):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
if not ai.client:
raise RuntimeError("OpenAI not configured")
@@ -480,7 +499,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
has_ai = bool(ai.client)
except Exception:
ai, has_ai = None, False
@@ -1618,7 +1637,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"You are an educational materials expert. Suggest learning materials "
@@ -1639,7 +1658,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
- ai = OpenAIService(request.env)
+ ai = OpenAIService(request.env, language=_request_language())
result = ai.generate_content(
body.get("content_type", "explanation"),
{"topic_id": topic_id, **body},
diff --git a/backend/custom_addons/encoach_ai/controllers/coach_controller.py b/backend/custom_addons/encoach_ai/controllers/coach_controller.py
index 340d001f..73abcb32 100644
--- a/backend/custom_addons/encoach_ai/controllers/coach_controller.py
+++ b/backend/custom_addons/encoach_ai/controllers/coach_controller.py
@@ -23,12 +23,25 @@ def _get_json():
return {}
+def _request_language():
+ """Read the caller's UI language from the ``Accept-Language`` header.
+
+ The frontend ``api-client`` automatically attaches this header from the
+ active i18n language so AI-generated text can be localized. Falls back
+ to English if the header is missing or malformed.
+ """
+ try:
+ return request.httprequest.headers.get("Accept-Language", "") or ""
+ except Exception:
+ return ""
+
+
class CoachController(http.Controller):
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
def _get_coach(self):
from odoo.addons.encoach_ai.services.coach_service import CoachService
- return CoachService(request.env)
+ return CoachService(request.env, language=_request_language())
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
diff --git a/backend/custom_addons/encoach_ai/models/ai_feedback.py b/backend/custom_addons/encoach_ai/models/ai_feedback.py
index ea5cf00d..4a9bef2c 100644
--- a/backend/custom_addons/encoach_ai/models/ai_feedback.py
+++ b/backend/custom_addons/encoach_ai/models/ai_feedback.py
@@ -89,7 +89,7 @@ class EncoachAIFeedback(models.Model):
"encoach.entity", ondelete="set null", index=True,
)
course_id = fields.Many2one(
- "encoach.course", ondelete="set null", index=True,
+ "op.course", ondelete="set null", index=True,
)
# ------------------------------------------------------------------
diff --git a/backend/custom_addons/encoach_ai/services/coach_service.py b/backend/custom_addons/encoach_ai/services/coach_service.py
index 8b0bf2e4..5bf10cf4 100644
--- a/backend/custom_addons/encoach_ai/services/coach_service.py
+++ b/backend/custom_addons/encoach_ai/services/coach_service.py
@@ -9,10 +9,10 @@ _logger = logging.getLogger(__name__)
class CoachService:
"""High-level AI coaching: chat, tips, explanations, writing help, study plans."""
- def __init__(self, env):
+ def __init__(self, env, *, language=None):
from .openai_service import OpenAIService
self.env = env
- self.ai = OpenAIService(env)
+ self.ai = OpenAIService(env, language=language)
def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None):
try:
diff --git a/backend/custom_addons/encoach_ai/services/openai_service.py b/backend/custom_addons/encoach_ai/services/openai_service.py
index ac9b9475..2570fff9 100644
--- a/backend/custom_addons/encoach_ai/services/openai_service.py
+++ b/backend/custom_addons/encoach_ai/services/openai_service.py
@@ -12,10 +12,45 @@ except ImportError:
_openai_mod = None
+# Human-readable names for the UI languages we support. Kept in sync with the
+# frontend i18n language set. When a user has the UI in Arabic (`ar`), we want
+# the LLM to reply in Arabic too — otherwise the user sees Arabic chrome with
+# English AI content, which is what they reported as "not translated correct".
+_LANGUAGE_NAMES = {
+ "en": "English",
+ "ar": "Arabic",
+ "fr": "French",
+ "es": "Spanish",
+ "de": "German",
+ "ru": "Russian",
+ "tr": "Turkish",
+ "fa": "Persian",
+ "ur": "Urdu",
+ "hi": "Hindi",
+ "zh": "Chinese",
+ "ja": "Japanese",
+ "ko": "Korean",
+}
+
+
+def _normalize_language(code):
+ """Pull a short ISO-639-1 code out of a raw Accept-Language-style string.
+
+ Handles ``ar``, ``ar-EG``, ``ar-EG,en;q=0.9`` and friends. Falls back to
+ ``en`` for anything we don't recognise so the AI always has a concrete
+ target language and never reverts to an empty prompt.
+ """
+ if not code:
+ return "en"
+ token = str(code).strip().split(",")[0].split(";")[0].strip().lower()
+ short = token.split("-")[0]
+ return short if short in _LANGUAGE_NAMES else "en"
+
+
class OpenAIService:
"""Wraps the OpenAI Python SDK with Odoo settings and logging."""
- def __init__(self, env):
+ def __init__(self, env, *, language=None):
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")
@@ -34,6 +69,49 @@ class OpenAIService:
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-4o-mini")
+ self.language = _normalize_language(language)
+
+ def _language_system_message(self):
+ """Return a system message that forces the LLM to answer in the user's
+ UI language, or ``None`` for English (the model's default).
+
+ We keep the original English prompts (which are tuned for JSON
+ structure) and simply tack a language instruction on the end. This
+ preserves behaviour for ``en`` users while giving Arabic users Arabic
+ output without having to translate every prompt in the codebase.
+ """
+ if not self.language or self.language == "en":
+ return None
+ lang_name = _LANGUAGE_NAMES.get(self.language, "English")
+ return {
+ "role": "system",
+ "content": (
+ f"LOCALIZATION: Write every user-facing natural-language string "
+ f"(titles, descriptions, explanations, feedback, recommendations, "
+ f"suggestions, motivation, narrative) in {lang_name}. "
+ "Keep JSON keys, enum values (e.g. 'info', 'warning', 'critical', "
+ "'TRUE', 'FALSE', 'NOT GIVEN'), CEFR band codes (A1-C2), band numbers, "
+ "and identifiers in their original form. Do not translate rubric "
+ "category names that appear as JSON keys."
+ ),
+ }
+
+ def _inject_language(self, messages):
+ """Prepend the localization instruction after the existing system
+ prompt(s) so it doesn't displace the structural prompt but is still
+ heeded by the model."""
+ lang_msg = self._language_system_message()
+ if not lang_msg:
+ return messages
+ messages = list(messages)
+ # Find the index of the last system message so we append after it.
+ last_system = -1
+ for i, m in enumerate(messages):
+ if isinstance(m, dict) and m.get("role") == "system":
+ last_system = i
+ insert_at = last_system + 1 if last_system >= 0 else 0
+ messages.insert(insert_at, lang_msg)
+ return messages
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
try:
@@ -82,6 +160,7 @@ class OpenAIService:
if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model
+ messages = self._inject_language(messages)
t0 = time.time()
try:
def _call():
@@ -108,6 +187,7 @@ class OpenAIService:
if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model
+ messages = self._inject_language(messages)
t0 = time.time()
try:
def _call():
diff --git a/frontend/index.html b/frontend/index.html
index ea5a3388..173935e1 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -17,7 +17,7 @@
-
+