A full-stack AI-powered study platform for Tunisian Baccalaureate students (Computer Science, Math, French). Bac Engine ingests scanned exam PDFs and course summaries into a pgvector-searchable knowledge base, then powers an AI tutor, a practice/evaluation engine, a multi-language tutor, and a sandboxed code executor.
Students can chat with an AI tutor that understands their intent, practice with auto-generated exercises graded by AI, browse and semantically search a growing archive of past exams, and even run Python code — all from a single platform.
┌─────────────────────────────────┐
│ React / Vite Frontend │
│ (JWT Bearer via Axios) │
└──────────────┬──────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ FastAPI Backend (app/) │
│ │
│ Router ──► Controller ──► Service ──► Model │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────────────────┐ │
│ │ Routers │ │ Controllers │ │ Services │ │
│ │ (HTTP + │ │ (business │ │ - LLM (Gemini, Groq) │ │
│ │ auth + │ │ logic) │ │ - Intent classifier (ML) │ │
│ │ valid.) │ │ │ │ - Vector search (pgvector) │ │
│ └──────────┘ └──────────────┘ │ - Ingestion pipeline │ │
│ └────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ SQLModel ORM │ │
│ │ (SQLAlchemy 2) │ │
│ └────────┬─────────┘ │
└─────────────────────────────────────────────┼─────────────────────────┘
│
▼
┌──────────────────────────┐
│ PostgreSQL 16 + pgvector │
└──────────────────────────┘
┌──────────────────────────────────┐
│ Isolated Code Executor │ (internal Docker network only)
│ FastAPI + subprocess + RLIMIT │ (no internet, token-gated)
└──────────────────────────────────┘
Request flow: Frontend sends JWT-authenticated requests to FastAPI routers, which delegate to controllers for business logic, services for LLM/ML/vector operations, and SQLModel for database persistence.
Ingestion pipeline flow (detailed diagram):
input PDF → OCR (Gemini → PaddleOCR fallback) → LaTeX
→ Classify (exam/summary, subject, year, session)
→ Archive original
→ Chunk (exam: structural headers / summary: LaTeX sections)
→ Embed (all-MiniLM-L6-v2, batch 64)
→ Persist (exam_chunks | math_summary_chunks in pgvector)
| Layer | Technology |
|---|---|
| Backend | Python 3.12, FastAPI, SQLModel / SQLAlchemy 2.0 |
| Database | PostgreSQL 16 + pgvector (vector similarity search) |
| LLMs | Google Gemini (primary tutor + OCR), Groq (general chat + language tutor + Whisper) |
| ML (in-process) | sentence-transformers (all-MiniLM-L6-v2 embeddings), scikit-learn (intent classifier), PaddleOCR (OCR fallback) |
| Frontend | React 19, Vite 8, TypeScript 6, Tailwind CSS 3 |
| Frontend Libraries | Axios, react-hook-form, Zod, react-markdown, KaTeX, JSXGraph, mathjs, lucide-react |
| Auth | JWT (HS256 via python-jose), bcrypt (passlib) |
| Deployment | Docker Compose (multi-stage builds), Terraform (GCP), GitHub Actions CI/CD |
| Monitoring | Sentry SDK |
├── app/ # FastAPI backend (main application)
│ ├── main.py # App entry point, CORS, router registration, startup hooks
│ ├── database.py # Sync + async SQLAlchemy engines, pgvector, session deps
│ ├── init_db.py # Standalone table-creation script
│ ├── controller/ # Business logic layer
│ │ ├── chat_controller.py # Intent routing + practice hand-off
│ │ ├── practice_controller.py # Exercise generation + evaluation
│ │ ├── conversation_controller.py # Conversation CRUD + Groq titles
│ │ ├── ingestion_controller.py # Upload validation + document bookkeeping
│ │ ├── language_tutor_controller.py # Tutor turn/start/assess orchestration
│ │ └── ...
│ ├── routers/ # HTTP layer (validation + auth deps)
│ │ ├── auth.py # /auth (login, me, forgot/reset password)
│ │ ├── chat.py # POST /chat
│ │ ├── practice.py # /practice (generate + evaluate)
│ │ ├── exams.py # /exams (browse/search/download/move/delete)
│ │ ├── ingestion.py # /ingestion/upload
│ │ ├── conversation.py # /conversations (CRUD + history + titles)
│ │ ├── language.py # /language/tutor (language tutor + voice)
│ │ ├── stats.py # /stats/me
│ │ ├── models.py # /models (embedding model lifecycle)
│ │ ├── paddle.py # /paddle (PaddleOCR lifecycle)
│ │ ├── document.py # /documents (document CRUD)
│ │ ├── code.py # /code/execute (sandboxed Python)
│ │ ├── exercise.py # /exercises (exercise CRUD)
│ │ ├── performance.py # /performances (performance CRUD)
│ │ └── users.py # /users (admin user management)
│ ├── services/ # LLM / ML / vector search layer
│ │ ├── call_llm.py # AsyncLLMService (Gemini + fallback chain)
│ │ ├── call_groq.py # GroqService (chat + Whisper transcription)
│ │ ├── Assistant.py # Conversation-aware chat with LRU cache
│ │ ├── intent_service.py # Logistic regression intent classifier
│ │ ├── practice_mode.py # Exercise generation (Gemma -> Gemini fallback)
│ │ ├── language_tutor.py # LanguageTutorService (Groq)
│ │ ├── language_prompts.py # Per-language prompt templates
│ │ ├── code_executor.py # HTTP client for isolated executor container
│ │ ├── mail.py # SMTP password-reset email sender
│ │ └── ingestion/ # Document ingestion pipeline
│ │ ├── ingestion_pipeline.py # Full pipeline orchestrator
│ │ ├── ocr_extractor.py # Gemini PDF->LaTeX + PaddleOCR fallback
│ │ ├── doc_classifier.py # Exam/summary + subject/year/session detection
│ │ ├── exam_chunker.py # Structural exam chunker
│ │ ├── summary_chunker.py # LaTeX section-based chunker
│ │ ├── model_manager.py # Embedding model singleton + TTL worker
│ │ ├── paddle_model_manager.py # PaddleOCR singleton + TTL worker
│ │ ├── vectorization.py # Bulk vectorization + pgvector search
│ │ └── exams_index.py # Static JSON exam index, rebuilt at startup
│ ├── models/ # SQLModel database tables
│ ├── schema/ # Pydantic request/response models
│ └── utils/ # Shared utilities (auth, password, client)
├── bac-frontend/ # React/Vite frontend
│ ├── src/
│ │ ├── api/axios.ts # Axios instance, JWT interceptor, 401 handling
│ │ ├── auth/ # AuthContext, authService, ProtectedRoute, AdminRoute
│ │ ├── components/ # Shared UI components
│ │ ├── pages/ # 14 page components
│ │ ├── layouts/ # AuthLayout, DashboardLayout
│ │ ├── hooks/useAuth.ts # useAuth() hook
│ │ └── types/index.ts # All API interfaces
│ ├── package.json
│ └── vite.config.ts
├── executor/ # Isolated Python code-executor microservice
│ ├── app/
│ │ ├── main.py # FastAPI POST /run + GET /health
│ │ └── executor.py # Subprocess runner with RLIMIT resource limits
│ └── Dockerfile # python:3.12-slim, non-root user
├── ML/ # ML training scripts
│ ├── logistic_reg.py # Trains intent classifier
│ └── manipulating_data.py # Merges intent training data
├── docs/ # Detailed documentation
├── gcp-vm/ # Terraform for GCP deployment
├── .github/workflows/deploy.yaml # GitHub Actions CI/CD
├── Dockerfile # Multi-stage: builder + runtime
├── docker-compose.yml # bac-api + python-executor + postgres
└── requirements.txt # Pinned Python dependencies
# 1. Clone the repository
git clone https://github.com/scalexi/tunisian_bac_platform.git
cd bac-engine
# 2. Create the environment file
cp app/.env.example app/.env
# Edit app/.env and set at minimum:
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bac_db
# gemini=<your-gemini-api-key>
# GROQ_API_KEY=<your-groq-api-key>
# SECRET_KEY=<random-long-string>
# CODE_EXECUTOR_TOKEN=<random-secret>
# 3. Start everything
docker compose up --build
# The API will be available at http://localhost:8000
# Interactive docs at http://localhost:8000/docs
Docker Compose runs three services:
docker compose exec postgres psql -U postgres -d bac_db)Backend:
# 1. Create virtual environment
python -m venv .venv
source .venv/bin/activate
# 2. Install dependencies
pip install -r requirements.txt
# 3. Set up environment
cp app/.env.example app/.env
# Edit app/.env (see Environment Variables below)
# 4. Create database tables
cd app && python init_db.py
# 5. Start the server
uvicorn main:app --reload --host 0.0.0.0 --port 8000
Frontend:
cd bac-frontend
# 1. Install dependencies
npm install
# 2. Create .env file
echo "VITE_API_URL=http://localhost:8000" > .env
# 3. Start dev server
npm run dev
# Frontend will be available at http://localhost:5173
| Variable | Default | Description |
|---|---|---|
DATABASE_URL | postgresql://postgres:postgres@localhost:5432/bac_db | PostgreSQL connection string |
gemini | — | Google Gemini API key (primary LLM + OCR) |
GROQ_API_KEY | — | Groq API key (general chat + language tutor + Whisper) |
SECRET_KEY | change-me-in-production-use-a-long-random-string | JWT signing secret (HS256) |
| Variable | Default | Description |
|---|---|---|
TOKEN_EXPIRE_MINUTES | 60 | JWT access-token lifetime in minutes |
GEMINI_MODEL | gemini-3.5-flash | Primary Gemini model (fallback chain: primary → lite → flash-lite) |
GROQ_MODEL | llama-3.3-70b-versatile | Groq model for chat completions |
EMBED_MODEL_TTL | 1800 (30 min) | Embedding model idle TTL before auto-unload (seconds) |
PADDLE_OCR_TTL | 1800 (30 min) | PaddleOCR idle TTL before auto-unload (seconds) |
CODE_EXECUTOR_URL | http://python-executor:8001 | Isolated code executor service URL |
CODE_EXECUTOR_TOKEN | — | Shared secret for code executor auth |
DEEPSEEK_API_KEY | — | DeepSeek API key (experimental) |
MOONSHOT_API_KEY | — | Moonshot AI API key (experimental PDF-to-LaTeX) |
| Variable | Default | Description |
|---|---|---|
SMTP_HOST | — | SMTP server hostname |
SMTP_PORT | 587 | SMTP server port (465=implicit TLS, 587=STARTTLS) |
SMTP_USERNAME | — | SMTP auth username |
SMTP_PASSWORD | — | SMTP auth password |
SMTP_FROM | Bac Engine <[email protected]> | Sender address for outgoing email |
SMTP_TIMEOUT | 15 | SMTP connection timeout (seconds) |
FRONTEND_URL | http://localhost:5173 | Frontend base URL for password-reset links |
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES | 30 | Password-reset token TTL (minutes) |
| Variable | Default | Description |
|---|---|---|
VITE_API_URL | http://localhost:8000 | Backend API base URL |
http://localhost:8000
Interactive API docs are available at /docs (Swagger UI) and /redoc (ReDoc).
All authenticated endpoints expect the header:
Authorization: Bearer <access_token>
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /auth/login | No | Authenticate with email/password, receive JWT |
GET | /auth/me | Yes | Get current user profile |
POST | /auth/forgot-password | No | Request a password reset email |
POST | /auth/reset-password | No | Set new password with emailed token |
How intent routing works: See AI Agent Flow — logistic regression classifies messages into
practice,course, orotherand routes to the right LLM.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /chat | No | Send a message; returns intent-routed AI response (Gemini for course, Groq for general, practice for exercises) |
Request body:
{
"conversation_id": "uuid",
"message": "Explique la différence entre pile et file",
"language": "français",
"level": "B1"
}
How RAG exercise generation works: See Practice Mode Pipeline — pgvector retrieval → reference formatting → Gemini generation → persistence.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /practice/exercise | No | Generate a Bac-style practice exercise from a topic prompt |
POST | /practice/evaluation | Yes | Evaluate a student's answer against an exercise, returns score + AI correction |
Generate exercise:
{ "prompt": "recherche binaire" }
Evaluate answer:
{
"exercise_id": 123,
"student_answer": "Voici ma réponse..."
}
How the ingestion pipeline works: See Ingestion Pipeline for the full flow from PDF upload to vectorized chunks in PostgreSQL.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /ingestion/upload | Admin/Teacher | Upload and ingest a PDF (OCR → classify → chunk → vectorize → persist) |
Upload:
curl -X POST http://localhost:8000/ingestion/upload \
-H "Authorization: Bearer <token>" \
-F "[email protected]"
Response:
{
"filename": "math_2025_principale.pdf",
"status": "success",
"document_type": "exam",
"subject": "math",
"year": 2025,
"session": "principale",
"chunks_count": 42,
"embeddings_count": 42
}
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /exams/ | No | List all exam sections, subjects, sessions, and years |
GET | /exams/search?q=<query> | No | Semantic search over ingested exam exercises (pgvector, top 3 results) |
GET | /exams/download/{section}/{subject}/{session}/{filename} | No | Download an exam PDF (add ?inline=1 for inline display) |
PATCH | /exams/{section}/{subject}/{session}/{filename} | Admin/Teacher | Move/reclassify an exam to a different session |
DELETE | /exams/{section}/{subject}/{session}/{filename} | Admin/Teacher | Delete an exam PDF and its associated DB chunks |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /conversations/ | Yes | Create a new conversation (client-supplied UUID) |
GET | /conversations/ | Yes | List all conversations for the authenticated user |
GET | /conversations/{id}/history | Yes | Get full message history of a conversation |
POST | /conversations/{id}/title | Yes | Generate a concise title from the conversation's first messages |
DELETE | /conversations/{id} | Yes | Delete a conversation and clear its in-memory chat state |
How grammar correction and CEFR assessment works: See Language Assistant — analyze → correct/answer pipeline, 5-language prompt architecture, Whisper voice input.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /language/tutor/start | No | Start a new language session; tutor greets and asks for a topic |
POST | /language/tutor/ | No | Send a message; receives grammar correction or conversational answer |
POST | /language/tutor/assess | No | Grade a finished conversation on the CEFR scale (A1–C1) |
POST | /language/tutor/transcribe | No | Transcribe a voice message via Groq Whisper |
Supported languages: English, French, Spanish, German, Arabic
Start session:
{
"conversation_id": "uuid",
"language": "English",
"level": "B1.1"
}
Send message:
{
"conversation_id": "uuid",
"message": "I goed to the store yesterday"
}
Returns grammar correction: "I went to the store yesterday"
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /code/execute | Yes | Execute Python code in an isolated container and return stdout/stderr/exit code |
Request:
{
"code": "print(sum(range(100)))",
"timeout": 5
}
The executor runs in a sandboxed Docker container with:
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /documents/ | Admin/Teacher | Create a new document entry |
GET | /documents/ | Yes | List all documents |
GET | /documents/{id} | Yes | Get a document by ID |
PATCH | /documents/{id} | Admin/Teacher | Update a document's metadata |
DELETE | /documents/{id} | Admin/Teacher | Delete a document |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /exercises/ | No | Create a new exercise entry |
GET | /exercises/ | No | List all exercises |
GET | /exercises/{id} | No | Get an exercise by ID |
PATCH | /exercises/{id} | No | Update an exercise |
DELETE | /exercises/{id} | No | Delete an exercise |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /performances/ | No | Record a new performance entry |
GET | /performances/{id} | No | Get a performance record by ID |
GET | /performances/user/{user_id} | No | Get all performances for a user |
GET | /performances/user/{user_id}/category/{category} | No | Get a user's performances filtered by category |
DELETE | /performances/{id} | No | Delete a performance record |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /stats/me | Yes | Get practice statistics for the authenticated user |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /models/status | No | Get current embedding model status (loaded, name, TTL, last access) |
POST | /models/warmup | No | Load the embedding model into memory |
POST | /models/reload | No | Reload with a (possibly different) model |
POST | /models/evict | No | Unload the model from memory to free RAM |
PATCH | /models/ttl | No | Update the model's time-to-live before auto-eviction |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /paddle/status | No | Get current PaddleOCR status |
POST | /paddle/warmup | No | Load PaddleOCR into memory |
POST | /paddle/reload | No | Reload PaddleOCR (optionally with a different language) |
POST | /paddle/evict | No | Unload PaddleOCR from memory |
PATCH | /paddle/ttl | No | Update PaddleOCR's time-to-live |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /users/ | Admin | List all users with practice stats |
PATCH | /users/{user_id}/role | Admin | Update a user's role (student/teacher/admin) |
| Column | Type | Constraints |
|---|---|---|
user_id | int | PK, auto-increment |
email | str(255) | UNIQUE, indexed |
password_hash | str(255) | NOT NULL |
first_name | str(100) | nullable |
last_name | str(100) | nullable |
phone_number | str(20) | nullable |
birth_date | date | nullable |
location | str(255) | nullable |
role | str(20) | default "student" |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
id | str | PK (client-supplied UUID) |
user_id | int | nullable |
language | str | nullable |
level | str | nullable |
title | str | nullable |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
id | int | PK, auto-increment |
conversation_id | str | FK → conversations.id, indexed |
role | str | nullable |
content | str | NOT NULL |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
id | str | PK |
content | str | NOT NULL |
embedding_text | str | NOT NULL |
embedding | Vector(384) | NOT NULL (pgvector) |
exercise_type | str | nullable |
points | float | nullable |
document_id | int | FK → document.id, ON DELETE SET NULL |
| Column | Type | Constraints |
|---|---|---|
id | int | PK, auto-increment |
title | str | nullable |
content | str | NOT NULL |
embedding | Vector(384) | nullable (pgvector) |
| Column | Type | Constraints |
|---|---|---|
id | int | PK, auto-increment |
file_path | str | NOT NULL |
latex | str | nullable |
subject | str | nullable |
section | list[str] (JSON) | nullable |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
exercise_id | int | PK, auto-increment |
ex_text | str | NOT NULL |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
performance_id | int | PK, auto-increment |
user_id | int | indexed, NOT NULL |
exercise_id | int | NOT NULL |
category | str(50) | NOT NULL |
score | float | nullable |
max_score | float | nullable |
ai_correction | str | nullable, default '{}' |
raw_response | str | nullable, default '{}' |
attempted_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
id | int | PK, auto-increment |
user_id | int | indexed, NOT NULL |
token_hash | str(64) | UNIQUE, NOT NULL |
expires_at | datetime | NOT NULL |
used | bool | default False |
created_at | datetime | auto |
The production setup uses a multi-stage Dockerfile:
appuser# Build and start
docker compose up --build -d
# View logs
docker compose logs -f bac-api
# Stop
docker compose down
Infrastructure is defined in gcp-vm/:
cd gcp-vm
terraform init
terraform apply -var="project_id=<your-gcp-project>"
Provisions:
europe-west9GitHub Actions (.github/workflows/deploy.yaml) deploys on every push to main:
HF_TOKEN for model download)docker compose up -dRequires repository secrets: GCP_SSH_KEY, GCP_HOST, GCP_USER, HF_TOKEN.
Running a full ML stack on a single budget VM required solving several hard infrastructure problems. This section documents the key challenges and the architectural decisions that made the system viable.
The Problem
The production deployment runs on a GCP e2-medium instance — 2 vCPUs and 4GB RAM. The application needs to load multiple heavy ML models simultaneously:
| Component | Estimated RAM When Loaded |
|---|---|
SentenceTransformer (all-MiniLM-L6-v2) | ~90 MB |
| PaddleOCR (fr) | ~500 MB+ |
| FastAPI + Uvicorn + deps | ~150 MB |
| PostgreSQL 16 (in-container) | ~200 MB |
| Python code executor (in-container) | up to 1 GB |
| Total (all loaded) | ~1.9 GB |
With only 4GB available and OS overhead, loading everything at startup would leave almost no headroom — and with a single worker process, concurrent requests during model loads caused OOM kills.
Solution: Singleton + Lazy Loading
Both the embedding model (model_manager.py) and PaddleOCR (paddle_model_manager.py) use the same pattern — a module-level singleton that is never loaded at startup:
_MODEL = None # Nothing in memory at boot
def get_model(model_name=None):
global _MODEL
with _LOCK:
if _MODEL is None or name != _MODEL_NAME:
_MODEL = SentenceTransformer(name) # Load on first request only
_LAST_USED = time.time()
return _MODEL
threading.Lock (or RLock for PaddleOCR) ensures only one thread loads a model at a time.Solution: TTL-Based Auto-Eviction
Loading on demand solves the startup problem, but a model loaded at 9 AM still sits in memory at 3 AM. A daemon cleanup_worker thread solves this:
def cleanup_worker():
while True:
time.sleep(60) # Check every minute
with _LOCK:
if _MODEL is None:
continue
idle_time = time.time() - _LAST_USED
if idle_time >= TTL: # Default: 1800s (30 min)
_MODEL = None
gc.collect() # Force Python to release memory
EMBED_MODEL_TTL / PADDLE_OCR_TTL).gc.collect() is called to force CPython to release the freed memory back to the OS./models/ttl and /paddle/ttl endpoints without restarting the server.Solution: Two-Layer Conversation Context (Persistence + LRU Cache)
Every chat message must survive server restarts, but querying PostgreSQL on every turn adds ~200ms of latency — unacceptable for a conversational UI. The solution is a two-layer architecture: durable persistence in PostgreSQL as the source of truth, with a fast in-memory LRU cache on top.
Layer 1 — Persistence (PostgreSQL)
Every chat turn is saved to the chat_histories table via log_chat_turn(). Both the user message and assistant response are written in a single transaction — either both succeed or neither does, preventing orphaned partial entries:
# chat_histroy_controller.py
def log_chat_turn(session, conversation_id, user_message, assistant_message):
user_entry = ChatHistory(conversation_id=conversation_id, role="user", content=user_message)
assistant_entry = ChatHistory(conversation_id=conversation_id, role="assistant", content=assistant_message)
session.add(user_entry)
session.add(assistant_entry)
session.commit() # Atomic: both rows written or both rolled back
This guarantees that conversation history is never lost — even if the server crashes mid-response, the committed messages remain in the database. On cache miss or server restart, the last 10 messages are loaded from the DB via get_last_n_messages().
Layer 2 — In-Memory LRU Cache
The AssistantService wraps the database with an OrderedDict-based LRU cache that holds the most recently active conversations in RAM:
# Assistant.py
class AssistantService:
MAX_MESSAGES_PER_CONVERSATION = 10
MAX_CONVERSATIONS_IN_MEMORY = 1000
_cache: OrderedDict[str, List[Dict[str, str]]] = OrderedDict()
def _get_context(self, conversation_id, db):
if conversation_id in self._cache:
self._cache.move_to_end(conversation_id) # Refresh LRU position
return self._cache[conversation_id]
# Cache miss — load from DB, populate cache, return
messages = get_last_n_messages(db, conversation_id, self.max_messages)
self._cache[conversation_id] = [
{"role": m.role, "content": m.content} for m in messages
]
if len(self._cache) > self.max_conversations:
self._cache.popitem(last=False) # Evict oldest conversation
return list(self._cache[conversation_id])
def _add_messages(self, conversation_id, user_message, assistant_message):
# Write-through: append to cache immediately after DB persist
self._cache[conversation_id].append({"role": "user", "content": user_message})
self._cache[conversation_id].append({"role": "assistant", "content": assistant_message})
while len(self._cache[conversation_id]) > self.max_messages:
self._cache[conversation_id].pop(0) # Trim to last 10
self._cache.move_to_end(conversation_id)
The flow for a typical chat turn:
User sends message
│
▼
log_chat_turn() ──► PostgreSQL (persist both messages)
│
▼
_add_messages() ──► In-memory cache (append, trim to 10, refresh LRU)
│
▼
_get_context() ──► On next turn: return from cache (<1ms)
On cache miss: load from DB (~200ms), populate cache
log_chat_turn().Impact: Chat latency drops from ~200ms (DB query per turn) to <1ms for cache hits. Memory stays bounded at ~80MB. Conversation history is never lost — even after a full cache eviction or server restart, the DB is the durable source of truth.
Solution: Model Fallback Chains
When a Gemini model is overloaded (503) or unreachable, the system doesn't fail — it falls back to alternative models:
# call_llm.py — Chat fallback chain
self.fallback_models = [primary_model, "gemini-3.5-flash-lite", "gemini-3.1-flash-lite"]
# practice_mode.py — Exercise generation fallback chain
generate_with_fallback(client, prompt, models=["gemma-4-31b-it", "gemma-4-26b-a4b-it", "gemini-3.5-flash"])
Each fallback attempt includes exponential backoff (2 retries per model with delay doubling) before cascading to the next model. This is implemented in _execute_with_fallbacks() in call_llm.py.
Solution: Sandboxed Code Executor
Student-submitted Python code is the highest memory risk — an infinite loop or a print("x" * 1_000_000_000) can crash the entire server. The executor solves this at two levels:
| Layer | Mechanism | Limits |
|---|---|---|
| Docker | mem_limit, cpus, pids_limit | 1 GB RAM, 1 CPU, 256 PIDs |
| Subprocess | RLIMIT_CPU, RLIMIT_AS, RLIMIT_FSIZE, RLIMIT_NPROC | 30s CPU, 512MB virtual, 64KB output, 64 processes |
Additionally, the executor container lives on an internal Docker network with no internet access and is token-gated — even if a student crafts malicious code, it cannot reach the main API or external services.
def _limits_preexec_fn():
resource.setrlimit(resource.RLIMIT_CPU, (30, 31))
resource.setrlimit(resource.RLIMIT_AS, (512 * 1024 * 1024, 512 * 1024 * 1024))
resource.setrlimit(resource.RLIMIT_FSIZE, (128 * 1024, 128 * 1024))
resource.setrlimit(resource.RLIMIT_NPROC, (64, 64))
Memory Budget Summary
┌─────────────────────────────────────────────────────────┐
│ GCP e2-medium: 4 GB RAM │
├─────────────────────────────────────────────────────────┤
│ OS + Docker overhead ~500 MB │
│ PostgreSQL (pgvector:pg16) ~200 MB │
│ FastAPI + Uvicorn ~150 MB │
│ Python executor (Docker cap) 1 GB (hard limit) │
│ ─────────────────────────────────────────────────────── │
│ ML Models (on-demand, TTL-evicted): │
│ SentenceTransformer ~90 MB (idle = 0) │
│ PaddleOCR ~500 MB (idle = 0) │
│ ─────────────────────────────────────────────────────── │
│ Swap headroom (5GB configured) │
│ │
│ Peak with all models loaded: ~2.4 GB (within 4GB) │
│ Idle with all models evicted: ~0.85 GB (comfortable) │
└─────────────────────────────────────────────────────────┘
Full diagram and flow: See AI Agent Flow for the complete intent classification → model selection → RAG pipeline → response lifecycle.
The Problem
Without intent classification, every chat message — "hi", "what's 2+2?", "explain recursion", "generate an exercise on binary search" — would hit the same expensive LLM endpoint. This created three problems:
Solution: Logistic Regression Intent Classifier
A lightweight ML model classifies each message into one of three intents before any LLM is called:
| Intent | Trigger Examples | Routing Target |
|---|---|---|
practice | "give me an exercise", "practice binary search", "generate a problem" | Gemini → generate + return exercise |
course | "explain recursion", "what is a stack?", "difference between pile and file" | Gemini → conversation-aware answer with context |
other | "hi", "thanks", "what's the weather?", "2+2" | Groq/Llama → fast, free, general response |
The classifier runs entirely in-process with zero LLM calls:
# intent_service.py — 37 lines total
classifier = joblib.load("intent_classifier.pkl")
def classify_intent(text: str):
embedding = get_model().encode([text]) # ~5ms (SentenceTransformer)
intent = classifier.predict(embedding)[0] # ~0.1ms (LogisticRegression)
confidence = max(classifier.predict_proba(embedding)[0])
return {"intent": intent, "confidence": float(confidence)}
Training Pipeline
The model was trained on hand-crafted and curated data:
ML/manipulating_data.py merges all source files into a single intents.json with practice, course, and other keysall-MiniLM-L6-v2 (384-dim vectors) — the same model used for exam search, so no additional model loadingML/logistic_reg.py splits 80/20, trains LogisticRegression(max_iter=1000), prints a classification reportintent_classifier.pkl (~100KB) and loaded once at import time# ML/logistic_reg.py — key steps
X = model.encode(texts) # SentenceTransformer embeddings
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
classifier = LogisticRegression(max_iter=1000)
classifier.fit(X_train, y_train)
joblib.dump(classifier, "intent_classifier.pkl")
Routing Logic in the Chat Controller
The intent classifier is called on every /chat request, and the response is routed accordingly:
# chat_controller.py — 24 lines
async def handle_chat(request, db, client):
result = classify_intent(request.message)
intent = result["intent"]
if intent == "practice":
exercise = generate_exercise_logic(client, db, request.message)
return ChatResponse(response=exercise.ex_text, practice_mode=True, exercise_id=exercise.exercise_id)
elif intent == "course":
return await assistant.handle_chat(request, db) # Gemini with context
else:
return await assistant.handle_chat(request, db, model="groq") # Groq/Llama
practice → Retrieves similar reference exercises from pgvector, generates a new Bac-style exercise via Gemini, stores it, and returns it with a practice_mode=True flag.course → Sends the message + conversation history to Gemini for a course-aware explanation.other → Sends to Groq (Llama 3.3 70B) which is free and faster (< 1s) — perfect for casual conversation.Impact
| Metric | Without Intent Routing | With Intent Routing |
|---|---|---|
| Casual query cost | Gemini token cost (~$0.001/query) | Groq: free |
| Casual query latency | 1-3s (Gemini) | < 1s (Groq) |
| Exercise generation | Manual endpoint only | Automatic via chat |
| System complexity | One LLM path | Three specialized paths |
| Classification overhead | — | ~5ms (negligible) |
The intent classifier itself consumes ~5ms per inference (embedding + logistic regression), which is negligible compared to the 1-3s LLM round-trips it routes around.
| Document | Description |
|---|---|
| docs/backend.md | Backend architecture, ingestion pipeline, practice mode, model management (lazy loading / singleton / TTL) |
| docs/project-overview.md | Full-stack system overview, infrastructure, all components |
| docs/ai-assistant.md | AI tutor usage guide — intent classification, conversation flow, example usage |
| docs/practice-mode.md | Practice mode usage guide — exercise generation, evaluation, model fallback |
| docs/model-routes.md | /models management API — status, warmup, reload, evict, TTL |
| docs/ingestion-pipeline.md | Mermaid diagram: PDF ingestion → storage → vector search → exercise generation → evaluation |
| docs/ai-agent.md | AI agent flow: intent routing, practice mode RAG pipeline, model selection, fallback chains, cost optimization |
| docs/language-assistant.md | Language tutor: grammar correction, voice transcription, CEFR assessment, prompt architecture |
A full-stack AI-powered study platform for Tunisian Baccalaureate students (Computer Science, Math, French). Bac Engine ingests scanned exam PDFs and course summaries into a pgvector-searchable knowledge base, then powers an AI tutor, a practice/evaluation engine, a multi-language tutor, and a sandboxed code executor.
Students can chat with an AI tutor that understands their intent, practice with auto-generated exercises graded by AI, browse and semantically search a growing archive of past exams, and even run Python code — all from a single platform.
┌─────────────────────────────────┐
│ React / Vite Frontend │
│ (JWT Bearer via Axios) │
└──────────────┬──────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ FastAPI Backend (app/) │
│ │
│ Router ──► Controller ──► Service ──► Model │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────────────────┐ │
│ │ Routers │ │ Controllers │ │ Services │ │
│ │ (HTTP + │ │ (business │ │ - LLM (Gemini, Groq) │ │
│ │ auth + │ │ logic) │ │ - Intent classifier (ML) │ │
│ │ valid.) │ │ │ │ - Vector search (pgvector) │ │
│ └──────────┘ └──────────────┘ │ - Ingestion pipeline │ │
│ └────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ SQLModel ORM │ │
│ │ (SQLAlchemy 2) │ │
│ └────────┬─────────┘ │
└─────────────────────────────────────────────┼─────────────────────────┘
│
▼
┌──────────────────────────┐
│ PostgreSQL 16 + pgvector │
└──────────────────────────┘
┌──────────────────────────────────┐
│ Isolated Code Executor │ (internal Docker network only)
│ FastAPI + subprocess + RLIMIT │ (no internet, token-gated)
└──────────────────────────────────┘
Request flow: Frontend sends JWT-authenticated requests to FastAPI routers, which delegate to controllers for business logic, services for LLM/ML/vector operations, and SQLModel for database persistence.
Ingestion pipeline flow (detailed diagram):
input PDF → OCR (Gemini → PaddleOCR fallback) → LaTeX
→ Classify (exam/summary, subject, year, session)
→ Archive original
→ Chunk (exam: structural headers / summary: LaTeX sections)
→ Embed (all-MiniLM-L6-v2, batch 64)
→ Persist (exam_chunks | math_summary_chunks in pgvector)
| Layer | Technology |
|---|---|
| Backend | Python 3.12, FastAPI, SQLModel / SQLAlchemy 2.0 |
| Database | PostgreSQL 16 + pgvector (vector similarity search) |
| LLMs | Google Gemini (primary tutor + OCR), Groq (general chat + language tutor + Whisper) |
| ML (in-process) | sentence-transformers (all-MiniLM-L6-v2 embeddings), scikit-learn (intent classifier), PaddleOCR (OCR fallback) |
| Frontend | React 19, Vite 8, TypeScript 6, Tailwind CSS 3 |
| Frontend Libraries | Axios, react-hook-form, Zod, react-markdown, KaTeX, JSXGraph, mathjs, lucide-react |
| Auth | JWT (HS256 via python-jose), bcrypt (passlib) |
| Deployment | Docker Compose (multi-stage builds), Terraform (GCP), GitHub Actions CI/CD |
| Monitoring | Sentry SDK |
├── app/ # FastAPI backend (main application)
│ ├── main.py # App entry point, CORS, router registration, startup hooks
│ ├── database.py # Sync + async SQLAlchemy engines, pgvector, session deps
│ ├── init_db.py # Standalone table-creation script
│ ├── controller/ # Business logic layer
│ │ ├── chat_controller.py # Intent routing + practice hand-off
│ │ ├── practice_controller.py # Exercise generation + evaluation
│ │ ├── conversation_controller.py # Conversation CRUD + Groq titles
│ │ ├── ingestion_controller.py # Upload validation + document bookkeeping
│ │ ├── language_tutor_controller.py # Tutor turn/start/assess orchestration
│ │ └── ...
│ ├── routers/ # HTTP layer (validation + auth deps)
│ │ ├── auth.py # /auth (login, me, forgot/reset password)
│ │ ├── chat.py # POST /chat
│ │ ├── practice.py # /practice (generate + evaluate)
│ │ ├── exams.py # /exams (browse/search/download/move/delete)
│ │ ├── ingestion.py # /ingestion/upload
│ │ ├── conversation.py # /conversations (CRUD + history + titles)
│ │ ├── language.py # /language/tutor (language tutor + voice)
│ │ ├── stats.py # /stats/me
│ │ ├── models.py # /models (embedding model lifecycle)
│ │ ├── paddle.py # /paddle (PaddleOCR lifecycle)
│ │ ├── document.py # /documents (document CRUD)
│ │ ├── code.py # /code/execute (sandboxed Python)
│ │ ├── exercise.py # /exercises (exercise CRUD)
│ │ ├── performance.py # /performances (performance CRUD)
│ │ └── users.py # /users (admin user management)
│ ├── services/ # LLM / ML / vector search layer
│ │ ├── call_llm.py # AsyncLLMService (Gemini + fallback chain)
│ │ ├── call_groq.py # GroqService (chat + Whisper transcription)
│ │ ├── Assistant.py # Conversation-aware chat with LRU cache
│ │ ├── intent_service.py # Logistic regression intent classifier
│ │ ├── practice_mode.py # Exercise generation (Gemma -> Gemini fallback)
│ │ ├── language_tutor.py # LanguageTutorService (Groq)
│ │ ├── language_prompts.py # Per-language prompt templates
│ │ ├── code_executor.py # HTTP client for isolated executor container
│ │ ├── mail.py # SMTP password-reset email sender
│ │ └── ingestion/ # Document ingestion pipeline
│ │ ├── ingestion_pipeline.py # Full pipeline orchestrator
│ │ ├── ocr_extractor.py # Gemini PDF->LaTeX + PaddleOCR fallback
│ │ ├── doc_classifier.py # Exam/summary + subject/year/session detection
│ │ ├── exam_chunker.py # Structural exam chunker
│ │ ├── summary_chunker.py # LaTeX section-based chunker
│ │ ├── model_manager.py # Embedding model singleton + TTL worker
│ │ ├── paddle_model_manager.py # PaddleOCR singleton + TTL worker
│ │ ├── vectorization.py # Bulk vectorization + pgvector search
│ │ └── exams_index.py # Static JSON exam index, rebuilt at startup
│ ├── models/ # SQLModel database tables
│ ├── schema/ # Pydantic request/response models
│ └── utils/ # Shared utilities (auth, password, client)
├── bac-frontend/ # React/Vite frontend
│ ├── src/
│ │ ├── api/axios.ts # Axios instance, JWT interceptor, 401 handling
│ │ ├── auth/ # AuthContext, authService, ProtectedRoute, AdminRoute
│ │ ├── components/ # Shared UI components
│ │ ├── pages/ # 14 page components
│ │ ├── layouts/ # AuthLayout, DashboardLayout
│ │ ├── hooks/useAuth.ts # useAuth() hook
│ │ └── types/index.ts # All API interfaces
│ ├── package.json
│ └── vite.config.ts
├── executor/ # Isolated Python code-executor microservice
│ ├── app/
│ │ ├── main.py # FastAPI POST /run + GET /health
│ │ └── executor.py # Subprocess runner with RLIMIT resource limits
│ └── Dockerfile # python:3.12-slim, non-root user
├── ML/ # ML training scripts
│ ├── logistic_reg.py # Trains intent classifier
│ └── manipulating_data.py # Merges intent training data
├── docs/ # Detailed documentation
├── gcp-vm/ # Terraform for GCP deployment
├── .github/workflows/deploy.yaml # GitHub Actions CI/CD
├── Dockerfile # Multi-stage: builder + runtime
├── docker-compose.yml # bac-api + python-executor + postgres
└── requirements.txt # Pinned Python dependencies
# 1. Clone the repository
git clone https://github.com/scalexi/tunisian_bac_platform.git
cd bac-engine
# 2. Create the environment file
cp app/.env.example app/.env
# Edit app/.env and set at minimum:
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bac_db
# gemini=<your-gemini-api-key>
# GROQ_API_KEY=<your-groq-api-key>
# SECRET_KEY=<random-long-string>
# CODE_EXECUTOR_TOKEN=<random-secret>
# 3. Start everything
docker compose up --build
# The API will be available at http://localhost:8000
# Interactive docs at http://localhost:8000/docs
Docker Compose runs three services:
docker compose exec postgres psql -U postgres -d bac_db)Backend:
# 1. Create virtual environment
python -m venv .venv
source .venv/bin/activate
# 2. Install dependencies
pip install -r requirements.txt
# 3. Set up environment
cp app/.env.example app/.env
# Edit app/.env (see Environment Variables below)
# 4. Create database tables
cd app && python init_db.py
# 5. Start the server
uvicorn main:app --reload --host 0.0.0.0 --port 8000
Frontend:
cd bac-frontend
# 1. Install dependencies
npm install
# 2. Create .env file
echo "VITE_API_URL=http://localhost:8000" > .env
# 3. Start dev server
npm run dev
# Frontend will be available at http://localhost:5173
| Variable | Default | Description |
|---|---|---|
DATABASE_URL | postgresql://postgres:postgres@localhost:5432/bac_db | PostgreSQL connection string |
gemini | — | Google Gemini API key (primary LLM + OCR) |
GROQ_API_KEY | — | Groq API key (general chat + language tutor + Whisper) |
SECRET_KEY | change-me-in-production-use-a-long-random-string | JWT signing secret (HS256) |
| Variable | Default | Description |
|---|---|---|
TOKEN_EXPIRE_MINUTES | 60 | JWT access-token lifetime in minutes |
GEMINI_MODEL | gemini-3.5-flash | Primary Gemini model (fallback chain: primary → lite → flash-lite) |
GROQ_MODEL | llama-3.3-70b-versatile | Groq model for chat completions |
EMBED_MODEL_TTL | 1800 (30 min) | Embedding model idle TTL before auto-unload (seconds) |
PADDLE_OCR_TTL | 1800 (30 min) | PaddleOCR idle TTL before auto-unload (seconds) |
CODE_EXECUTOR_URL | http://python-executor:8001 | Isolated code executor service URL |
CODE_EXECUTOR_TOKEN | — | Shared secret for code executor auth |
DEEPSEEK_API_KEY | — | DeepSeek API key (experimental) |
MOONSHOT_API_KEY | — | Moonshot AI API key (experimental PDF-to-LaTeX) |
| Variable | Default | Description |
|---|---|---|
SMTP_HOST | — | SMTP server hostname |
SMTP_PORT | 587 | SMTP server port (465=implicit TLS, 587=STARTTLS) |
SMTP_USERNAME | — | SMTP auth username |
SMTP_PASSWORD | — | SMTP auth password |
SMTP_FROM | Bac Engine <[email protected]> | Sender address for outgoing email |
SMTP_TIMEOUT | 15 | SMTP connection timeout (seconds) |
FRONTEND_URL | http://localhost:5173 | Frontend base URL for password-reset links |
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES | 30 | Password-reset token TTL (minutes) |
| Variable | Default | Description |
|---|---|---|
VITE_API_URL | http://localhost:8000 | Backend API base URL |
http://localhost:8000
Interactive API docs are available at /docs (Swagger UI) and /redoc (ReDoc).
All authenticated endpoints expect the header:
Authorization: Bearer <access_token>
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /auth/login | No | Authenticate with email/password, receive JWT |
GET | /auth/me | Yes | Get current user profile |
POST | /auth/forgot-password | No | Request a password reset email |
POST | /auth/reset-password | No | Set new password with emailed token |
How intent routing works: See AI Agent Flow — logistic regression classifies messages into
practice,course, orotherand routes to the right LLM.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /chat | No | Send a message; returns intent-routed AI response (Gemini for course, Groq for general, practice for exercises) |
Request body:
{
"conversation_id": "uuid",
"message": "Explique la différence entre pile et file",
"language": "français",
"level": "B1"
}
How RAG exercise generation works: See Practice Mode Pipeline — pgvector retrieval → reference formatting → Gemini generation → persistence.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /practice/exercise | No | Generate a Bac-style practice exercise from a topic prompt |
POST | /practice/evaluation | Yes | Evaluate a student's answer against an exercise, returns score + AI correction |
Generate exercise:
{ "prompt": "recherche binaire" }
Evaluate answer:
{
"exercise_id": 123,
"student_answer": "Voici ma réponse..."
}
How the ingestion pipeline works: See Ingestion Pipeline for the full flow from PDF upload to vectorized chunks in PostgreSQL.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /ingestion/upload | Admin/Teacher | Upload and ingest a PDF (OCR → classify → chunk → vectorize → persist) |
Upload:
curl -X POST http://localhost:8000/ingestion/upload \
-H "Authorization: Bearer <token>" \
-F "[email protected]"
Response:
{
"filename": "math_2025_principale.pdf",
"status": "success",
"document_type": "exam",
"subject": "math",
"year": 2025,
"session": "principale",
"chunks_count": 42,
"embeddings_count": 42
}
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /exams/ | No | List all exam sections, subjects, sessions, and years |
GET | /exams/search?q=<query> | No | Semantic search over ingested exam exercises (pgvector, top 3 results) |
GET | /exams/download/{section}/{subject}/{session}/{filename} | No | Download an exam PDF (add ?inline=1 for inline display) |
PATCH | /exams/{section}/{subject}/{session}/{filename} | Admin/Teacher | Move/reclassify an exam to a different session |
DELETE | /exams/{section}/{subject}/{session}/{filename} | Admin/Teacher | Delete an exam PDF and its associated DB chunks |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /conversations/ | Yes | Create a new conversation (client-supplied UUID) |
GET | /conversations/ | Yes | List all conversations for the authenticated user |
GET | /conversations/{id}/history | Yes | Get full message history of a conversation |
POST | /conversations/{id}/title | Yes | Generate a concise title from the conversation's first messages |
DELETE | /conversations/{id} | Yes | Delete a conversation and clear its in-memory chat state |
How grammar correction and CEFR assessment works: See Language Assistant — analyze → correct/answer pipeline, 5-language prompt architecture, Whisper voice input.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /language/tutor/start | No | Start a new language session; tutor greets and asks for a topic |
POST | /language/tutor/ | No | Send a message; receives grammar correction or conversational answer |
POST | /language/tutor/assess | No | Grade a finished conversation on the CEFR scale (A1–C1) |
POST | /language/tutor/transcribe | No | Transcribe a voice message via Groq Whisper |
Supported languages: English, French, Spanish, German, Arabic
Start session:
{
"conversation_id": "uuid",
"language": "English",
"level": "B1.1"
}
Send message:
{
"conversation_id": "uuid",
"message": "I goed to the store yesterday"
}
Returns grammar correction: "I went to the store yesterday"
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /code/execute | Yes | Execute Python code in an isolated container and return stdout/stderr/exit code |
Request:
{
"code": "print(sum(range(100)))",
"timeout": 5
}
The executor runs in a sandboxed Docker container with:
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /documents/ | Admin/Teacher | Create a new document entry |
GET | /documents/ | Yes | List all documents |
GET | /documents/{id} | Yes | Get a document by ID |
PATCH | /documents/{id} | Admin/Teacher | Update a document's metadata |
DELETE | /documents/{id} | Admin/Teacher | Delete a document |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /exercises/ | No | Create a new exercise entry |
GET | /exercises/ | No | List all exercises |
GET | /exercises/{id} | No | Get an exercise by ID |
PATCH | /exercises/{id} | No | Update an exercise |
DELETE | /exercises/{id} | No | Delete an exercise |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /performances/ | No | Record a new performance entry |
GET | /performances/{id} | No | Get a performance record by ID |
GET | /performances/user/{user_id} | No | Get all performances for a user |
GET | /performances/user/{user_id}/category/{category} | No | Get a user's performances filtered by category |
DELETE | /performances/{id} | No | Delete a performance record |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /stats/me | Yes | Get practice statistics for the authenticated user |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /models/status | No | Get current embedding model status (loaded, name, TTL, last access) |
POST | /models/warmup | No | Load the embedding model into memory |
POST | /models/reload | No | Reload with a (possibly different) model |
POST | /models/evict | No | Unload the model from memory to free RAM |
PATCH | /models/ttl | No | Update the model's time-to-live before auto-eviction |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /paddle/status | No | Get current PaddleOCR status |
POST | /paddle/warmup | No | Load PaddleOCR into memory |
POST | /paddle/reload | No | Reload PaddleOCR (optionally with a different language) |
POST | /paddle/evict | No | Unload PaddleOCR from memory |
PATCH | /paddle/ttl | No | Update PaddleOCR's time-to-live |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /users/ | Admin | List all users with practice stats |
PATCH | /users/{user_id}/role | Admin | Update a user's role (student/teacher/admin) |
| Column | Type | Constraints |
|---|---|---|
user_id | int | PK, auto-increment |
email | str(255) | UNIQUE, indexed |
password_hash | str(255) | NOT NULL |
first_name | str(100) | nullable |
last_name | str(100) | nullable |
phone_number | str(20) | nullable |
birth_date | date | nullable |
location | str(255) | nullable |
role | str(20) | default "student" |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
id | str | PK (client-supplied UUID) |
user_id | int | nullable |
language | str | nullable |
level | str | nullable |
title | str | nullable |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
id | int | PK, auto-increment |
conversation_id | str | FK → conversations.id, indexed |
role | str | nullable |
content | str | NOT NULL |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
id | str | PK |
content | str | NOT NULL |
embedding_text | str | NOT NULL |
embedding | Vector(384) | NOT NULL (pgvector) |
exercise_type | str | nullable |
points | float | nullable |
document_id | int | FK → document.id, ON DELETE SET NULL |
| Column | Type | Constraints |
|---|---|---|
id | int | PK, auto-increment |
title | str | nullable |
content | str | NOT NULL |
embedding | Vector(384) | nullable (pgvector) |
| Column | Type | Constraints |
|---|---|---|
id | int | PK, auto-increment |
file_path | str | NOT NULL |
latex | str | nullable |
subject | str | nullable |
section | list[str] (JSON) | nullable |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
exercise_id | int | PK, auto-increment |
ex_text | str | NOT NULL |
created_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
performance_id | int | PK, auto-increment |
user_id | int | indexed, NOT NULL |
exercise_id | int | NOT NULL |
category | str(50) | NOT NULL |
score | float | nullable |
max_score | float | nullable |
ai_correction | str | nullable, default '{}' |
raw_response | str | nullable, default '{}' |
attempted_at | datetime | auto |
| Column | Type | Constraints |
|---|---|---|
id | int | PK, auto-increment |
user_id | int | indexed, NOT NULL |
token_hash | str(64) | UNIQUE, NOT NULL |
expires_at | datetime | NOT NULL |
used | bool | default False |
created_at | datetime | auto |
The production setup uses a multi-stage Dockerfile:
appuser# Build and start
docker compose up --build -d
# View logs
docker compose logs -f bac-api
# Stop
docker compose down
Infrastructure is defined in gcp-vm/:
cd gcp-vm
terraform init
terraform apply -var="project_id=<your-gcp-project>"
Provisions:
europe-west9GitHub Actions (.github/workflows/deploy.yaml) deploys on every push to main:
HF_TOKEN for model download)docker compose up -dRequires repository secrets: GCP_SSH_KEY, GCP_HOST, GCP_USER, HF_TOKEN.
Running a full ML stack on a single budget VM required solving several hard infrastructure problems. This section documents the key challenges and the architectural decisions that made the system viable.
The Problem
The production deployment runs on a GCP e2-medium instance — 2 vCPUs and 4GB RAM. The application needs to load multiple heavy ML models simultaneously:
| Component | Estimated RAM When Loaded |
|---|---|
SentenceTransformer (all-MiniLM-L6-v2) | ~90 MB |
| PaddleOCR (fr) | ~500 MB+ |
| FastAPI + Uvicorn + deps | ~150 MB |
| PostgreSQL 16 (in-container) | ~200 MB |
| Python code executor (in-container) | up to 1 GB |
| Total (all loaded) | ~1.9 GB |
With only 4GB available and OS overhead, loading everything at startup would leave almost no headroom — and with a single worker process, concurrent requests during model loads caused OOM kills.
Solution: Singleton + Lazy Loading
Both the embedding model (model_manager.py) and PaddleOCR (paddle_model_manager.py) use the same pattern — a module-level singleton that is never loaded at startup:
_MODEL = None # Nothing in memory at boot
def get_model(model_name=None):
global _MODEL
with _LOCK:
if _MODEL is None or name != _MODEL_NAME:
_MODEL = SentenceTransformer(name) # Load on first request only
_LAST_USED = time.time()
return _MODEL
threading.Lock (or RLock for PaddleOCR) ensures only one thread loads a model at a time.Solution: TTL-Based Auto-Eviction
Loading on demand solves the startup problem, but a model loaded at 9 AM still sits in memory at 3 AM. A daemon cleanup_worker thread solves this:
def cleanup_worker():
while True:
time.sleep(60) # Check every minute
with _LOCK:
if _MODEL is None:
continue
idle_time = time.time() - _LAST_USED
if idle_time >= TTL: # Default: 1800s (30 min)
_MODEL = None
gc.collect() # Force Python to release memory
EMBED_MODEL_TTL / PADDLE_OCR_TTL).gc.collect() is called to force CPython to release the freed memory back to the OS./models/ttl and /paddle/ttl endpoints without restarting the server.Solution: Two-Layer Conversation Context (Persistence + LRU Cache)
Every chat message must survive server restarts, but querying PostgreSQL on every turn adds ~200ms of latency — unacceptable for a conversational UI. The solution is a two-layer architecture: durable persistence in PostgreSQL as the source of truth, with a fast in-memory LRU cache on top.
Layer 1 — Persistence (PostgreSQL)
Every chat turn is saved to the chat_histories table via log_chat_turn(). Both the user message and assistant response are written in a single transaction — either both succeed or neither does, preventing orphaned partial entries:
# chat_histroy_controller.py
def log_chat_turn(session, conversation_id, user_message, assistant_message):
user_entry = ChatHistory(conversation_id=conversation_id, role="user", content=user_message)
assistant_entry = ChatHistory(conversation_id=conversation_id, role="assistant", content=assistant_message)
session.add(user_entry)
session.add(assistant_entry)
session.commit() # Atomic: both rows written or both rolled back
This guarantees that conversation history is never lost — even if the server crashes mid-response, the committed messages remain in the database. On cache miss or server restart, the last 10 messages are loaded from the DB via get_last_n_messages().
Layer 2 — In-Memory LRU Cache
The AssistantService wraps the database with an OrderedDict-based LRU cache that holds the most recently active conversations in RAM:
# Assistant.py
class AssistantService:
MAX_MESSAGES_PER_CONVERSATION = 10
MAX_CONVERSATIONS_IN_MEMORY = 1000
_cache: OrderedDict[str, List[Dict[str, str]]] = OrderedDict()
def _get_context(self, conversation_id, db):
if conversation_id in self._cache:
self._cache.move_to_end(conversation_id) # Refresh LRU position
return self._cache[conversation_id]
# Cache miss — load from DB, populate cache, return
messages = get_last_n_messages(db, conversation_id, self.max_messages)
self._cache[conversation_id] = [
{"role": m.role, "content": m.content} for m in messages
]
if len(self._cache) > self.max_conversations:
self._cache.popitem(last=False) # Evict oldest conversation
return list(self._cache[conversation_id])
def _add_messages(self, conversation_id, user_message, assistant_message):
# Write-through: append to cache immediately after DB persist
self._cache[conversation_id].append({"role": "user", "content": user_message})
self._cache[conversation_id].append({"role": "assistant", "content": assistant_message})
while len(self._cache[conversation_id]) > self.max_messages:
self._cache[conversation_id].pop(0) # Trim to last 10
self._cache.move_to_end(conversation_id)
The flow for a typical chat turn:
User sends message
│
▼
log_chat_turn() ──► PostgreSQL (persist both messages)
│
▼
_add_messages() ──► In-memory cache (append, trim to 10, refresh LRU)
│
▼
_get_context() ──► On next turn: return from cache (<1ms)
On cache miss: load from DB (~200ms), populate cache
log_chat_turn().Impact: Chat latency drops from ~200ms (DB query per turn) to <1ms for cache hits. Memory stays bounded at ~80MB. Conversation history is never lost — even after a full cache eviction or server restart, the DB is the durable source of truth.
Solution: Model Fallback Chains
When a Gemini model is overloaded (503) or unreachable, the system doesn't fail — it falls back to alternative models:
# call_llm.py — Chat fallback chain
self.fallback_models = [primary_model, "gemini-3.5-flash-lite", "gemini-3.1-flash-lite"]
# practice_mode.py — Exercise generation fallback chain
generate_with_fallback(client, prompt, models=["gemma-4-31b-it", "gemma-4-26b-a4b-it", "gemini-3.5-flash"])
Each fallback attempt includes exponential backoff (2 retries per model with delay doubling) before cascading to the next model. This is implemented in _execute_with_fallbacks() in call_llm.py.
Solution: Sandboxed Code Executor
Student-submitted Python code is the highest memory risk — an infinite loop or a print("x" * 1_000_000_000) can crash the entire server. The executor solves this at two levels:
| Layer | Mechanism | Limits |
|---|---|---|
| Docker | mem_limit, cpus, pids_limit | 1 GB RAM, 1 CPU, 256 PIDs |
| Subprocess | RLIMIT_CPU, RLIMIT_AS, RLIMIT_FSIZE, RLIMIT_NPROC | 30s CPU, 512MB virtual, 64KB output, 64 processes |
Additionally, the executor container lives on an internal Docker network with no internet access and is token-gated — even if a student crafts malicious code, it cannot reach the main API or external services.
def _limits_preexec_fn():
resource.setrlimit(resource.RLIMIT_CPU, (30, 31))
resource.setrlimit(resource.RLIMIT_AS, (512 * 1024 * 1024, 512 * 1024 * 1024))
resource.setrlimit(resource.RLIMIT_FSIZE, (128 * 1024, 128 * 1024))
resource.setrlimit(resource.RLIMIT_NPROC, (64, 64))
Memory Budget Summary
┌─────────────────────────────────────────────────────────┐
│ GCP e2-medium: 4 GB RAM │
├─────────────────────────────────────────────────────────┤
│ OS + Docker overhead ~500 MB │
│ PostgreSQL (pgvector:pg16) ~200 MB │
│ FastAPI + Uvicorn ~150 MB │
│ Python executor (Docker cap) 1 GB (hard limit) │
│ ─────────────────────────────────────────────────────── │
│ ML Models (on-demand, TTL-evicted): │
│ SentenceTransformer ~90 MB (idle = 0) │
│ PaddleOCR ~500 MB (idle = 0) │
│ ─────────────────────────────────────────────────────── │
│ Swap headroom (5GB configured) │
│ │
│ Peak with all models loaded: ~2.4 GB (within 4GB) │
│ Idle with all models evicted: ~0.85 GB (comfortable) │
└─────────────────────────────────────────────────────────┘
Full diagram and flow: See AI Agent Flow for the complete intent classification → model selection → RAG pipeline → response lifecycle.
The Problem
Without intent classification, every chat message — "hi", "what's 2+2?", "explain recursion", "generate an exercise on binary search" — would hit the same expensive LLM endpoint. This created three problems:
Solution: Logistic Regression Intent Classifier
A lightweight ML model classifies each message into one of three intents before any LLM is called:
| Intent | Trigger Examples | Routing Target |
|---|---|---|
practice | "give me an exercise", "practice binary search", "generate a problem" | Gemini → generate + return exercise |
course | "explain recursion", "what is a stack?", "difference between pile and file" | Gemini → conversation-aware answer with context |
other | "hi", "thanks", "what's the weather?", "2+2" | Groq/Llama → fast, free, general response |
The classifier runs entirely in-process with zero LLM calls:
# intent_service.py — 37 lines total
classifier = joblib.load("intent_classifier.pkl")
def classify_intent(text: str):
embedding = get_model().encode([text]) # ~5ms (SentenceTransformer)
intent = classifier.predict(embedding)[0] # ~0.1ms (LogisticRegression)
confidence = max(classifier.predict_proba(embedding)[0])
return {"intent": intent, "confidence": float(confidence)}
Training Pipeline
The model was trained on hand-crafted and curated data:
ML/manipulating_data.py merges all source files into a single intents.json with practice, course, and other keysall-MiniLM-L6-v2 (384-dim vectors) — the same model used for exam search, so no additional model loadingML/logistic_reg.py splits 80/20, trains LogisticRegression(max_iter=1000), prints a classification reportintent_classifier.pkl (~100KB) and loaded once at import time# ML/logistic_reg.py — key steps
X = model.encode(texts) # SentenceTransformer embeddings
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
classifier = LogisticRegression(max_iter=1000)
classifier.fit(X_train, y_train)
joblib.dump(classifier, "intent_classifier.pkl")
Routing Logic in the Chat Controller
The intent classifier is called on every /chat request, and the response is routed accordingly:
# chat_controller.py — 24 lines
async def handle_chat(request, db, client):
result = classify_intent(request.message)
intent = result["intent"]
if intent == "practice":
exercise = generate_exercise_logic(client, db, request.message)
return ChatResponse(response=exercise.ex_text, practice_mode=True, exercise_id=exercise.exercise_id)
elif intent == "course":
return await assistant.handle_chat(request, db) # Gemini with context
else:
return await assistant.handle_chat(request, db, model="groq") # Groq/Llama
practice → Retrieves similar reference exercises from pgvector, generates a new Bac-style exercise via Gemini, stores it, and returns it with a practice_mode=True flag.course → Sends the message + conversation history to Gemini for a course-aware explanation.other → Sends to Groq (Llama 3.3 70B) which is free and faster (< 1s) — perfect for casual conversation.Impact
| Metric | Without Intent Routing | With Intent Routing |
|---|---|---|
| Casual query cost | Gemini token cost (~$0.001/query) | Groq: free |
| Casual query latency | 1-3s (Gemini) | < 1s (Groq) |
| Exercise generation | Manual endpoint only | Automatic via chat |
| System complexity | One LLM path | Three specialized paths |
| Classification overhead | — | ~5ms (negligible) |
The intent classifier itself consumes ~5ms per inference (embedding + logistic regression), which is negligible compared to the 1-3s LLM round-trips it routes around.
| Document | Description |
|---|---|
| docs/backend.md | Backend architecture, ingestion pipeline, practice mode, model management (lazy loading / singleton / TTL) |
| docs/project-overview.md | Full-stack system overview, infrastructure, all components |
| docs/ai-assistant.md | AI tutor usage guide — intent classification, conversation flow, example usage |
| docs/practice-mode.md | Practice mode usage guide — exercise generation, evaluation, model fallback |
| docs/model-routes.md | /models management API — status, warmup, reload, evict, TTL |
| docs/ingestion-pipeline.md | Mermaid diagram: PDF ingestion → storage → vector search → exercise generation → evaluation |
| docs/ai-agent.md | AI agent flow: intent routing, practice mode RAG pipeline, model selection, fallback chains, cost optimization |
| docs/language-assistant.md | Language tutor: grammar correction, voice transcription, CEFR assessment, prompt architecture |