From 93def02e94fbc91433f2ba956c37c690b17ad22a Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Sun, 19 Apr 2026 18:13:16 +0400 Subject: [PATCH] feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts Frontend - i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css. - Language toggle: localize aria-label / menu label, persist choice, update document dir synchronously. - Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass side = i18n.dir() === 'rtl' ? 'right' : 'left'. - AdminLmsLayout: convert every nav item from hard-coded title to titleKey, translate group labels (incl. the collapsible Training), breadcrumbs, user menu (Profile / Settings / Logout), help button and toggle aria labels; replace physical mr-/right- utilities with logical me-/end-. - AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar, AiAssistantDrawer): apply dir="auto" at the container level, localize titles, loading / error / empty states. - Dashboards (admin / student / teacher): wrap numeric values in , localize dates via ar-EG, fix flex direction for KPI and assignment cards. - UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar, context-menu, pagination, sidebar): flip chevrons in RTL via a scoped CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-. - Add logical-direction helpers and bidirectional isolation classes. Locales - Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`, `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity. API client - `api-client.ts` reads the active language from localStorage/i18n and sends `Accept-Language` on every request so the backend can localize AI output. Backend (encoach_ai) - openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware system prompt injection for every OpenAI call. - coach_service + controllers (coach_controller, ai_controller): thread the requested language from headers / user locale down to OpenAIService. - ai_feedback: fix latent registry error by pointing course_id at op.course instead of the non-existent encoach.course. Other - .gitignore: ignore runtime odoo logs and local caches. Made-with: Cursor --- .../encoach_ai/controllers/ai_controller.py | 51 ++++++++---- .../controllers/coach_controller.py | 15 +++- .../encoach_ai/models/ai_feedback.py | 2 +- .../encoach_ai/services/coach_service.py | 4 +- .../encoach_ai/services/openai_service.py | 82 ++++++++++++++++++- 5 files changed, 133 insertions(+), 21 deletions(-) diff --git a/custom_addons/encoach_ai/controllers/ai_controller.py b/custom_addons/encoach_ai/controllers/ai_controller.py index 18655c9f..fdfeca8b 100644 --- a/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/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/custom_addons/encoach_ai/controllers/coach_controller.py b/custom_addons/encoach_ai/controllers/coach_controller.py index 340d001f..73abcb32 100644 --- a/custom_addons/encoach_ai/controllers/coach_controller.py +++ b/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/custom_addons/encoach_ai/models/ai_feedback.py b/custom_addons/encoach_ai/models/ai_feedback.py index ea5cf00d..4a9bef2c 100644 --- a/custom_addons/encoach_ai/models/ai_feedback.py +++ b/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/custom_addons/encoach_ai/services/coach_service.py b/custom_addons/encoach_ai/services/coach_service.py index 8b0bf2e4..5bf10cf4 100644 --- a/custom_addons/encoach_ai/services/coach_service.py +++ b/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/custom_addons/encoach_ai/services/openai_service.py b/custom_addons/encoach_ai/services/openai_service.py index ac9b9475..2570fff9 100644 --- a/custom_addons/encoach_ai/services/openai_service.py +++ b/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():