feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled

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 <bdi>,
  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
This commit is contained in:
Yamen Ahmad
2026-04-19 18:13:16 +04:00
parent 6712d1d551
commit e1f059069f
56 changed files with 1471 additions and 491 deletions

View File

@@ -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:

View File

@@ -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():