Tlili793/bac_app

Python

0

77 commits

updated Sep 28, 2026

See the code

README

Bac Engine — AI Study Assistant

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.


Features

  • AI Tutor — Intent-classified chat (ML logistic regression) that routes questions to practice exercises, course-specific answers (Gemini), or general knowledge (Groq/Llama)
  • Practice Mode — Generate and auto-grade Bac-style exercises with detailed AI corrections and score tracking
  • Document Ingestion — OCR pipeline (Gemini + PaddleOCR fallback) that converts scanned PDFs to LaTeX, classifies them, chunks them structurally, and vectorizes them for semantic search
  • Language Tutor — Multi-language conversation practice (English, French, Spanish, German, Arabic) with grammar correction, CEFR assessment, and voice I/O via Whisper
  • Exam Archive — Browse, search, download, and semantically query thousands of past Bac exams organized by section, subject, and session
  • Sandboxed Code Executor — Run untrusted Python code in an isolated Docker container with resource limits (CPU, memory, processes)
  • Math Graphing — JSXGraph-based function plotting with integral shading for visualizing mathematical concepts
  • Progress Tracking — Practice statistics, performance logs, and score history per user

Architecture

                          ┌─────────────────────────────────┐
                          │       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)

Tech Stack

LayerTechnology
BackendPython 3.12, FastAPI, SQLModel / SQLAlchemy 2.0
DatabasePostgreSQL 16 + pgvector (vector similarity search)
LLMsGoogle 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)
FrontendReact 19, Vite 8, TypeScript 6, Tailwind CSS 3
Frontend LibrariesAxios, react-hook-form, Zod, react-markdown, KaTeX, JSXGraph, mathjs, lucide-react
AuthJWT (HS256 via python-jose), bcrypt (passlib)
DeploymentDocker Compose (multi-stage builds), Terraform (GCP), GitHub Actions CI/CD
MonitoringSentry SDK

Repository Layout

├── 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

Prerequisites

  • Python 3.12
  • Node.js 18+ (for frontend)
  • PostgreSQL 16 with pgvector extension
  • Docker + Docker Compose (for containerized deployment)
  • Google Gemini API key (primary LLM + OCR)
  • Groq API key (general chat + language tutor + audio transcription)

Getting Started

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

  • bac-api (port 8000) — FastAPI backend with pre-downloaded embedding model
  • python-executor (internal only) — Isolated code executor, no internet access, token-gated, 1GB RAM / 1 CPU / 256 PIDs limits
  • postgres — PostgreSQL 16 with pgvector (connect via docker compose exec postgres psql -U postgres -d bac_db)

Local Development

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

Environment Variables

Required

VariableDefaultDescription
DATABASE_URLpostgresql://postgres:postgres@localhost:5432/bac_dbPostgreSQL connection string
gemini—Google Gemini API key (primary LLM + OCR)
GROQ_API_KEY—Groq API key (general chat + language tutor + Whisper)
SECRET_KEYchange-me-in-production-use-a-long-random-stringJWT signing secret (HS256)

Optional

VariableDefaultDescription
TOKEN_EXPIRE_MINUTES60JWT access-token lifetime in minutes
GEMINI_MODELgemini-3.5-flashPrimary Gemini model (fallback chain: primary → lite → flash-lite)
GROQ_MODELllama-3.3-70b-versatileGroq model for chat completions
EMBED_MODEL_TTL1800 (30 min)Embedding model idle TTL before auto-unload (seconds)
PADDLE_OCR_TTL1800 (30 min)PaddleOCR idle TTL before auto-unload (seconds)
CODE_EXECUTOR_URLhttp://python-executor:8001Isolated 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)

Email (Password Reset)

VariableDefaultDescription
SMTP_HOST—SMTP server hostname
SMTP_PORT587SMTP server port (465=implicit TLS, 587=STARTTLS)
SMTP_USERNAME—SMTP auth username
SMTP_PASSWORD—SMTP auth password
SMTP_FROMBac Engine <[email protected]>Sender address for outgoing email
SMTP_TIMEOUT15SMTP connection timeout (seconds)
FRONTEND_URLhttp://localhost:5173Frontend base URL for password-reset links
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES30Password-reset token TTL (minutes)

Frontend

VariableDefaultDescription
VITE_API_URLhttp://localhost:8000Backend API base URL

API Reference

Base URL

http://localhost:8000

Interactive API docs are available at /docs (Swagger UI) and /redoc (ReDoc).

Authentication

All authenticated endpoints expect the header:

Authorization: Bearer <access_token>
MethodEndpointAuthDescription
POST/auth/loginNoAuthenticate with email/password, receive JWT
GET/auth/meYesGet current user profile
POST/auth/forgot-passwordNoRequest a password reset email
POST/auth/reset-passwordNoSet new password with emailed token

AI Chat

How intent routing works: See AI Agent Flow — logistic regression classifies messages into practice, course, or other and routes to the right LLM.

MethodEndpointAuthDescription
POST/chatNoSend 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"
}

Practice Mode

How RAG exercise generation works: See Practice Mode Pipeline — pgvector retrieval → reference formatting → Gemini generation → persistence.

MethodEndpointAuthDescription
POST/practice/exerciseNoGenerate a Bac-style practice exercise from a topic prompt
POST/practice/evaluationYesEvaluate 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..."
}

Document Ingestion

How the ingestion pipeline works: See Ingestion Pipeline for the full flow from PDF upload to vectorized chunks in PostgreSQL.

MethodEndpointAuthDescription
POST/ingestion/uploadAdmin/TeacherUpload 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
}

Exam Archive

MethodEndpointAuthDescription
GET/exams/NoList all exam sections, subjects, sessions, and years
GET/exams/search?q=<query>NoSemantic search over ingested exam exercises (pgvector, top 3 results)
GET/exams/download/{section}/{subject}/{session}/{filename}NoDownload an exam PDF (add ?inline=1 for inline display)
PATCH/exams/{section}/{subject}/{session}/{filename}Admin/TeacherMove/reclassify an exam to a different session
DELETE/exams/{section}/{subject}/{session}/{filename}Admin/TeacherDelete an exam PDF and its associated DB chunks

Conversations

MethodEndpointAuthDescription
POST/conversations/YesCreate a new conversation (client-supplied UUID)
GET/conversations/YesList all conversations for the authenticated user
GET/conversations/{id}/historyYesGet full message history of a conversation
POST/conversations/{id}/titleYesGenerate a concise title from the conversation's first messages
DELETE/conversations/{id}YesDelete a conversation and clear its in-memory chat state

Language Tutor

How grammar correction and CEFR assessment works: See Language Assistant — analyze → correct/answer pipeline, 5-language prompt architecture, Whisper voice input.

MethodEndpointAuthDescription
POST/language/tutor/startNoStart a new language session; tutor greets and asks for a topic
POST/language/tutor/NoSend a message; receives grammar correction or conversational answer
POST/language/tutor/assessNoGrade a finished conversation on the CEFR scale (A1–C1)
POST/language/tutor/transcribeNoTranscribe 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"

Code Executor

MethodEndpointAuthDescription
POST/code/executeYesExecute 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:

  • No internet access (internal network only)
  • Resource limits: 1GB RAM, 1 CPU, 256 PIDs
  • RLIMIT-based file size and CPU time enforcement

Documents

MethodEndpointAuthDescription
POST/documents/Admin/TeacherCreate a new document entry
GET/documents/YesList all documents
GET/documents/{id}YesGet a document by ID
PATCH/documents/{id}Admin/TeacherUpdate a document's metadata
DELETE/documents/{id}Admin/TeacherDelete a document

Exercises

MethodEndpointAuthDescription
POST/exercises/NoCreate a new exercise entry
GET/exercises/NoList all exercises
GET/exercises/{id}NoGet an exercise by ID
PATCH/exercises/{id}NoUpdate an exercise
DELETE/exercises/{id}NoDelete an exercise

Performances

MethodEndpointAuthDescription
POST/performances/NoRecord a new performance entry
GET/performances/{id}NoGet a performance record by ID
GET/performances/user/{user_id}NoGet all performances for a user
GET/performances/user/{user_id}/category/{category}NoGet a user's performances filtered by category
DELETE/performances/{id}NoDelete a performance record

Statistics

MethodEndpointAuthDescription
GET/stats/meYesGet practice statistics for the authenticated user

Embedding Model Management

MethodEndpointAuthDescription
GET/models/statusNoGet current embedding model status (loaded, name, TTL, last access)
POST/models/warmupNoLoad the embedding model into memory
POST/models/reloadNoReload with a (possibly different) model
POST/models/evictNoUnload the model from memory to free RAM
PATCH/models/ttlNoUpdate the model's time-to-live before auto-eviction

PaddleOCR Management

MethodEndpointAuthDescription
GET/paddle/statusNoGet current PaddleOCR status
POST/paddle/warmupNoLoad PaddleOCR into memory
POST/paddle/reloadNoReload PaddleOCR (optionally with a different language)
POST/paddle/evictNoUnload PaddleOCR from memory
PATCH/paddle/ttlNoUpdate PaddleOCR's time-to-live

User Management

MethodEndpointAuthDescription
GET/users/AdminList all users with practice stats
PATCH/users/{user_id}/roleAdminUpdate a user's role (student/teacher/admin)

Database Schema

Users

ColumnTypeConstraints
user_idintPK, auto-increment
emailstr(255)UNIQUE, indexed
password_hashstr(255)NOT NULL
first_namestr(100)nullable
last_namestr(100)nullable
phone_numberstr(20)nullable
birth_datedatenullable
locationstr(255)nullable
rolestr(20)default "student"
created_atdatetimeauto

Conversations

ColumnTypeConstraints
idstrPK (client-supplied UUID)
user_idintnullable
languagestrnullable
levelstrnullable
titlestrnullable
created_atdatetimeauto

Chat Histories

ColumnTypeConstraints
idintPK, auto-increment
conversation_idstrFK → conversations.id, indexed
rolestrnullable
contentstrNOT NULL
created_atdatetimeauto
ColumnTypeConstraints
idstrPK
contentstrNOT NULL
embedding_textstrNOT NULL
embeddingVector(384)NOT NULL (pgvector)
exercise_typestrnullable
pointsfloatnullable
document_idintFK → document.id, ON DELETE SET NULL
ColumnTypeConstraints
idintPK, auto-increment
titlestrnullable
contentstrNOT NULL
embeddingVector(384)nullable (pgvector)

Documents

ColumnTypeConstraints
idintPK, auto-increment
file_pathstrNOT NULL
latexstrnullable
subjectstrnullable
sectionlist[str] (JSON)nullable
created_atdatetimeauto

Exercises

ColumnTypeConstraints
exercise_idintPK, auto-increment
ex_textstrNOT NULL
created_atdatetimeauto

Performances

ColumnTypeConstraints
performance_idintPK, auto-increment
user_idintindexed, NOT NULL
exercise_idintNOT NULL
categorystr(50)NOT NULL
scorefloatnullable
max_scorefloatnullable
ai_correctionstrnullable, default '{}'
raw_responsestrnullable, default '{}'
attempted_atdatetimeauto

Password Reset Tokens

ColumnTypeConstraints
idintPK, auto-increment
user_idintindexed, NOT NULL
token_hashstr(64)UNIQUE, NOT NULL
expires_atdatetimeNOT NULL
usedbooldefault False
created_atdatetimeauto

Deployment

Docker Compose

The production setup uses a multi-stage Dockerfile:

  1. Builder stage — Installs build tools, CPU-only PyTorch, and all Python dependencies
  2. Runtime stage — Slim image with system libs (poppler, texlive, libpq), pre-downloaded sentence-transformers model, runs as non-root appuser
# Build and start
docker compose up --build -d

# View logs
docker compose logs -f bac-api

# Stop
docker compose down

GCP with Terraform

Infrastructure is defined in gcp-vm/:

cd gcp-vm
terraform init
terraform apply -var="project_id=<your-gcp-project>"

Provisions:

  • e2-medium VM (2 vCPUs, 4GB RAM)
  • Ubuntu 24.04 with 33GB boot disk
  • 5GB swap for model loading headroom
  • Static IP in europe-west9

CI/CD

GitHub Actions (.github/workflows/deploy.yaml) deploys on every push to main:

  1. SSH into the GCP VM
  2. Pull latest code
  3. Build Docker images (with HF_TOKEN for model download)
  4. Recreate containers with docker compose up -d
  5. Prune unused images

Requires repository secrets: GCP_SSH_KEY, GCP_HOST, GCP_USER, HF_TOKEN.


Engineering Challenges & Solutions

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.

Challenge 1: Memory Management on a 4GB VM

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:

ComponentEstimated 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
  • Models are loaded on the first request that needs them, not at application startup.
  • A threading.Lock (or RLock for PaddleOCR) ensures only one thread loads a model at a time.
  • The model is stored in a module-level global — subsequent requests return the cached instance instantly.
  • Impact: Idle deployments consume ~0MB for ML. Memory is only used when students are actively uploading or searching.

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
  • A background thread checks every 60 seconds whether any model has been idle longer than its TTL (default 30 minutes, configurable via EMBED_MODEL_TTL / PADDLE_OCR_TTL).
  • When a model is evicted, gc.collect() is called to force CPython to release the freed memory back to the OS.
  • TTL can be adjusted at runtime via the /models/ttl and /paddle/ttl endpoints without restarting the server.
  • Impact: After a burst of activity, memory is reclaimed within 30 minutes. The VM stays responsive for other services.

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
  • Cache size: Hard cap of 1000 conversations × 10 messages = ~80MB max.
  • Eviction: When the cache exceeds 1000 conversations, the least-recently-used conversation is evicted (but remains safe in PostgreSQL).
  • Persistence guarantee: The cache is a read-through cache — it never writes to the DB itself; persistence always happens first via 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.

  • Impact: System stays available during Gemini outages. Smaller fallback models (flash-lite) also reduce per-request cost.

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:

LayerMechanismLimits
Dockermem_limit, cpus, pids_limit1 GB RAM, 1 CPU, 256 PIDs
SubprocessRLIMIT_CPU, RLIMIT_AS, RLIMIT_FSIZE, RLIMIT_NPROC30s 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))
  • Impact: No student code can crash the main API, consume unbounded memory, or access the network.

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)   │
└─────────────────────────────────────────────────────────┘

Challenge 2: Intelligent Intent Routing with ML

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:

  1. Cost: Gemini charges per token. A casual "hello" generating a full tutor response wastes tokens.
  2. Latency: Gemini takes 1-3 seconds. For simple greetings, this feels broken.
  3. Wrong behavior: A student saying "give me an exercise" should get a generated exercise, not a text explanation.

Solution: Logistic Regression Intent Classifier

A lightweight ML model classifies each message into one of three intents before any LLM is called:

IntentTrigger ExamplesRouting 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:

  1. Data collection — Multiple JSON files with example messages per intent, covering Tunisian French/Arabic/English patterns
  2. Merging — ML/manipulating_data.py merges all source files into a single intents.json with practice, course, and other keys
  3. Embedding — All texts are encoded with all-MiniLM-L6-v2 (384-dim vectors) — the same model used for exam search, so no additional model loading
  4. Training — ML/logistic_reg.py splits 80/20, trains LogisticRegression(max_iter=1000), prints a classification report
  5. Serialization — The trained classifier is saved to intent_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

MetricWithout Intent RoutingWith Intent Routing
Casual query costGemini token cost (~$0.001/query)Groq: free
Casual query latency1-3s (Gemini)< 1s (Groq)
Exercise generationManual endpoint onlyAutomatic via chat
System complexityOne LLM pathThree 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.


Documentation

DocumentDescription
docs/backend.mdBackend architecture, ingestion pipeline, practice mode, model management (lazy loading / singleton / TTL)
docs/project-overview.mdFull-stack system overview, infrastructure, all components
docs/ai-assistant.mdAI tutor usage guide — intent classification, conversation flow, example usage
docs/practice-mode.mdPractice mode usage guide — exercise generation, evaluation, model fallback
docs/model-routes.md/models management API — status, warmup, reload, evict, TTL
docs/ingestion-pipeline.mdMermaid diagram: PDF ingestion → storage → vector search → exercise generation → evaluation
docs/ai-agent.mdAI agent flow: intent routing, practice mode RAG pipeline, model selection, fallback chains, cost optimization
docs/language-assistant.mdLanguage tutor: grammar correction, voice transcription, CEFR assessment, prompt architecture

Tlili793/bac_app

Python

0

77 commits

updated Sep 28, 2026

See the code

README

Bac Engine — AI Study Assistant

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.


Features

  • AI Tutor — Intent-classified chat (ML logistic regression) that routes questions to practice exercises, course-specific answers (Gemini), or general knowledge (Groq/Llama)
  • Practice Mode — Generate and auto-grade Bac-style exercises with detailed AI corrections and score tracking
  • Document Ingestion — OCR pipeline (Gemini + PaddleOCR fallback) that converts scanned PDFs to LaTeX, classifies them, chunks them structurally, and vectorizes them for semantic search
  • Language Tutor — Multi-language conversation practice (English, French, Spanish, German, Arabic) with grammar correction, CEFR assessment, and voice I/O via Whisper
  • Exam Archive — Browse, search, download, and semantically query thousands of past Bac exams organized by section, subject, and session
  • Sandboxed Code Executor — Run untrusted Python code in an isolated Docker container with resource limits (CPU, memory, processes)
  • Math Graphing — JSXGraph-based function plotting with integral shading for visualizing mathematical concepts
  • Progress Tracking — Practice statistics, performance logs, and score history per user

Architecture

                          ┌─────────────────────────────────┐
                          │       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)

Tech Stack

LayerTechnology
BackendPython 3.12, FastAPI, SQLModel / SQLAlchemy 2.0
DatabasePostgreSQL 16 + pgvector (vector similarity search)
LLMsGoogle 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)
FrontendReact 19, Vite 8, TypeScript 6, Tailwind CSS 3
Frontend LibrariesAxios, react-hook-form, Zod, react-markdown, KaTeX, JSXGraph, mathjs, lucide-react
AuthJWT (HS256 via python-jose), bcrypt (passlib)
DeploymentDocker Compose (multi-stage builds), Terraform (GCP), GitHub Actions CI/CD
MonitoringSentry SDK

Repository Layout

├── 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

Prerequisites

  • Python 3.12
  • Node.js 18+ (for frontend)
  • PostgreSQL 16 with pgvector extension
  • Docker + Docker Compose (for containerized deployment)
  • Google Gemini API key (primary LLM + OCR)
  • Groq API key (general chat + language tutor + audio transcription)

Getting Started

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

  • bac-api (port 8000) — FastAPI backend with pre-downloaded embedding model
  • python-executor (internal only) — Isolated code executor, no internet access, token-gated, 1GB RAM / 1 CPU / 256 PIDs limits
  • postgres — PostgreSQL 16 with pgvector (connect via docker compose exec postgres psql -U postgres -d bac_db)

Local Development

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

Environment Variables

Required

VariableDefaultDescription
DATABASE_URLpostgresql://postgres:postgres@localhost:5432/bac_dbPostgreSQL connection string
gemini—Google Gemini API key (primary LLM + OCR)
GROQ_API_KEY—Groq API key (general chat + language tutor + Whisper)
SECRET_KEYchange-me-in-production-use-a-long-random-stringJWT signing secret (HS256)

Optional

VariableDefaultDescription
TOKEN_EXPIRE_MINUTES60JWT access-token lifetime in minutes
GEMINI_MODELgemini-3.5-flashPrimary Gemini model (fallback chain: primary → lite → flash-lite)
GROQ_MODELllama-3.3-70b-versatileGroq model for chat completions
EMBED_MODEL_TTL1800 (30 min)Embedding model idle TTL before auto-unload (seconds)
PADDLE_OCR_TTL1800 (30 min)PaddleOCR idle TTL before auto-unload (seconds)
CODE_EXECUTOR_URLhttp://python-executor:8001Isolated 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)

Email (Password Reset)

VariableDefaultDescription
SMTP_HOST—SMTP server hostname
SMTP_PORT587SMTP server port (465=implicit TLS, 587=STARTTLS)
SMTP_USERNAME—SMTP auth username
SMTP_PASSWORD—SMTP auth password
SMTP_FROMBac Engine <[email protected]>Sender address for outgoing email
SMTP_TIMEOUT15SMTP connection timeout (seconds)
FRONTEND_URLhttp://localhost:5173Frontend base URL for password-reset links
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES30Password-reset token TTL (minutes)

Frontend

VariableDefaultDescription
VITE_API_URLhttp://localhost:8000Backend API base URL

API Reference

Base URL

http://localhost:8000

Interactive API docs are available at /docs (Swagger UI) and /redoc (ReDoc).

Authentication

All authenticated endpoints expect the header:

Authorization: Bearer <access_token>
MethodEndpointAuthDescription
POST/auth/loginNoAuthenticate with email/password, receive JWT
GET/auth/meYesGet current user profile
POST/auth/forgot-passwordNoRequest a password reset email
POST/auth/reset-passwordNoSet new password with emailed token

AI Chat

How intent routing works: See AI Agent Flow — logistic regression classifies messages into practice, course, or other and routes to the right LLM.

MethodEndpointAuthDescription
POST/chatNoSend 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"
}

Practice Mode

How RAG exercise generation works: See Practice Mode Pipeline — pgvector retrieval → reference formatting → Gemini generation → persistence.

MethodEndpointAuthDescription
POST/practice/exerciseNoGenerate a Bac-style practice exercise from a topic prompt
POST/practice/evaluationYesEvaluate 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..."
}

Document Ingestion

How the ingestion pipeline works: See Ingestion Pipeline for the full flow from PDF upload to vectorized chunks in PostgreSQL.

MethodEndpointAuthDescription
POST/ingestion/uploadAdmin/TeacherUpload 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
}

Exam Archive

MethodEndpointAuthDescription
GET/exams/NoList all exam sections, subjects, sessions, and years
GET/exams/search?q=<query>NoSemantic search over ingested exam exercises (pgvector, top 3 results)
GET/exams/download/{section}/{subject}/{session}/{filename}NoDownload an exam PDF (add ?inline=1 for inline display)
PATCH/exams/{section}/{subject}/{session}/{filename}Admin/TeacherMove/reclassify an exam to a different session
DELETE/exams/{section}/{subject}/{session}/{filename}Admin/TeacherDelete an exam PDF and its associated DB chunks

Conversations

MethodEndpointAuthDescription
POST/conversations/YesCreate a new conversation (client-supplied UUID)
GET/conversations/YesList all conversations for the authenticated user
GET/conversations/{id}/historyYesGet full message history of a conversation
POST/conversations/{id}/titleYesGenerate a concise title from the conversation's first messages
DELETE/conversations/{id}YesDelete a conversation and clear its in-memory chat state

Language Tutor

How grammar correction and CEFR assessment works: See Language Assistant — analyze → correct/answer pipeline, 5-language prompt architecture, Whisper voice input.

MethodEndpointAuthDescription
POST/language/tutor/startNoStart a new language session; tutor greets and asks for a topic
POST/language/tutor/NoSend a message; receives grammar correction or conversational answer
POST/language/tutor/assessNoGrade a finished conversation on the CEFR scale (A1–C1)
POST/language/tutor/transcribeNoTranscribe 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"

Code Executor

MethodEndpointAuthDescription
POST/code/executeYesExecute 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:

  • No internet access (internal network only)
  • Resource limits: 1GB RAM, 1 CPU, 256 PIDs
  • RLIMIT-based file size and CPU time enforcement

Documents

MethodEndpointAuthDescription
POST/documents/Admin/TeacherCreate a new document entry
GET/documents/YesList all documents
GET/documents/{id}YesGet a document by ID
PATCH/documents/{id}Admin/TeacherUpdate a document's metadata
DELETE/documents/{id}Admin/TeacherDelete a document

Exercises

MethodEndpointAuthDescription
POST/exercises/NoCreate a new exercise entry
GET/exercises/NoList all exercises
GET/exercises/{id}NoGet an exercise by ID
PATCH/exercises/{id}NoUpdate an exercise
DELETE/exercises/{id}NoDelete an exercise

Performances

MethodEndpointAuthDescription
POST/performances/NoRecord a new performance entry
GET/performances/{id}NoGet a performance record by ID
GET/performances/user/{user_id}NoGet all performances for a user
GET/performances/user/{user_id}/category/{category}NoGet a user's performances filtered by category
DELETE/performances/{id}NoDelete a performance record

Statistics

MethodEndpointAuthDescription
GET/stats/meYesGet practice statistics for the authenticated user

Embedding Model Management

MethodEndpointAuthDescription
GET/models/statusNoGet current embedding model status (loaded, name, TTL, last access)
POST/models/warmupNoLoad the embedding model into memory
POST/models/reloadNoReload with a (possibly different) model
POST/models/evictNoUnload the model from memory to free RAM
PATCH/models/ttlNoUpdate the model's time-to-live before auto-eviction

PaddleOCR Management

MethodEndpointAuthDescription
GET/paddle/statusNoGet current PaddleOCR status
POST/paddle/warmupNoLoad PaddleOCR into memory
POST/paddle/reloadNoReload PaddleOCR (optionally with a different language)
POST/paddle/evictNoUnload PaddleOCR from memory
PATCH/paddle/ttlNoUpdate PaddleOCR's time-to-live

User Management

MethodEndpointAuthDescription
GET/users/AdminList all users with practice stats
PATCH/users/{user_id}/roleAdminUpdate a user's role (student/teacher/admin)

Database Schema

Users

ColumnTypeConstraints
user_idintPK, auto-increment
emailstr(255)UNIQUE, indexed
password_hashstr(255)NOT NULL
first_namestr(100)nullable
last_namestr(100)nullable
phone_numberstr(20)nullable
birth_datedatenullable
locationstr(255)nullable
rolestr(20)default "student"
created_atdatetimeauto

Conversations

ColumnTypeConstraints
idstrPK (client-supplied UUID)
user_idintnullable
languagestrnullable
levelstrnullable
titlestrnullable
created_atdatetimeauto

Chat Histories

ColumnTypeConstraints
idintPK, auto-increment
conversation_idstrFK → conversations.id, indexed
rolestrnullable
contentstrNOT NULL
created_atdatetimeauto
ColumnTypeConstraints
idstrPK
contentstrNOT NULL
embedding_textstrNOT NULL
embeddingVector(384)NOT NULL (pgvector)
exercise_typestrnullable
pointsfloatnullable
document_idintFK → document.id, ON DELETE SET NULL
ColumnTypeConstraints
idintPK, auto-increment
titlestrnullable
contentstrNOT NULL
embeddingVector(384)nullable (pgvector)

Documents

ColumnTypeConstraints
idintPK, auto-increment
file_pathstrNOT NULL
latexstrnullable
subjectstrnullable
sectionlist[str] (JSON)nullable
created_atdatetimeauto

Exercises

ColumnTypeConstraints
exercise_idintPK, auto-increment
ex_textstrNOT NULL
created_atdatetimeauto

Performances

ColumnTypeConstraints
performance_idintPK, auto-increment
user_idintindexed, NOT NULL
exercise_idintNOT NULL
categorystr(50)NOT NULL
scorefloatnullable
max_scorefloatnullable
ai_correctionstrnullable, default '{}'
raw_responsestrnullable, default '{}'
attempted_atdatetimeauto

Password Reset Tokens

ColumnTypeConstraints
idintPK, auto-increment
user_idintindexed, NOT NULL
token_hashstr(64)UNIQUE, NOT NULL
expires_atdatetimeNOT NULL
usedbooldefault False
created_atdatetimeauto

Deployment

Docker Compose

The production setup uses a multi-stage Dockerfile:

  1. Builder stage — Installs build tools, CPU-only PyTorch, and all Python dependencies
  2. Runtime stage — Slim image with system libs (poppler, texlive, libpq), pre-downloaded sentence-transformers model, runs as non-root appuser
# Build and start
docker compose up --build -d

# View logs
docker compose logs -f bac-api

# Stop
docker compose down

GCP with Terraform

Infrastructure is defined in gcp-vm/:

cd gcp-vm
terraform init
terraform apply -var="project_id=<your-gcp-project>"

Provisions:

  • e2-medium VM (2 vCPUs, 4GB RAM)
  • Ubuntu 24.04 with 33GB boot disk
  • 5GB swap for model loading headroom
  • Static IP in europe-west9

CI/CD

GitHub Actions (.github/workflows/deploy.yaml) deploys on every push to main:

  1. SSH into the GCP VM
  2. Pull latest code
  3. Build Docker images (with HF_TOKEN for model download)
  4. Recreate containers with docker compose up -d
  5. Prune unused images

Requires repository secrets: GCP_SSH_KEY, GCP_HOST, GCP_USER, HF_TOKEN.


Engineering Challenges & Solutions

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.

Challenge 1: Memory Management on a 4GB VM

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:

ComponentEstimated 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
  • Models are loaded on the first request that needs them, not at application startup.
  • A threading.Lock (or RLock for PaddleOCR) ensures only one thread loads a model at a time.
  • The model is stored in a module-level global — subsequent requests return the cached instance instantly.
  • Impact: Idle deployments consume ~0MB for ML. Memory is only used when students are actively uploading or searching.

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
  • A background thread checks every 60 seconds whether any model has been idle longer than its TTL (default 30 minutes, configurable via EMBED_MODEL_TTL / PADDLE_OCR_TTL).
  • When a model is evicted, gc.collect() is called to force CPython to release the freed memory back to the OS.
  • TTL can be adjusted at runtime via the /models/ttl and /paddle/ttl endpoints without restarting the server.
  • Impact: After a burst of activity, memory is reclaimed within 30 minutes. The VM stays responsive for other services.

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
  • Cache size: Hard cap of 1000 conversations × 10 messages = ~80MB max.
  • Eviction: When the cache exceeds 1000 conversations, the least-recently-used conversation is evicted (but remains safe in PostgreSQL).
  • Persistence guarantee: The cache is a read-through cache — it never writes to the DB itself; persistence always happens first via 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.

  • Impact: System stays available during Gemini outages. Smaller fallback models (flash-lite) also reduce per-request cost.

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:

LayerMechanismLimits
Dockermem_limit, cpus, pids_limit1 GB RAM, 1 CPU, 256 PIDs
SubprocessRLIMIT_CPU, RLIMIT_AS, RLIMIT_FSIZE, RLIMIT_NPROC30s 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))
  • Impact: No student code can crash the main API, consume unbounded memory, or access the network.

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)   │
└─────────────────────────────────────────────────────────┘

Challenge 2: Intelligent Intent Routing with ML

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:

  1. Cost: Gemini charges per token. A casual "hello" generating a full tutor response wastes tokens.
  2. Latency: Gemini takes 1-3 seconds. For simple greetings, this feels broken.
  3. Wrong behavior: A student saying "give me an exercise" should get a generated exercise, not a text explanation.

Solution: Logistic Regression Intent Classifier

A lightweight ML model classifies each message into one of three intents before any LLM is called:

IntentTrigger ExamplesRouting 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:

  1. Data collection — Multiple JSON files with example messages per intent, covering Tunisian French/Arabic/English patterns
  2. Merging — ML/manipulating_data.py merges all source files into a single intents.json with practice, course, and other keys
  3. Embedding — All texts are encoded with all-MiniLM-L6-v2 (384-dim vectors) — the same model used for exam search, so no additional model loading
  4. Training — ML/logistic_reg.py splits 80/20, trains LogisticRegression(max_iter=1000), prints a classification report
  5. Serialization — The trained classifier is saved to intent_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

MetricWithout Intent RoutingWith Intent Routing
Casual query costGemini token cost (~$0.001/query)Groq: free
Casual query latency1-3s (Gemini)< 1s (Groq)
Exercise generationManual endpoint onlyAutomatic via chat
System complexityOne LLM pathThree 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.


Documentation

DocumentDescription
docs/backend.mdBackend architecture, ingestion pipeline, practice mode, model management (lazy loading / singleton / TTL)
docs/project-overview.mdFull-stack system overview, infrastructure, all components
docs/ai-assistant.mdAI tutor usage guide — intent classification, conversation flow, example usage
docs/practice-mode.mdPractice mode usage guide — exercise generation, evaluation, model fallback
docs/model-routes.md/models management API — status, warmup, reload, evict, TTL
docs/ingestion-pipeline.mdMermaid diagram: PDF ingestion → storage → vector search → exercise generation → evaluation
docs/ai-agent.mdAI agent flow: intent routing, practice mode RAG pipeline, model selection, fallback chains, cost optimization
docs/language-assistant.mdLanguage tutor: grammar correction, voice transcription, CEFR assessment, prompt architecture