Skip to content

YuriiDorosh/Lexora

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

183 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Lexora Academy

Powered by Avantgarde Systems

An advanced AI-driven language learning ecosystem for mastering English, Ukrainian, and Greek β€” built on enterprise-grade infrastructure with spaced repetition science, real-time multiplayer duels, and LLM-powered vocabulary intelligence.

Python Odoo PostgreSQL RabbitMQ Redis Docker FastAPI License


Table of Contents


What is Lexora?

Lexora is a full-stack private language learning platform that goes far beyond flashcard apps. It combines a battle-tested SRS engine, synchronous AI translation, PvP word duels with XP progression, a curated Gold Vocabulary of 3000+ words, an interactive Grammar Encyclopedia, and a premium glassmorphism UI β€” all self-hosted and running entirely on your own infrastructure.

Supported languages: English Β· Ukrainian Β· Greek


Core Features

AI Translator

Real-time synchronous translation between all three language pairs (en ↔ uk ↔ el) powered by deep_translator (Google/MyMemory backend). Translate any text and save the result directly to your personal vocabulary in one click. Route: /translator

Smart Spaced Repetition (SRS)

Full SM-2 algorithm implementation. Cards are scheduled at scientifically optimal intervals based on recall difficulty. Four grade buttons (Again / Hard / Good / Easy) adjust ease factor and next-review date. Daily practice queue with due-card counter. Route: /my/practice

PvP Arena

Asynchronous word duel system. Challenge other learners or the Lexora Bot, stake XP, play 10 rounds against each other's vocabulary, and climb the global leaderboard. XP economy with Streak Freeze, Double XP Booster, and Profile Frame shop items. Route: /my/arena

Knowledge Hub

  • Gold Vocabulary β€” 3184 most common English words tagged by CEFR level (A1–B2), part of speech, and Ukrainian/Greek translations. Paginated 50/page with one-click "Add to My List" buttons.
  • Grammar Encyclopedia β€” 6 comprehensive sections: All 12 Tenses, Irregular Verbs, Articles, Conditionals, Modal Verbs, Passive Voice & Reported Speech.

Routes: /useful-words Β· /grammar

PDF Export Suite

Printable cheat sheets generated server-side via wkhtmltopdf:

  • Personal vocabulary (word + translation + example sentence)
  • Gold Vocabulary filtered by CEFR level
  • Any Grammar section

Routes: /my/vocabulary/print Β· /useful-words/print?level=A1 Β· /grammar/<slug>/print

Vocabulary Management

Manual entry with automatic language detection, normalization-based deduplication, Anki .apkg / .txt import with audio extraction, inline translation editing, LLM enrichment (synonyms, antonyms, example sentences, explanation), audio recording and TTS generation. Route: /my/vocabulary

Community Layer

Public language channels and private DMs (built on Odoo Discuss), posts and articles with moderator review workflow, comments with @mentions, and "Save from Chat / Save from Post" inline vocabulary capture. Routes: /posts Β· /my/posts

AI Situational Roleplay

Six curated conversation scenarios (cafΓ© ordering, job interview, airport check-in, doctor's visit, hotel check-in, supermarket) powered by the local Qwen2.5 LLM. In-context grammar corrections, glassmorphism chat UI, and session persistence. Route: /my/roleplay

Grammar Pro β€” Cloze Tests

110 fill-in-the-blank exercises covering all 12 tenses, conditionals, modal verbs, articles, and more. CEFR A1–B2 filter, instant green/red feedback, XP rewards. Route: /my/grammar-practice

Premium Visual Identity

Dark animated hero section, glassmorphism cards, Inter + Montserrat typography, Avantgarde Systems branding, and a fully custom CSS design system (lx-* tokens).


System Architecture

                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                     β”‚               Browser / Client               β”‚
                     β”‚  HTTP Β· WebSocket Β· JSON-RPC Β· PDF download  β”‚
                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                          β”‚ :443 / :80
                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                     β”‚              Nginx 1.27 (alpine)             β”‚
                     β”‚   SSL termination Β· static files Β· WS proxy  β”‚
                     β”‚   /websocket β†’ odoo:8072                     β”‚
                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                          β”‚ :8069 / :8072
                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                     β”‚           Odoo 18 Community (4 workers)      β”‚
                     β”‚                                              β”‚
                     β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
                     β”‚  β”‚  Portal /   β”‚  β”‚   Odoo Backend /   β”‚   β”‚
                     β”‚  β”‚  Website    β”‚  β”‚   Admin Interface  β”‚   β”‚
                     β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
                     β”‚                                              β”‚
                     β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
                     β”‚  β”‚          Custom Modules (12)          β”‚  β”‚
                     β”‚  β”‚  security Β· core Β· words Β· trans Β·   β”‚  β”‚
                     β”‚  β”‚  enrich Β· audio Β· anki Β· chat Β·      β”‚  β”‚
                     β”‚  β”‚  dashboard Β· pvp Β· portal Β· learning  β”‚  β”‚
                     β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
                     β”‚                                              β”‚
                     β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
                     β”‚  β”‚  RabbitMQ    β”‚  β”‚  Redis 7 client  β”‚   β”‚
                     β”‚  β”‚  Publisher   β”‚  β”‚  (PvP state)     β”‚   β”‚
                     β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚ AMQP 0-9-1
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚                RabbitMQ 3 (management UI)      β”‚
          β”‚          Durable queues Β· persistent messages   β”‚
          β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚            β”‚               β”‚               β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚Translationβ”‚  β”‚LLM Enrichmt β”‚  β”‚   Anki    β”‚  β”‚  Audio / TTS  β”‚
    β”‚ FastAPI   β”‚  β”‚  FastAPI    β”‚  β”‚  FastAPI  β”‚  β”‚  FastAPI      β”‚
    β”‚           β”‚  β”‚             β”‚  β”‚           β”‚  β”‚               β”‚
    β”‚deep_trans β”‚  β”‚llama-cpp-py β”‚  β”‚zstandard  β”‚  β”‚edge-tts       β”‚
    β”‚Google/    β”‚  β”‚Qwen2.5-1.5B β”‚  β”‚bs4 / zip  β”‚  β”‚faster-whisper β”‚
    β”‚MyMemory   β”‚  β”‚GGUF Q4_K_M  β”‚  β”‚SQLite     β”‚  β”‚espeak-ng      β”‚
    β”‚:8001      β”‚  β”‚:8002        β”‚  β”‚:8003      β”‚  β”‚:8004          β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚   PostgreSQL 16       β”‚     β”‚         Redis 7               β”‚
    β”‚                       β”‚     β”‚                               β”‚
    β”‚  Odoo ORM (business   β”‚     β”‚  PvP ephemeral state only:    β”‚
    β”‚  records, sessions,   β”‚     β”‚  matchmaking queues, round    β”‚
    β”‚  filestore metadata)  β”‚     β”‚  state, reconnect grace TTLs  β”‚
    β”‚  :5432                β”‚     β”‚  :6379                        β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Design Principles

Principle Implementation
Single system of record All business data lives in Odoo / PostgreSQL
Stateless processors Worker services (FastAPI) own no persistent state
Async by default Translation, enrichment, Anki import, TTS all via RabbitMQ
Sync exception Roleplay and Translator use direct HTTP (immediate response required)
Idempotency Every async job carries a UUID job_id; workers check terminal state before reprocessing
CPU-first No GPU assumed; LLM and Whisper tuned for AVX-only 8 GiB servers

Tech Stack

Layer Technology Notes
Application monolith Odoo 18 Community 4 prefork workers
Database PostgreSQL 16 pg_trgm extension for fuzzy search
Async message bus RabbitMQ 3 Durable queues, persistent messages
Ephemeral PvP state Redis 7 TTL-keyed battle state; NOT session store
Translation service FastAPI + deep_translator Google primary / MyMemory fallback
LLM enrichment service FastAPI + llama-cpp-python Qwen2.5-1.5B GGUF Q4_K_M, CPU-only
Anki import service FastAPI + zstandard + beautifulsoup4 .apkg (Zstd) + .txt (TSV)
Audio / TTS service FastAPI + edge-tts + faster-whisper Microsoft neural voices; Whisper base STT
Reverse proxy Nginx 1.27 WebSocket pass-through, SSL termination
Container orchestration Docker Compose Single-host; prod overlay planned
PDF generation wkhtmltopdf 0.12.6 via Odoo QWeb A4, 2-column print layout
Fuzzy search base_search_fuzzy OCA addon + pg_trgm Vocabulary and cross-language lookup
Language detection langdetect 1.0.9 Source language prefill; 0.7 confidence threshold

Custom Odoo Modules

All modules live under src/addons/. Install order matters β€” each module declares its dependencies in __manifest__.py.

language_security
└── language_core
    β”œβ”€β”€ language_words
    β”‚   β”œβ”€β”€ language_translation
    β”‚   β”œβ”€β”€ language_enrichment
    β”‚   β”œβ”€β”€ language_audio
    β”‚   └── language_anki_jobs
    β”œβ”€β”€ language_chat
    β”œβ”€β”€ language_dashboard
    β”œβ”€β”€ language_pvp
    β”œβ”€β”€ language_portal
    └── language_learning

Module Responsibilities

Module Key Models Responsibility
language_security β€” Security groups, record rules, portal signup hook
language_core language.job.status.mixin System params, RabbitMQ publisher/consumer, job mixin
language_words language.entry language.user.profile language.lang language.media.link Vocabulary CRUD, dedup, normalization, language detection, sharing
language_translation language.translation Translation job lifecycle, translation.* event handling
language_enrichment language.enrichment LLM enrichment job lifecycle, enrichment.* event handling
language_audio language.audio User recording upload, TTS generation, audio.* event handling
language_anki_jobs language.anki.job Anki import job lifecycle, dedup on completion, audio extraction
language_chat discuss.channel (extended) Public language channels, DMs, save-from-chat
language_dashboard β€” Word of the day cron, popular words, community aggregations
language_pvp language.duel language.duel.line PvP duels, Lexora Bot, XP transfer, leaderboard
language_portal language.scenario language.scenario.session language.seeded.word language.grammar.section language.shop.item language.user.item All portal routes: vocabulary, translator, roleplay, grammar, shop, PDF export, library
language_learning language.review language.user.profile (extended) language.xp.log SM-2 SRS engine, XP/streak/level gamification, leaderboard, dashboard

Module File Structure

Every custom module follows the standard Odoo 18 layout:

src/addons/language_<name>/
β”œβ”€β”€ __init__.py                    # post_init_hook / post_update_hook (if needed)
β”œβ”€β”€ __manifest__.py                # module metadata, depends, data file list
β”‚
β”œβ”€β”€ models/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ language_<entity>.py       # ORM model definition
β”‚   └── ...
β”‚
β”œβ”€β”€ controllers/
β”‚   β”œβ”€β”€ __init__.py
β”‚   └── portal.py                  # HTTP routes (Odoo Controller)
β”‚
β”œβ”€β”€ views/
β”‚   β”œβ”€β”€ <model>_views.xml          # backend list/form/search views
β”‚   β”œβ”€β”€ portal_<feature>.xml       # QWeb portal templates
β”‚   └── pdf_<feature>.xml          # QWeb PDF report templates (language_portal)
β”‚
β”œβ”€β”€ security/
β”‚   β”œβ”€β”€ ir.model.access.csv        # CRUD rights per group
β”‚   └── record_rules.xml           # row-level access rules
β”‚
β”œβ”€β”€ data/
β”‚   β”œβ”€β”€ ir_cron_<name>.xml         # scheduled actions
β”‚   β”œβ”€β”€ website_menus.xml          # navbar entries
β”‚   β”œβ”€β”€ <seed_data>.xml            # XML fixture data (noupdate="1")
β”‚   └── <seed_data>.py             # Python seed data (importlib-loaded in hook)
β”‚
β”œβ”€β”€ static/
β”‚   └── src/css/
β”‚       └── premium_ui.css         # custom CSS design system (language_portal)
β”‚
└── tests/
    β”œβ”€β”€ __init__.py
    └── test_<feature>.py          # pytest-style Odoo test cases

Key Non-Standard Patterns

Pattern Where Why
importlib.util.spec_from_file_location for seed data language_portal/__init__.py Absolute import in hook context where relative imports fail
_inherit = 'language.entry' with new field only language_audio, language_enrichment, language_translation Adds audio_ids, enrichment_ids, translation_ids to entry without modifying language_words
"language.xp.log" in request.env.registry guard language_portal/controllers/ Loose coupling β€” XP awards degrade gracefully if language_learning is not installed
QWeb inheritance via xpath with position="after" portal_audio.xml, portal_enrichment.xml Injects sections into the entry detail page without touching the parent template
noupdate="0" on scenario XML roleplay_scenarios.xml Allows prompt updates via --update language_portal without delete/re-insert

Database Schema

All tables are managed by Odoo ORM. Below are the key tables and relationships.

Core Domain

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         language_entry                               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ id                    β”‚ Integer PK                                   β”‚
β”‚ type                  β”‚ Selection: word/phrase/sentence/collocation  β”‚
β”‚ source_text           β”‚ Char (raw user input)                        β”‚
β”‚ normalized_text       β”‚ Char (dedup key component, computed on save) β”‚
β”‚ source_language       β”‚ Selection: en/uk/el                          β”‚
β”‚ owner_id              β”‚ Many2one β†’ res_users                         β”‚
β”‚ is_shared             β”‚ Boolean (default False)                      β”‚
β”‚ status                β”‚ Selection: active/archived                   β”‚
β”‚ created_from          β”‚ Selection: manual/anki_import/copied_from_*  β”‚
β”‚ copied_from_user_id   β”‚ Many2one β†’ res_users (nullable)              β”‚
β”‚ copied_from_entry_id  β”‚ Many2one β†’ language_entry (nullable)         β”‚
β”‚ copied_from_post_id   β”‚ Many2one β†’ language_post (nullable)          β”‚
β”‚ pvp_eligible          β”‚ Boolean (computed: has completed translation) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ One2many                β”‚ One2many              β”‚ One2many
         β–Ό                        β–Ό                       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚language_trans-  β”‚   β”‚language_enrich-  β”‚   β”‚  language_audio   β”‚
β”‚lation           β”‚   β”‚ment              β”‚   β”‚                   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ entry_id        β”‚   β”‚ entry_id         β”‚   β”‚ entry_id          β”‚
β”‚ target_language β”‚   β”‚ language         β”‚   β”‚ audio_type        β”‚
β”‚ translated_text β”‚   β”‚ synonyms (JSON)  β”‚   β”‚ language          β”‚
β”‚ job_id (UUID)   β”‚   β”‚ antonyms (JSON)  β”‚   β”‚ attachment_id     β”‚
β”‚ status          β”‚   β”‚ example_sents    β”‚   β”‚ job_id (UUID)     β”‚
β”‚ error_message   β”‚   β”‚ explanation      β”‚   β”‚ status            β”‚
β”‚                 β”‚   β”‚ job_id (UUID)    β”‚   β”‚ tts_engine        β”‚
β”‚ UNIQUE(entry,   β”‚   β”‚ status           β”‚   β”‚ file_size_bytes   β”‚
β”‚   target_lang)  β”‚   β”‚ UNIQUE(entry,    β”‚   β”‚ transcription     β”‚
β”‚                 β”‚   β”‚   language)      β”‚   β”‚ UNIQUE(entry,     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚   type, language) β”‚
                                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

User Profile & Gamification

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    language_user_profile                          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ user_id              β”‚ Many2one β†’ res_users (UNIQUE)             β”‚
β”‚ native_language      β”‚ Selection: en/uk/el                       β”‚
β”‚ default_source_lang  β”‚ Selection: en/uk/el                       β”‚
β”‚ is_shared_list       β”‚ Boolean                                   β”‚
β”‚ pvp_total_battles    β”‚ Integer                                    β”‚
β”‚ pvp_wins             β”‚ Integer                                    β”‚
β”‚ pvp_losses           β”‚ Integer                                    β”‚
β”‚ pvp_draws            β”‚ Integer                                    β”‚
β”‚ pvp_win_rate         β”‚ Float (computed)                           β”‚
β”‚ xp_total             β”‚ Integer (gamification β€” from language_lrng)β”‚
β”‚ current_streak       β”‚ Integer                                    β”‚
β”‚ longest_streak       β”‚ Integer                                    β”‚
β”‚ last_practice_date   β”‚ Date                                       β”‚
β”‚ level                β”‚ Integer (computed: 1 + floor(sqrt(xp/50)))β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ Many2many
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   language_lang   β”‚          β”‚      language_xp_log         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€          β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ code  (en/uk/el)  β”‚          β”‚ user_id                      β”‚
β”‚ name  (English…)  β”‚          β”‚ amount (Integer, +/-)        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚ reason (practice/duel_win/…) β”‚
                               β”‚ duel_id (soft ref Integer)   β”‚
                               β”‚ date                         β”‚
                               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

SRS (Spaced Repetition)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      language_review                              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ entry_id             β”‚ Many2one β†’ language_entry                 β”‚
β”‚ user_id              β”‚ Many2one β†’ res_users                      β”‚
β”‚ state                β”‚ Selection: new/learning/review            β”‚
β”‚ next_review_date     β”‚ Date                                       β”‚
β”‚ last_review_date     β”‚ Date                                       β”‚
β”‚ repetitions          β”‚ Integer (n in SM-2)                       β”‚
β”‚ interval             β”‚ Integer (days until next review)          β”‚
β”‚ ease_factor          β”‚ Float (default 2.5, min 1.3, max 3.5)    β”‚
β”‚ total_reviews        β”‚ Integer                                    β”‚
β”‚ correct_reviews      β”‚ Integer                                    β”‚
β”‚ UNIQUE(user_id, entry_id)                                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

SM-2 algorithm grades:

Grade Button Effect
0 Again n=0, interval=1d, EF unchanged, state→learning
1 Hard n unchanged, intervalΓ—1.2, EFβˆ’=0.15
2 Good n+1, interval via SM-2, EF unchanged, state→review
3 Easy n+1, interval×1.3, EF+=0.15, state→review

PvP Arena

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                       language_duel                               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ challenger_id        β”‚ Many2one β†’ res_users                      β”‚
β”‚ opponent_id          β”‚ Many2one β†’ res_users (nullable)           β”‚
β”‚ state                β”‚ Selection: open/ongoing/finished/cancel   β”‚
β”‚ winner_id            β”‚ Many2one β†’ res_users (nullable)           β”‚
β”‚ xp_staked            β”‚ Integer (default 10)                      β”‚
β”‚ practice_language    β”‚ Selection: en/uk/el                       β”‚
β”‚ native_language      β”‚ Selection: en/uk/el                       β”‚
β”‚ rounds_total         β”‚ Integer (default 10)                      β”‚
β”‚ challenger_score     β”‚ Integer                                    β”‚
β”‚ opponent_score       β”‚ Integer                                    β”‚
β”‚ start_date           β”‚ Datetime                                   β”‚
β”‚ end_date             β”‚ Datetime                                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ One2many
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     language_duel_line                            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ duel_id              β”‚ Many2one β†’ language_duel (cascade)        β”‚
β”‚ player_id            β”‚ Many2one β†’ res_users                      β”‚
β”‚ entry_id             β”‚ Many2one β†’ language_entry                 β”‚
β”‚ round_number         β”‚ Integer (1-based)                         β”‚
β”‚ correct              β”‚ Boolean                                    β”‚
β”‚ answer_given         β”‚ Char                                       β”‚
β”‚ time_taken_seconds   β”‚ Float                                      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

XP Shop

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         language_shop_item          β”‚    β”‚       language_user_item           β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
│ name                 │ Char         │    │ user_id         │ Many2one→users  │
β”‚ description          β”‚ Text         │◄───│ item_id         β”‚ Many2oneβ†’item   β”‚
β”‚ xp_cost              β”‚ Integer      β”‚    β”‚ quantity        β”‚ Integer         β”‚
β”‚ item_type            β”‚ Selection:   β”‚    β”‚ activated_at    β”‚ Datetime        β”‚
β”‚                      β”‚  streak_freezeβ”‚   β”‚ expires_at      β”‚ Datetime        β”‚
β”‚                      β”‚  double_xp   β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚                      β”‚  profile_frameβ”‚
β”‚ icon                 β”‚ Char (emoji) β”‚
β”‚ is_active            β”‚ Boolean      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Knowledge Hub & Roleplay

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   language_seeded_word     β”‚    β”‚     language_grammar_section     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ word        Char           β”‚    β”‚ title        Char                β”‚
β”‚ cefr_level  Selection(A1…) β”‚    β”‚ slug         Char (unique)       β”‚
β”‚ pos         Char           β”‚    β”‚ category     Selection           β”‚
β”‚ uk_trans    Char           β”‚    β”‚ content_html Html                β”‚
β”‚ el_trans    Char           β”‚    β”‚ sequence     Integer             β”‚
β”‚ sort_order  Integer        β”‚    β”‚ is_published Boolean             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     language_scenario        β”‚    β”‚    language_scenario_session       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
│ name           Char          │    │ scenario_id  Many2one→scenario     │
│ description    Char          │    │ user_id      Many2one→res_users    │
β”‚ icon           Char (emoji)  │◄───│ chat_history Text (JSON array)     β”‚
β”‚ target_language Selection    β”‚    β”‚ UNIQUE(scenario_id, user_id)       β”‚
β”‚ initial_prompt Text          β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ is_active      Boolean       β”‚
β”‚ sequence       Integer       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Anki Import Jobs

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      language_anki_job                            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ user_id              β”‚ Many2one β†’ res_users                      β”‚
β”‚ filename             β”‚ Char                                       β”‚
β”‚ file_format          β”‚ Selection: apkg/txt                       β”‚
β”‚ source_language_id   β”‚ Many2one β†’ language_lang                  β”‚
β”‚ target_language_id   β”‚ Many2one β†’ language_lang (nullable)       β”‚
β”‚ field_mapping        β”‚ Text (JSON: {source: int, translation: int})β”‚
β”‚ job_id               β”‚ Char (UUID, auto-set on create)           β”‚
β”‚ status               β”‚ Selection: pending/processing/completed/failedβ”‚
β”‚ count_created        β”‚ Integer                                    β”‚
β”‚ count_skipped        β”‚ Integer                                    β”‚
β”‚ count_failed         β”‚ Integer                                    β”‚
β”‚ details_log          β”‚ Text (JSON: {skipped: [...], failed: [...]})β”‚
β”‚ error_message        β”‚ Text                                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Deduplication Invariant

Dedup key = normalize(source_text) + source_language + owner_id

normalize():
  1. Unicode NFC
  2. Lowercase
  3. Strip leading/trailing whitespace
  4. Collapse internal whitespace to single space
  5. Normalize smart quotes/apostrophes/dashes to ASCII
  6. Strip trailing sentence-ending punctuation (.!?) β€” dedup only, not stored

type is NOT in the dedup key.
On collision: skip + report count; never overwrite existing data.

Async Event Bus

All heavy processing flows through RabbitMQ. Odoo publishes a job and polls result queues via a 1-minute cron (basic_get drain, ADR-023).

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚        Odoo         β”‚
                    β”‚   (publishes job)   β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚ AMQP publish
                               β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚    RabbitMQ queue   β”‚
                    β”‚  (durable, persist) β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚ basic_consume (prefetch=1)
                               β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚   Worker service    β”‚
                    β”‚   (FastAPI thread)  β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚ AMQP publish result
                               β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚   Result queue      β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚ Odoo cron drains (every 1 min)
                               β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚   Odoo updates DB   β”‚
                    β”‚   status β†’ completedβ”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Event Catalog

Queue (requested) Publisher Queue (result) Consumer
translation.requested Odoo translation.completed / .failed Odoo cron
enrichment.requested Odoo enrichment.completed / .failed Odoo cron
anki.import.requested Odoo anki.import.completed / .failed Odoo cron
audio.generation.requested Odoo audio.generation.completed / .failed Odoo cron
audio.transcription.requested Odoo audio.transcription.completed / .failed Odoo cron

Message Envelope

Every message (in both directions) uses this standard envelope:

{
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "event_type": "translation.requested",
  "payload": {
    "source_text": "apple",
    "source_language": "en",
    "target_language": "uk",
    "entry_id": 42
  }
}

Workers ack only after a durable write. On failure, the fail event is published before the ack. job_id lookup prevents duplicate processing on redelivery.

Sync Exceptions (Direct HTTP, No RabbitMQ)

Two features require an immediate response and bypass the queue:

Feature Endpoint Caller
AI Translator POST /translate on translation service Odoo portal controller
AI Roleplay turn POST /roleplay on LLM service Odoo portal controller
# Pattern used in both sync features (portal controller side):
resp = requests.post(f"{LLM_SVC}/roleplay", json={...}, timeout=90)
resp.raise_for_status()
data = json.loads(resp.content.decode("utf-8", errors="replace"))

Docker Compose Stack

docker_compose/
β”œβ”€β”€ db/                       # PostgreSQL 16
β”‚   └── docker-compose.yml    # :5432, lexora volume
β”‚
β”œβ”€β”€ odoo/                     # Odoo 18 + observability
β”‚   β”œβ”€β”€ Dockerfile            # FROM odoo:18, pip base-requirements
β”‚   β”œβ”€β”€ docker-compose.yml    # :8069/:8072, nginx, loki, promtail
β”‚   └── nginx.conf            # WebSocket proxy, static files
β”‚
β”œβ”€β”€ nginx/                    # Standalone Nginx (prod overlay)
β”‚   β”œβ”€β”€ Dockerfile            # FROM nginx:1.27-alpine
β”‚   └── nginx.conf
β”‚
β”œβ”€β”€ rabbitmq/                 # RabbitMQ 3 management
β”‚   └── docker-compose.yml    # :5672 (AMQP), :15672 (UI)
β”‚
β”œβ”€β”€ redis/                    # Redis 7 alpine
β”‚   └── docker-compose.yml    # :6379, AOF persistence
β”‚
β”œβ”€β”€ translation/              # Translation FastAPI worker
β”‚   β”œβ”€β”€ Dockerfile            # python:3.11-slim
β”‚   └── docker-compose.yml    # :8001, TRANSLATE_* env vars
β”‚
β”œβ”€β”€ llm/                      # LLM Enrichment FastAPI worker
β”‚   β”œβ”€β”€ Dockerfile            # python:3.11-slim + build-essential/cmake
β”‚   └── docker-compose.yml    # :8002, llm_models volume, LLM_* env vars
β”‚
β”œβ”€β”€ anki/                     # Anki Import FastAPI worker
β”‚   β”œβ”€β”€ Dockerfile            # python:3.11-slim
β”‚   └── docker-compose.yml    # :8003
β”‚
└── audio/                    # Audio/TTS FastAPI worker
    β”œβ”€β”€ Dockerfile            # python:3.11-slim + ffmpeg + espeak-ng
    └── docker-compose.yml    # :8004, audio_models volume, TTS_* env vars

Port Map

Service Internal Host (dev)
Odoo (via Nginx) 80 5433
Odoo direct 8069 β€” (internal only)
Odoo WebSocket 8072 β€” (internal only)
PostgreSQL 5432 5432
RabbitMQ AMQP 5672 5672
RabbitMQ Management UI 15672 15672
Redis 6379 6379
Translation service 8000 8001
LLM service 8000 8002
Anki service 8000 8003
Audio service 8000 8004

Repository Layout

Lexora/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ addons/                       # All custom Odoo modules
β”‚   β”‚   β”œβ”€β”€ language_security/
β”‚   β”‚   β”œβ”€β”€ language_core/
β”‚   β”‚   β”œβ”€β”€ language_words/
β”‚   β”‚   β”œβ”€β”€ language_translation/
β”‚   β”‚   β”œβ”€β”€ language_enrichment/
β”‚   β”‚   β”œβ”€β”€ language_audio/
β”‚   β”‚   β”œβ”€β”€ language_anki_jobs/
β”‚   β”‚   β”œβ”€β”€ language_chat/
β”‚   β”‚   β”œβ”€β”€ language_dashboard/
β”‚   β”‚   β”œβ”€β”€ language_pvp/
β”‚   β”‚   β”œβ”€β”€ language_portal/
β”‚   β”‚   β”œβ”€β”€ language_learning/
β”‚   β”‚   β”œβ”€β”€ base_search_fuzzy/        # OCA addon (fuzzy vocab search)
β”‚   β”‚   β”œβ”€β”€ password_security/        # OCA addon (password policy)
β”‚   β”‚   β”œβ”€β”€ web_notify/               # OCA addon (toast notifications)
β”‚   β”‚   β”œβ”€β”€ website_require_login/    # OCA addon (auth gate)
β”‚   β”‚   └── website_menu_by_user_status/ # OCA addon (conditional nav)
β”‚   └── configs/
β”‚       └── odoo.conf                 # Odoo configuration (workers=4, etc.)
β”‚
β”œβ”€β”€ services/                         # FastAPI microservices
β”‚   β”œβ”€β”€ translation/
β”‚   β”‚   β”œβ”€β”€ main.py                   # Consumer + /translate sync endpoint
β”‚   β”‚   └── requirements.txt
β”‚   β”œβ”€β”€ llm/
β”‚   β”‚   β”œβ”€β”€ main.py                   # Consumer + /enrich + /roleplay endpoints
β”‚   β”‚   └── requirements.txt
β”‚   β”œβ”€β”€ anki/
β”‚   β”‚   β”œβ”€β”€ main.py                   # Consumer + .apkg/.txt parsers
β”‚   β”‚   β”œβ”€β”€ requirements.txt
β”‚   β”‚   └── tests/
β”‚   β”‚       └── test_parsers.py       # 22 parser unit tests
β”‚   └── audio/
β”‚       β”œβ”€β”€ main.py                   # Consumer + edge-tts + faster-whisper
β”‚       └── requirements.txt
β”‚
β”œβ”€β”€ docker_compose/                   # Per-service Docker Compose files
β”‚   β”œβ”€β”€ db/ odoo/ nginx/ rabbitmq/
β”‚   β”œβ”€β”€ redis/ translation/ llm/
β”‚   β”œβ”€β”€ anki/ audio/
β”‚   └── pgadmin/ adminer/             # Optional DB tooling
β”‚
β”œβ”€β”€ requirements/
β”‚   β”œβ”€β”€ base-requirements.txt         # Odoo container pip deps (langdetect, redis, etc.)
β”‚   └── dev-requirements.txt          # Developer tools (ruff, mypy, bandit, etc.)
β”‚
β”œβ”€β”€ docs/
β”‚   β”œβ”€β”€ SPEC.md                       # Product specification
β”‚   β”œβ”€β”€ ARCHITECTURE.md               # System design
β”‚   β”œβ”€β”€ PLAN.md                       # Milestone implementation plan (M0–M21)
β”‚   β”œβ”€β”€ DECISIONS.md                  # ADR-001 through ADR-028
β”‚   └── TASKS.md                      # Active task tracker / resume point
β”‚
β”œβ”€β”€ .github/
β”‚   β”œβ”€β”€ workflows/
β”‚   β”‚   β”œβ”€β”€ lint.yml                  # Ruff + Mypy + Bandit + Hadolint + XMLlint
β”‚   β”‚   β”œβ”€β”€ test.yml                  # FastAPI pytest matrix + Odoo module tests
β”‚   β”‚   β”œβ”€β”€ security.yml              # pip-audit + TruffleHog + Bandit SARIF
β”‚   β”‚   β”œβ”€β”€ docker-build.yml          # Build all 5 Docker images
β”‚   β”‚   └── pr-check.yml              # Conventional Commits + branch name check
β”‚   β”œβ”€β”€ PULL_REQUEST_TEMPLATE.md
β”‚   β”œβ”€β”€ CODEOWNERS
β”‚   β”œβ”€β”€ dependabot.yml
β”‚   └── ISSUE_TEMPLATE/
β”‚       β”œβ”€β”€ bug_report.yml
β”‚       └── feature_request.yml
β”‚
β”œβ”€β”€ backups/                          # pg_restore targets
β”œβ”€β”€ logs/                             # Nginx + app logs
β”œβ”€β”€ pyproject.toml                    # Ruff / Mypy / pytest / Bandit config
β”œβ”€β”€ .pre-commit-config.yaml           # pre-commit hooks
β”œβ”€β”€ .editorconfig                     # Editor formatting rules
β”œβ”€β”€ Makefile                          # Developer shortcuts
β”œβ”€β”€ env.example                       # Environment variable template
β”œβ”€β”€ CLAUDE.md                         # AI assistant context file
└── LICENSE                           # Proprietary β€” All rights reserved

Quick Start (Development)

# 1. Clone and configure
git clone https://github.com/YuriiDorosh/Lexora.git
cd Lexora
cp env.example .env          # fill in RABBITMQ_PASS, etc.

# 2. Start the full stack
make up-dev                  # Odoo + Postgres + RabbitMQ + Redis + Nginx
                             # + Translation + LLM + Anki + Audio

# 3. Initialize the database (first run only)
#    Open http://localhost:5433 and complete the Odoo setup wizard, then:
docker exec odoo odoo --config /etc/odoo/odoo.conf \
  -d lexora \
  --init language_security,language_core,language_words,language_translation,\
language_enrichment,language_audio,language_anki_jobs,language_chat,\
language_dashboard,language_pvp,language_portal,language_learning \
  --stop-after-init

# 4. Access the platform
open http://localhost:5433

Health checks:

curl http://localhost:5433/web/health     # Odoo ({"status":"pass"})
curl http://localhost:8001/health         # Translation service
curl http://localhost:8002/health         # LLM service
curl http://localhost:8003/health         # Anki service
curl http://localhost:8004/health         # Audio service
curl http://localhost:15672               # RabbitMQ management UI (guest/guest)

Common Makefile targets:

make up-dev                  # start everything
make down-dev                # stop everything
make logs-odoo               # tail Odoo logs
make logs-translation        # tail translation service logs
make up-llm-no-cache         # rebuild LLM image (after model change)
make load-backup FILE=x.dump # restore PostgreSQL from backup
make ps-dev                  # list all running dev containers

Update a single module:

docker exec odoo odoo --config /etc/odoo/odoo.conf \
  -d lexora --update language_portal --stop-after-init --no-http
docker restart odoo          # reload routes into running workers

Developer Tooling

# Install all dev tools
make install-dev             # pip install -r requirements/dev-requirements.txt

# Lint (ruff) β€” checks all service files and Odoo addons
make lint

# Format (ruff format)
make fmt                     # apply formatting
make fmt-check               # check only (used by CI)

# Type check (mypy β€” FastAPI services only)
make typecheck

# Security scan (bandit)
make security

# Dependency audit (pip-audit per service)
make audit

# Run all checks in sequence (matches CI)
make check

# pre-commit hooks
make pre-commit-install      # install hooks into .git/hooks/
make pre-commit-run          # run against all files

CI/CD Pipelines (.github/workflows/)

Workflow Trigger Jobs
lint.yml Push to main/feature branches, PRs Ruff lint + format, Mypy, Bandit, Hadolint, XMLlint
test.yml PRs to main, [test] commits FastAPI pytest matrix, Odoo module tests (postgres service)
security.yml Push to main, PRs pip-audit per service, TruffleHog secrets scan, Bandit SARIF
docker-build.yml Changes to docker_compose/ or services/ Build all 5 images with GHA cache
pr-check.yml PR opened/edited/synchronized Conventional Commits title, branch name convention

Commit convention: type(scope): description

feat(M19): add idioms hub with phrasal verbs
fix(audio): handle edge-tts timeout on slow networks
docs(architecture): update database schema diagram
ci(lint): add DL3059 to hadolint ignore list

Documentation

Document Purpose
docs/SPEC.md Full product specification: domain model, features, privacy, PvP rules
docs/ARCHITECTURE.md System design: services, event catalog, real-time PvP design
docs/PLAN.md Milestone-by-milestone implementation plan (M0–M21)
docs/DECISIONS.md Architecture decision records (ADR-001–ADR-028)
CLAUDE.md AI assistant context: build commands, key invariants, module order
docs/TASKS.md Active task tracker β€” resume point for interrupted sessions

Implementation Status

Milestone Feature Status
M0 Infrastructure Foundation βœ… Complete
M1 Core Module Scaffold + Auth βœ… Complete
M2 Learning Entries + Dedup βœ… Complete
M3 Translation Service (RabbitMQ) βœ… Complete
M4 LLM Enrichment Service βœ… Complete
M4b Real CPU-only LLM (Qwen2.5-1.5B) βœ… Complete
M4c Translation / Enrichment split βœ… Complete
M5 Anki Import (.apkg + .txt) βœ… Complete
M6 Audio Recording + TTS βœ… Complete
M7 Posts, Articles, Comments βœ… Complete
M8 Chat & DMs βœ… Complete
M9 SRS Core + Dashboards βœ… Complete
M10 PvP Arena + XP System βœ… Complete
M11 XP Shop βœ… Complete
M12 Knowledge Hub βœ… Complete
M13 PDF Export Suite βœ… Complete
M14 Premium Visual Identity βœ… Complete
M15 AI Translator Tool βœ… Complete
M16 Legal Protection + Documentation βœ… Complete
M17 AI Situational Roleplay βœ… Complete
M18 Grammar Pro β€” Cloze Tests βœ… Complete
M18.5 Header UI Redesign πŸ—“ Planned
M19 Natural Speech Hub (Idioms & Phrasal Verbs) πŸ—“ Planned
M20 Survival Phrasebook (Tourist Kits) πŸ—“ Planned
M21 Sentence Builder (Syntax Master) πŸ—“ Planned

License

Copyright (c) 2026 Yurii Dorosh & Avantgarde Systems. All rights reserved.

This software is proprietary. Unauthorized copying, modification, distribution, or use is strictly prohibited. See LICENSE for full terms.