À la session 100, Deblo.ai était passé d'une simple démo de chat mono-page à une plateforme éducative complète avec 24+ tables de base de données, 18 modules de routes API, 60+ composants frontend, une application mobile React Native, du streaming SSE avec 20+ types d'événements, des tâches de génération en arrière-plan, des appels vocaux et un système de paiement couvrant six pays. Voici à quoi ressemble cette architecture de l'intérieur.
Le backend : FastAPI de bout en bout
Nous avons choisi FastAPI pour une seule raison : l'asynchrone. Une plateforme d'éducation IA est fondamentalement un système limité par les E/S. Chaque requête de chat implique au moins un appel HTTP à un fournisseur LLM, souvent suivi d'exécutions d'outils (recherches web, génération de fichiers, envoi d'e-mails) qui impliquent chacune leurs propres appels réseau. Un framework synchrone passerait la majeure partie de son temps à attendre.
FastAPI avec async SQLAlchemy nous donne du multitâche coopératif à chaque couche. Les requêtes à la base de données sont asynchrones. Les appels HTTP vers OpenRouter sont asynchrones. Le streaming SSE est asynchrone. Le polling des tâches de fond est asynchrone. Un seul processus worker peut gérer des centaines de sessions de chat simultanées parce qu'aucune d'entre elles ne bloque un thread.
La structure de l'application est volontairement plate :
backend/app/
main.py # Application FastAPI, CORS, assemblage des routeurs, lifespan
config.py # Configuration depuis les variables d'environnement
database.py # Engine async SQLAlchemy, session factory, pool Redis
models/ # 24 modèles SQLAlchemy
routes/ # 18 modules de routes API
services/ # 40+ modules de services (LLM, outils, paiements, e-mail, etc.)
prompts/ # Composants de prompt système (racine, classes, matières, catégories, pro)
seed.py # Données d'amorçage pour le programme scolairePas de classes de base abstraites. Pas de framework d'injection de dépendances. Pas d'architecture « clean architecture » hexagonale ports-et-adaptateurs. Chaque fichier de route importe directement les services dont il a besoin. Chaque fichier de service importe directement les modèles dont il a besoin. Le graphe d'appels est évident à la lecture des imports.
La base de données : 24 tables, une colonne JSONB qui fait la différence
PostgreSQL 17 est la seule base de données. Pas de MongoDB pour les « schémas flexibles ». Pas de DynamoDB pour la « scalabilité ». PostgreSQL fait tout ce dont nous avons besoin, et il le fait avec des garanties ACID qui comptent quand on suit des transactions financières (grand livre des crédits) et la progression pédagogique (résultats d'exercices).
Le modèle le plus important est User :
pythonclass User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
phone = Column(String(20), unique=True, nullable=True, index=True)
email = Column(String(255), unique=True, nullable=True, index=True)
google_id = Column(String(255), unique=True, nullable=True, index=True)
auth_provider = Column(String(20), nullable=True)
name = Column(String(100), nullable=True)
preferred_class = Column(String(20), nullable=True)
user_type = Column(String(20), nullable=True) # 'child' | 'parent' | 'professional'
credit_balance = Column(Integer, default=0)
free_credits_today = Column(Integer, default=5)
free_credits_date = Column(Date, nullable=True)
country = Column(String(5), nullable=True)
preferred_language = Column(String(5), default="fr")
referral_code = Column(String(5), unique=True, nullable=True)
access_code = Column(String(12), unique=True, nullable=True)
push_token = Column(String(500), nullable=True)
# ... timestamps, flags admin, etc.Trois chemins d'authentification convergent vers ce modèle unique : téléphone (WhatsApp OTP), e-mail (Google OAuth) et code d'accès (pour les membres d'organisation qui n'ont ni téléphone ni e-mail). Le champ user_type détermine quel mode l'utilisateur voit par défaut. Le champ preferred_class verrouille l'adaptation par niveau scolaire. Les champs credit_balance et free_credits_today sont sur le chemin critique de chaque requête de chat.
Le deuxième modèle le plus important est Conversation, et il contient la décision de conception qui a le plus façonné notre architecture :
pythonclass Conversation(Base):
__tablename__ = "conversations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
class_id = Column(String(20), nullable=True)
subject = Column(String(50), nullable=True)
mode = Column(String(10), nullable=True) # "child" | "pro"
domain = Column(String(50), nullable=True) # "syscohada" | "fiscalite" | etc.
category = Column(String(20), nullable=True) # "question" | "exercice" | "devoir" | "examen"
agent = Column(String(50), nullable=True) # identifiant de l'agent pro
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True)
messages = Column(JSONB, default=[])
message_count = Column(Integer, default=0)
# ... timestamps, flags archive/favoriLa colonne messages est en JSONB. Chaque message d'une conversation -- utilisateur, assistant, appels d'outils, résultats d'outils -- réside dans un seul tableau JSON sur la ligne de la conversation. Pas de table messages. Pas de jointures.
C'était un compromis délibéré. L'alternative était une table messages normalisée avec des clés étrangères vers les conversations. Ce serait la conception relationnelle « correcte ». Mais considérons le patron d'accès : chaque requête de chat charge l'intégralité de l'historique de conversation pour l'envoyer au LLM comme contexte. Avec une conception normalisée, c'est une requête avec jointure pour chaque message. Avec JSONB, c'est une récupération d'une seule ligne. Sur une plateforme où la conversation médiane a 10 à 20 messages et où le chemin critique est le streaming SSE sensible à la latence, éliminer cette jointure compte.
L'inconvénient est que la mise à jour d'un seul message nécessite de réécrire le tableau JSONB entier. Nous l'avons accepté parce que les mises à jour de messages sont rares (seule la génération automatique de titre et les opérations d'archivage), tandis que les lectures de l'historique complet se produisent à chaque requête de chat.
La liste complète des tables couvre les domaines de la plateforme :
- Auth :
users,otp_codes - Chat :
conversations - Crédits :
credit_purchases,credit_usages,credit_ledger,coupons - Fichiers :
uploaded_files,file_folders,document_chunks - IA :
ai_logs,ai_memories,generation_jobs - Pédagogie :
exercise_results,daily_suggestions - Organisation :
organizations,org_memberships - Tâches :
tasks - Programme scolaire :
curriculum(données d'amorçage) - Communication :
notification_templates,user_notifications - Voix :
voice_sessions - Projets :
projects - Parrainage :
referrals - Paramètres :
system_settings
Le frontend : SvelteKit 2 + Svelte 5 Runes
Le frontend est une application SvelteKit 2 utilisant les runes Svelte 5 partout. Pas de réactivité héritée avec let. Pas de stores Svelte 4 dans le nouveau code. Tout utilise $state, $derived, $props et $effect.
Le nombre de composants dépasse 60, organisés par fonctionnalité :
- Composants chat : Bulles de messages, barre de saisie, upload de fichiers, enregistrement vocal, widgets de quiz, indicateurs de progression des outils, blocs de code avec coloration syntaxique
- Composants layout : Header, sidebar, navigation, toggle de thème, tiroir mobile
- Composants crédits : Affichage du solde, modale de recharge, cartes de forfaits, historique des transactions
- Composants admin : Tableau de bord, gestion des utilisateurs, visualiseur de conversations, éditeur de paramètres, analytics
- Composants auth : Saisie du téléphone, vérification OTP, connexion Google, sélecteur de pays
La gestion d'état utilise des stores Svelte writable avec persistance localStorage pour l'état côté client (thème, langue, sidebar réduite) et des appels API côté serveur pour tout le reste. Il n'y a pas de bibliothèque de gestion d'état globale. Les stores sont simples :
typescriptimport { writable } from 'svelte/store';
import { browser } from '$app/environment';
function createPersistedStore<T>(key: string, initial: T) {
const stored = browser ? localStorage.getItem(key) : null;
const value = stored ? JSON.parse(stored) : initial;
const store = writable<T>(value);
if (browser) {
store.subscribe(v => localStorage.setItem(key, JSON.stringify(v)));
}
return store;
}
export const theme = createPersistedStore<'light' | 'dark'>('deblo-theme', 'light');
export const sidebarCollapsed = createPersistedStore('deblo-sidebar', false);
export const preferredLanguage = createPersistedStore('deblo-lang', 'fr');Le protocole SSE : 20+ types d'événements
L'endpoint de chat ne renvoie pas une réponse JSON. Il renvoie un flux de Server-Sent Events. Le frontend ouvre une connexion de type EventSource (en réalité un fetch avec parsing de ReadableStream) et reçoit une séquence d'événements typés :
event: content
data: {"text": "Bonjour ! "}
event: content
data: {"text": "Qu'est-ce que "}
event: tool_start
data: {"name": "interactive_quiz", "id": "call_abc123"}
event: quiz
data: {"question": "Combien font 3 + 4 ?", "options": ["5", "6", "7", "8"], ...}
event: tool_end
data: {"name": "interactive_quiz", "id": "call_abc123"}
event: credits
data: {"free": 28, "recharge": 150, "total": 178}
event: done
data: {"conversation_id": "uuid-here", "title": "Addition"}Les types d'événements incluent :
content-- tokens de texte streamés depuis le LLMreasoning-- tokens de réflexion/raisonnement (pour les modèles avancés)tool_start/tool_progress/tool_end-- cycle de vie de l'exécution des outilsquiz/true_false_quiz-- widgets de quiz interactifsfile-- fichier généré (Excel, PDF, PowerPoint, Word, HTML, Markdown)credits-- solde de crédits mis à jour après déductionbonus_credits-- crédits bonus accordés par l'IA pour l'effort de l'élèveerror-- messages d'erreurdone-- fin du flux avec métadonnées de conversationannotations-- citations de sources du LLMdraft_email-- brouillon d'e-mail à réviser avant envoiexercise_result-- résultat d'exercice suivi silencieusementnotification-- notification push déclenchée par une action de l'IA
Chaque type d'événement est associé à un handler frontend spécifique. L'événement quiz affiche un widget QCM interactif. L'événement file déclenche un bouton de téléchargement avec aperçu. L'événement tool_start affiche un indicateur de progression (« Recherche sur le web... », « Génération du fichier Excel... »).
L'application mobile : Monorepo Expo React Native
L'application mobile est arrivée à la session 86 -- environ trois semaines après le début du développement. C'est une application Expo React Native structurée en monorepo avec des packages partagés :
deblo-mobile/
apps/
k12/ # Application étudiante (Expo Router)
packages/
@deblo/api/ # Client HTTP, endpoints, types
@deblo/stores/ # Stores Zustand, état partagé
@deblo/streaming/ # Parsing SSE, handlers d'événements
@deblo/i18n/ # Internationalisation (fr, en)Le package @deblo/api enveloppe chaque endpoint du backend avec des fonctions typées. Le package @deblo/stores utilise Zustand (la bibliothèque de gestion d'état React) avec persistance AsyncStorage. Le package @deblo/streaming gère le parsing SSE -- le même protocole d'événements que le frontend web, juste avec une couche de transport différente (fetch + ReadableStream au lieu du EventSource du navigateur).
La structure en monorepo a été choisie pour une seule raison : l'application Pro est prévue comme une application Expo séparée (apps/pro/) qui partage les quatre packages avec l'application K12. Interface différente, navigation différente, même client API, même logique de streaming, mêmes chaînes i18n.
Docker Compose : quatre services
Le déploiement de production est un stack Docker Compose avec quatre services :
yamlservices:
frontend:
build: ./frontend
ports: ["5173:5173"]
environment:
- PUBLIC_API_URL=https://api.deblo.ai
depends_on: [backend]
backend:
build: ./backend
ports: ["8000:8000"]
environment:
- DATABASE_URL=postgresql+asyncpg://...
- REDIS_URL=redis://redis:6379
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
depends_on: [postgres, redis]
postgres:
image: pgvector/pgvector:pg17
volumes: ["pgdata:/var/lib/postgresql/data"]
environment:
- POSTGRES_DB=deblo
- POSTGRES_USER=deblo
- POSTGRES_PASSWORD=${DB_PASSWORD}
redis:
image: redis:7-alpine
volumes: ["redisdata:/data"]Nous utilisons pgvector/pgvector:pg17 au lieu de l'image PostgreSQL standard parce que le pipeline RAG nécessite la recherche par similarité vectorielle pour les embeddings de documents. Redis fait triple emploi : suivi de progression SSE pour les tâches de fond, stockage de l'état des quiz (avec expiration TTL) et coordination du polling de paiement.
Le backend tourne avec Uvicorn, 4 workers, derrière un reverse proxy. Le frontend est un serveur Node.js SvelteKit. Pas d'export statique -- nous avons besoin du rendu côté serveur pour le SEO et de la couche proxy API qui gère le transfert des tokens d'authentification basés sur les cookies.
La couche de services : 40+ modules
Le répertoire services/ est là où réside la majeure partie de la complexité. Chaque module de service possède une capacité spécifique :
llm.py-- Streaming OpenRouter, boucle agentique d'outils, gestion du contextetool_executor.py-- Dispatche les appels d'outils vers leurs implémentationstools.py-- 24 définitions d'outils (schémas JSON compatibles OpenRouter)credits.py-- Vérifications de solde, déductions, journalisation au grand livre, rechargement quotidienbackground_generation.py-- Tâches asyncio détachées pour les générations longuesfile_generator.py-- Génération Excel, PDF, PowerPoint, Word, HTML, Markdownweb_tools.py-- Recherche web Tavily, navigation d'URL Jina Readersandbox.py-- Exécution bash dans un sous-processus sandboxéquiz.py-- Gestion de l'état des quiz dans Redismemory.py-- Persistance de la mémoire IA (sauvegarde/rappel des préférences utilisateur)payment.py-- Initiation de paiement auprès de multiples fournisseurspayment_poller.py-- Polling en arrière-plan pour la confirmation des paiementszerofee.py/xpaye.py/stripe_service.py-- Intégrations spécifiques aux fournisseurstwilio_client.py-- Livraison d'OTP WhatsAppemail.py-- E-mail transactionnel via Resendfirebase_service.py-- Notifications push via FCMultravox.py-- Gestion des appels vocauxpreprocessing.py-- Extraction de texte des documents (PDF, DOCX, images)embedding.py-- Embedding de texte pour le pipeline RAGrag_service.py-- Génération augmentée par récupération depuis les documents utilisateurnotification_service.py-- Dispatch des notifications in-appdaily_suggestions.py-- Suggestions d'étude quotidiennes générées par l'IA
Aucun service ne dépend de l'état interne d'un autre service. Ils communiquent via la base de données et Redis. Cela signifie que n'importe quel service peut être testé isolément avec un fixture de base de données et un mock Redis.
Le triple de l'authentification
L'authentification a été l'un des systèmes les plus itérés. La conception finale supporte trois chemins :
- WhatsApp OTP : L'utilisateur entre son numéro de téléphone, reçoit un code à 6 chiffres via WhatsApp (Twilio), le vérifie, reçoit un JWT. C'est le chemin principal parce que les taux de livraison WhatsApp en Afrique sont dramatiquement plus élevés que ceux des SMS.
- Google OAuth : L'utilisateur appuie sur « Se connecter avec Google », complète le flux OAuth, le backend crée ou lie le compte par e-mail/Google ID.
- Code d'accès : Les administrateurs d'organisation génèrent des codes d'accès de 12 caractères pour les membres qui n'ont ni téléphone ni e-mail (courant dans les scénarios de formation en entreprise). Le code est entré une fois et lié à un compte.
Les trois chemins convergent vers le même modèle User et le même format de token JWT. Le token a une expiration de 30 jours -- suffisamment long pour que les élèves n'aient pas à se ré-authentifier chaque semaine, suffisamment court pour que les téléphones perdus ne créent pas un accès indéfini.
Ce que 100 sessions nous ont appris
L'architecture n'a pas été conçue en amont. Elle a été découverte en construisant. La session 1 avait un seul endpoint de chat. La session 15 a ajouté le système de crédits. La session 27 a ajouté le grand livre des crédits. La session 35 a ajouté la génération en arrière-plan. La session 50 a ajouté l'appel d'outils. La session 67 a livré en production. La session 86 a lancé l'application mobile.
Chaque session a ajouté une capacité, et l'architecture s'est développée pour l'accueillir. La structure de fichiers plate, la colonne JSONB pour les messages, l'organisation service-par-capacité -- ce sont toutes des décisions prises sous la contrainte « nous devons livrer ça aujourd'hui et l'étendre demain ».
Cette contrainte, paradoxalement, a produit une architecture plus propre que la plupart des exercices de planification ne l'auraient fait. Chaque abstraction existe parce qu'un besoin concret l'a exigée. Pas de généralisation spéculative. Pas de couches « on pourrait en avoir besoin plus tard ». Juste le code que le produit exige, organisé de sorte que la prochaine session puisse l'étendre sans réécrire ce qui précède.
Ceci est la partie 2 d'une série de 12 articles sur la construction de Deblo.ai.
- Tutorat IA pour 250 millions d'élèves africains
- 100 sessions plus tard : l'architecture d'une plateforme d'éducation IA (vous êtes ici)
- La boucle agentique : 24 outils IA dans un seul chat
- Des prompts système qui enseignent : anti-triche, méthode socratique et adaptation par niveau
- WhatsApp OTP et le problème de l'authentification en Afrique
- Crédits, FCFA et 6 passerelles de paiement africaines
- Streaming SSE : réponses IA en temps réel dans SvelteKit
- Appels vocaux avec l'IA : Ultravox, LiveKit et WebRTC
- Construire une application React Native K12 en 7 jours
- 101 conseillers IA : intelligence professionnelle pour l'Afrique
- Tâches de fond : quand l'IA met 30 minutes à réfléchir
- D'Abidjan à 250 millions : l'histoire de Deblo.ai