feat(agentic-week1): conversational memory, RAG threshold, wallet alerts, weekly reports, campaign scheduling
Browse files- AIAgentHandler: Redis sliding window (20 entries, TTL 24h) for conversation
history injected into system prompt — AI_AGENT now remembers context
- IndexingService.searchRelevantContext: cosine threshold 0.70 — returns '' if
no chunk is relevant so agent responds honestly instead of hallucinating
- add-hnsw-index.ts: one-shot HNSW index script (m=16, ef=64) for ~10x faster
pgvector cosine search on large knowledge bases
- scheduler.ts: hourly wallet alert (email via Brevo if < 3 days runway,
6h Redis suppression) + weekly report every Monday 07:00 UTC with trend vs
previous week and color-coded wallet status
- queue.ts + campaigns.ts: sendAt ISO-8601 parameter on broadcast/campaign
routes — BullMQ delay option schedules jobs natively, no cron needed
- docs/agentic/: roadmap updated with Semaine 1 marked complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- apps/api/src/routes/campaigns.ts +45 -22
- apps/api/src/services/queue.ts +26 -14
- apps/whatsapp-worker/src/handlers/AIAgentHandler.ts +54 -16
- apps/whatsapp-worker/src/index.ts +3 -1
- apps/whatsapp-worker/src/scheduler.ts +175 -0
- apps/whatsapp-worker/src/services/indexing.ts +20 -6
- docs/agentic/audit_agentic_complet_2026.md +1339 -0
- packages/database/scripts/add-hnsw-index.ts +36 -0
|
@@ -29,23 +29,34 @@ export default async function campaignRoutes(fastify: FastifyInstance) {
|
|
| 29 |
}
|
| 30 |
});
|
| 31 |
|
| 32 |
-
// Send Campaign to Broadcast List
|
| 33 |
fastify.post('/:id/campaigns/send', async (req, reply) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
const { id: organizationId } = req.params as { id: string };
|
| 35 |
-
const { listId, message } =
|
| 36 |
|
| 37 |
-
if (
|
| 38 |
-
return reply.code(400).send({ error: '
|
| 39 |
}
|
| 40 |
|
| 41 |
try {
|
| 42 |
const { scheduleBroadcast } = await import('../services/queue');
|
| 43 |
-
await scheduleBroadcast({ organizationId, listId, message });
|
| 44 |
|
| 45 |
-
return reply.code(202).send({
|
| 46 |
-
ok: true,
|
| 47 |
-
status: 'queued',
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
});
|
| 50 |
} catch (err) {
|
| 51 |
fastify.log.error(err);
|
|
@@ -74,34 +85,46 @@ export default async function campaignRoutes(fastify: FastifyInstance) {
|
|
| 74 |
}
|
| 75 |
});
|
| 76 |
|
| 77 |
-
// New Broadcast Campaign Route
|
| 78 |
fastify.post('/:id/campaigns/broadcast', async (req, reply) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
const { id: organizationId } = req.params as { id: string };
|
| 80 |
-
const { message, listId, templateName, templateLanguage } =
|
| 81 |
-
message: string,
|
| 82 |
-
listId?: string,
|
| 83 |
-
templateName?: string,
|
| 84 |
-
templateLanguage?: string
|
| 85 |
-
};
|
| 86 |
|
| 87 |
if (!message && !templateName) {
|
| 88 |
return reply.code(400).send({ error: 'Message content or templateName is required' });
|
| 89 |
}
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
try {
|
| 92 |
const { scheduleCampaign } = await import('../services/queue');
|
| 93 |
-
await scheduleCampaign({
|
| 94 |
-
organizationId,
|
| 95 |
-
messageContent: message
|
| 96 |
listId,
|
| 97 |
templateName,
|
| 98 |
-
templateLanguage
|
|
|
|
| 99 |
});
|
| 100 |
|
| 101 |
return {
|
| 102 |
ok: true,
|
| 103 |
-
status: 'queued',
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
| 105 |
};
|
| 106 |
} catch (err) {
|
| 107 |
fastify.log.error(err);
|
|
|
|
| 29 |
}
|
| 30 |
});
|
| 31 |
|
| 32 |
+
// Send Campaign to Broadcast List (with optional sendAt scheduling)
|
| 33 |
fastify.post('/:id/campaigns/send', async (req, reply) => {
|
| 34 |
+
const schema = z.object({
|
| 35 |
+
listId: z.string().uuid(),
|
| 36 |
+
message: z.string().min(1),
|
| 37 |
+
sendAt: z.string().datetime({ offset: true }).optional(),
|
| 38 |
+
});
|
| 39 |
+
const parsed = schema.safeParse(req.body);
|
| 40 |
+
if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
|
| 41 |
+
|
| 42 |
const { id: organizationId } = req.params as { id: string };
|
| 43 |
+
const { listId, message, sendAt } = parsed.data;
|
| 44 |
|
| 45 |
+
if (sendAt && new Date(sendAt) <= new Date()) {
|
| 46 |
+
return reply.code(400).send({ error: 'sendAt must be in the future' });
|
| 47 |
}
|
| 48 |
|
| 49 |
try {
|
| 50 |
const { scheduleBroadcast } = await import('../services/queue');
|
| 51 |
+
await scheduleBroadcast({ organizationId, listId, message, sendAt });
|
| 52 |
|
| 53 |
+
return reply.code(202).send({
|
| 54 |
+
ok: true,
|
| 55 |
+
status: sendAt ? 'scheduled' : 'queued',
|
| 56 |
+
scheduledFor: sendAt ?? null,
|
| 57 |
+
message: sendAt
|
| 58 |
+
? `Campagne programmée pour le ${new Date(sendAt).toLocaleString('fr-FR')}`
|
| 59 |
+
: "Campagne en cours d'envoi en arrière-plan",
|
| 60 |
});
|
| 61 |
} catch (err) {
|
| 62 |
fastify.log.error(err);
|
|
|
|
| 85 |
}
|
| 86 |
});
|
| 87 |
|
| 88 |
+
// New Broadcast Campaign Route (with optional sendAt scheduling)
|
| 89 |
fastify.post('/:id/campaigns/broadcast', async (req, reply) => {
|
| 90 |
+
const schema = z.object({
|
| 91 |
+
message: z.string().optional(),
|
| 92 |
+
listId: z.string().uuid().optional(),
|
| 93 |
+
templateName: z.string().optional(),
|
| 94 |
+
templateLanguage: z.string().optional(),
|
| 95 |
+
sendAt: z.string().datetime({ offset: true }).optional(),
|
| 96 |
+
});
|
| 97 |
+
const parsed = schema.safeParse(req.body);
|
| 98 |
+
if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
|
| 99 |
+
|
| 100 |
const { id: organizationId } = req.params as { id: string };
|
| 101 |
+
const { message, listId, templateName, templateLanguage, sendAt } = parsed.data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
if (!message && !templateName) {
|
| 104 |
return reply.code(400).send({ error: 'Message content or templateName is required' });
|
| 105 |
}
|
| 106 |
+
if (sendAt && new Date(sendAt) <= new Date()) {
|
| 107 |
+
return reply.code(400).send({ error: 'sendAt must be in the future' });
|
| 108 |
+
}
|
| 109 |
|
| 110 |
try {
|
| 111 |
const { scheduleCampaign } = await import('../services/queue');
|
| 112 |
+
await scheduleCampaign({
|
| 113 |
+
organizationId,
|
| 114 |
+
messageContent: message ?? '',
|
| 115 |
listId,
|
| 116 |
templateName,
|
| 117 |
+
templateLanguage,
|
| 118 |
+
sendAt,
|
| 119 |
});
|
| 120 |
|
| 121 |
return {
|
| 122 |
ok: true,
|
| 123 |
+
status: sendAt ? 'scheduled' : 'queued',
|
| 124 |
+
scheduledFor: sendAt ?? null,
|
| 125 |
+
message: sendAt
|
| 126 |
+
? `Campagne programmée pour le ${new Date(sendAt).toLocaleString('fr-FR')}`
|
| 127 |
+
: "Votre campagne a été mise en file d'attente pour une diffusion progressive.",
|
| 128 |
};
|
| 129 |
} catch (err) {
|
| 130 |
fastify.log.error(err);
|
|
@@ -113,25 +113,37 @@ export async function scheduleInboundMessage(payload: { phone: string, text: str
|
|
| 113 |
});
|
| 114 |
}
|
| 115 |
|
| 116 |
-
/** 📢 BROADCAST: Enqueue a mass message task. */
|
| 117 |
-
export async function scheduleBroadcast(payload: {
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
});
|
| 122 |
}
|
| 123 |
|
| 124 |
-
/** 🚀 CAMPAIGN: Enqueue a mass campaign task
|
| 125 |
-
export async function scheduleCampaign(payload: {
|
| 126 |
-
organizationId: string
|
| 127 |
-
messageContent: string
|
| 128 |
-
listId?: string
|
| 129 |
-
templateName?: string
|
| 130 |
-
templateLanguage?: string
|
|
|
|
| 131 |
}) {
|
| 132 |
-
|
|
|
|
|
|
|
| 133 |
attempts: 1,
|
| 134 |
-
removeOnComplete: true
|
|
|
|
| 135 |
});
|
| 136 |
}
|
| 137 |
|
|
|
|
| 113 |
});
|
| 114 |
}
|
| 115 |
|
| 116 |
+
/** 📢 BROADCAST: Enqueue a mass message task with optional future scheduling. */
|
| 117 |
+
export async function scheduleBroadcast(payload: {
|
| 118 |
+
organizationId: string;
|
| 119 |
+
listId: string;
|
| 120 |
+
message: string;
|
| 121 |
+
sendAt?: string; // ISO 8601 — if set, job is delayed until this time
|
| 122 |
+
}) {
|
| 123 |
+
const { sendAt, ...data } = payload;
|
| 124 |
+
const delayMs = sendAt ? Math.max(0, new Date(sendAt).getTime() - Date.now()) : 0;
|
| 125 |
+
await whatsappQueue.add('send-broadcast', data, {
|
| 126 |
+
attempts: 1,
|
| 127 |
+
removeOnComplete: true,
|
| 128 |
+
...(delayMs > 0 ? { delay: delayMs } : {}),
|
| 129 |
});
|
| 130 |
}
|
| 131 |
|
| 132 |
+
/** 🚀 CAMPAIGN: Enqueue a mass campaign task with optional future scheduling. */
|
| 133 |
+
export async function scheduleCampaign(payload: {
|
| 134 |
+
organizationId: string;
|
| 135 |
+
messageContent: string;
|
| 136 |
+
listId?: string;
|
| 137 |
+
templateName?: string;
|
| 138 |
+
templateLanguage?: string;
|
| 139 |
+
sendAt?: string; // ISO 8601 — if set, job is delayed until this time
|
| 140 |
}) {
|
| 141 |
+
const { sendAt, ...data } = payload;
|
| 142 |
+
const delayMs = sendAt ? Math.max(0, new Date(sendAt).getTime() - Date.now()) : 0;
|
| 143 |
+
await whatsappQueue.add('process-campaign', data, {
|
| 144 |
attempts: 1,
|
| 145 |
+
removeOnComplete: true,
|
| 146 |
+
...(delayMs > 0 ? { delay: delayMs } : {}),
|
| 147 |
});
|
| 148 |
}
|
| 149 |
|
|
@@ -1,56 +1,94 @@
|
|
| 1 |
import { MessageContext, MessageHandler } from './types';
|
| 2 |
import { logger } from '../logger';
|
| 3 |
import { AIPedagogyService } from '../services/ai-pedagogy';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export class AIAgentHandler implements MessageHandler {
|
| 6 |
async canHandle(ctx: MessageContext): Promise<boolean> {
|
| 7 |
-
// Only handle if the organization mode is explicitly AI_AGENT
|
| 8 |
return ctx.organization?.mode === 'AI_AGENT';
|
| 9 |
}
|
| 10 |
|
| 11 |
async handle(ctx: MessageContext): Promise<boolean> {
|
| 12 |
const { phone, text, organization, whatsappQueue, traceId } = ctx;
|
| 13 |
-
|
| 14 |
if (!organization) return false;
|
| 15 |
|
| 16 |
logger.info(`${traceId} Processing via AIAgentHandler for Org: ${organization.id}`);
|
| 17 |
|
| 18 |
try {
|
| 19 |
-
|
| 20 |
-
const
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
systemPrompt += `\n\nIMPORTANT: Réponds TOUJOURS en langue: ${userLang}.`;
|
| 23 |
-
|
| 24 |
-
// 2.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
if (organization.knowledgeBaseUrl) {
|
| 26 |
const { IndexingService } = await import('../services/indexing');
|
| 27 |
const context = await IndexingService.searchRelevantContext(organization.id, text);
|
| 28 |
-
|
| 29 |
if (context) {
|
| 30 |
-
systemPrompt += `\n\nCONTEXTE
|
| 31 |
}
|
| 32 |
}
|
| 33 |
|
| 34 |
-
//
|
| 35 |
const responseText = await AIPedagogyService.generateChat(systemPrompt, text, organization.id);
|
| 36 |
|
| 37 |
-
//
|
|
|
|
|
|
|
|
|
|
| 38 |
await whatsappQueue.add('send-message-direct', {
|
| 39 |
phone,
|
| 40 |
text: responseText,
|
| 41 |
-
organizationId: organization.id
|
| 42 |
});
|
| 43 |
|
| 44 |
return true;
|
| 45 |
-
|
| 46 |
} catch (error) {
|
| 47 |
logger.error(`${traceId} AIAgentHandler failed: ${error}`);
|
| 48 |
await whatsappQueue.add('send-message-direct', {
|
| 49 |
phone,
|
| 50 |
-
text:
|
| 51 |
-
organizationId: organization.id
|
| 52 |
});
|
| 53 |
-
return true;
|
| 54 |
}
|
| 55 |
}
|
| 56 |
}
|
|
|
|
| 1 |
import { MessageContext, MessageHandler } from './types';
|
| 2 |
import { logger } from '../logger';
|
| 3 |
import { AIPedagogyService } from '../services/ai-pedagogy';
|
| 4 |
+
import { redis } from '../lib/redis';
|
| 5 |
+
|
| 6 |
+
const CONV_HISTORY_LIMIT = 20; // Max entries in Redis list (10 exchanges)
|
| 7 |
+
const CONV_TTL_SECONDS = 86_400; // 24h TTL — conversation expires after inactivity
|
| 8 |
+
|
| 9 |
+
interface ConvMessage { role: 'user' | 'assistant'; content: string }
|
| 10 |
+
|
| 11 |
+
async function loadHistory(key: string): Promise<ConvMessage[]> {
|
| 12 |
+
try {
|
| 13 |
+
const raw = await redis.lrange(key, 0, CONV_HISTORY_LIMIT - 1);
|
| 14 |
+
return raw.reverse().map(r => JSON.parse(r) as ConvMessage);
|
| 15 |
+
} catch {
|
| 16 |
+
return [];
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
async function saveHistory(key: string, user: string, assistant: string): Promise<void> {
|
| 21 |
+
try {
|
| 22 |
+
const userEntry = JSON.stringify({ role: 'user', content: user });
|
| 23 |
+
const assistantEntry = JSON.stringify({ role: 'assistant', content: assistant });
|
| 24 |
+
await redis.lpush(key, assistantEntry, userEntry);
|
| 25 |
+
await redis.ltrim(key, 0, CONV_HISTORY_LIMIT - 1);
|
| 26 |
+
await redis.expire(key, CONV_TTL_SECONDS);
|
| 27 |
+
} catch (err) {
|
| 28 |
+
logger.warn({ err }, '[AIAgent] Failed to persist conversation history');
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
|
| 32 |
export class AIAgentHandler implements MessageHandler {
|
| 33 |
async canHandle(ctx: MessageContext): Promise<boolean> {
|
|
|
|
| 34 |
return ctx.organization?.mode === 'AI_AGENT';
|
| 35 |
}
|
| 36 |
|
| 37 |
async handle(ctx: MessageContext): Promise<boolean> {
|
| 38 |
const { phone, text, organization, whatsappQueue, traceId } = ctx;
|
|
|
|
| 39 |
if (!organization) return false;
|
| 40 |
|
| 41 |
logger.info(`${traceId} Processing via AIAgentHandler for Org: ${organization.id}`);
|
| 42 |
|
| 43 |
try {
|
| 44 |
+
const userLang = ctx.user?.language || 'FR';
|
| 45 |
+
const userId = ctx.user?.id ?? phone;
|
| 46 |
+
const historyKey = `conv:${userId}:${organization.id}`;
|
| 47 |
+
|
| 48 |
+
// 1. Prepare system prompt
|
| 49 |
+
let systemPrompt = organization.customPrompt || 'Tu es un assistant virtuel utile et poli.';
|
| 50 |
systemPrompt += `\n\nIMPORTANT: Réponds TOUJOURS en langue: ${userLang}.`;
|
| 51 |
+
|
| 52 |
+
// 2. Load conversation history and inject as context
|
| 53 |
+
const history = await loadHistory(historyKey);
|
| 54 |
+
if (history.length > 0) {
|
| 55 |
+
const historyText = history
|
| 56 |
+
.map(m => `${m.role === 'user' ? 'Client' : 'Toi'}: ${m.content}`)
|
| 57 |
+
.join('\n');
|
| 58 |
+
systemPrompt += `\n\nHISTORIQUE DE LA CONVERSATION (du plus ancien au plus récent):\n${historyText}\n\nContinue la conversation de façon cohérente avec cet historique.`;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// 3. RAG — Knowledge Base context (filtered by relevance threshold)
|
| 62 |
if (organization.knowledgeBaseUrl) {
|
| 63 |
const { IndexingService } = await import('../services/indexing');
|
| 64 |
const context = await IndexingService.searchRelevantContext(organization.id, text);
|
|
|
|
| 65 |
if (context) {
|
| 66 |
+
systemPrompt += `\n\nCONTEXTE DE LA BASE DE CONNAISSANCES:\n${context}\n\nUtilise ce contexte pour répondre si la question concerne les produits ou services de l'entreprise.`;
|
| 67 |
}
|
| 68 |
}
|
| 69 |
|
| 70 |
+
// 4. Generate response
|
| 71 |
const responseText = await AIPedagogyService.generateChat(systemPrompt, text, organization.id);
|
| 72 |
|
| 73 |
+
// 5. Persist exchange to Redis history (fire-and-forget)
|
| 74 |
+
saveHistory(historyKey, text, responseText);
|
| 75 |
+
|
| 76 |
+
// 6. Send response
|
| 77 |
await whatsappQueue.add('send-message-direct', {
|
| 78 |
phone,
|
| 79 |
text: responseText,
|
| 80 |
+
organizationId: organization.id,
|
| 81 |
});
|
| 82 |
|
| 83 |
return true;
|
|
|
|
| 84 |
} catch (error) {
|
| 85 |
logger.error(`${traceId} AIAgentHandler failed: ${error}`);
|
| 86 |
await whatsappQueue.add('send-message-direct', {
|
| 87 |
phone,
|
| 88 |
+
text: 'Désolé, je rencontre une difficulté technique. Veuillez réessayer plus tard.',
|
| 89 |
+
organizationId: organization.id,
|
| 90 |
});
|
| 91 |
+
return true;
|
| 92 |
}
|
| 93 |
}
|
| 94 |
}
|
|
@@ -257,9 +257,11 @@ const start = async () => {
|
|
| 257 |
logger.info(`🚀 WhatsApp Worker + Bridge listening on port ${PORT}`);
|
| 258 |
|
| 259 |
// Start the daily cron scheduler + token expiry monitor
|
| 260 |
-
const { startDailyScheduler, startTokenExpiryMonitor } = await import('./scheduler');
|
| 261 |
startDailyScheduler();
|
| 262 |
startTokenExpiryMonitor();
|
|
|
|
|
|
|
| 263 |
} catch (err) {
|
| 264 |
logger.error('Failed to start worker server:', err);
|
| 265 |
process.exit(1);
|
|
|
|
| 257 |
logger.info(`🚀 WhatsApp Worker + Bridge listening on port ${PORT}`);
|
| 258 |
|
| 259 |
// Start the daily cron scheduler + token expiry monitor
|
| 260 |
+
const { startDailyScheduler, startTokenExpiryMonitor, startWalletAlertMonitor, startWeeklyReportScheduler } = await import('./scheduler');
|
| 261 |
startDailyScheduler();
|
| 262 |
startTokenExpiryMonitor();
|
| 263 |
+
startWalletAlertMonitor();
|
| 264 |
+
startWeeklyReportScheduler();
|
| 265 |
} catch (err) {
|
| 266 |
logger.error('Failed to start worker server:', err);
|
| 267 |
process.exit(1);
|
|
@@ -2,6 +2,7 @@ import { logger } from './logger';
|
|
| 2 |
import cron from 'node-cron';
|
| 3 |
import { prisma } from './services/prisma';
|
| 4 |
import { whatsappQueue } from './lib/queues';
|
|
|
|
| 5 |
|
| 6 |
export function startDailyScheduler() {
|
| 7 |
// Runs at 08:00 AM every day (Dakar time = UTC+0 in winter, so 8 UTC = 8 Dakar)
|
|
@@ -120,3 +121,177 @@ export function startTokenExpiryMonitor() {
|
|
| 120 |
|
| 121 |
logger.info('[TOKEN-MONITOR] Meta token expiry monitor initialized (cron: every Monday 09:00 UTC).');
|
| 122 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import cron from 'node-cron';
|
| 3 |
import { prisma } from './services/prisma';
|
| 4 |
import { whatsappQueue } from './lib/queues';
|
| 5 |
+
import { EmailService } from './services/email';
|
| 6 |
|
| 7 |
export function startDailyScheduler() {
|
| 8 |
// Runs at 08:00 AM every day (Dakar time = UTC+0 in winter, so 8 UTC = 8 Dakar)
|
|
|
|
| 121 |
|
| 122 |
logger.info('[TOKEN-MONITOR] Meta token expiry monitor initialized (cron: every Monday 09:00 UTC).');
|
| 123 |
}
|
| 124 |
+
|
| 125 |
+
// ─── Wallet Alert Monitor ────────────────────────────────────────────────────
|
| 126 |
+
// Runs every hour. Emails the org admin if wallet < 3 days of runway left.
|
| 127 |
+
|
| 128 |
+
async function findOrgAdminEmail(organizationId: string): Promise<{ email: string; name: string } | null> {
|
| 129 |
+
const admin = await prisma.user.findFirst({
|
| 130 |
+
where: { organizationId, role: { in: ['ORG_ADMIN', 'ADMIN'] }, email: { not: null } },
|
| 131 |
+
select: { email: true, name: true },
|
| 132 |
+
});
|
| 133 |
+
return admin ? { email: admin.email!, name: admin.name ?? 'Administrateur' } : null;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
export function startWalletAlertMonitor() {
|
| 137 |
+
cron.schedule('0 * * * *', async () => {
|
| 138 |
+
logger.info('[WALLET-MONITOR] Running hourly wallet check...');
|
| 139 |
+
try {
|
| 140 |
+
const now = new Date();
|
| 141 |
+
const weekAgo = new Date(now.getTime() - 7 * 86_400_000);
|
| 142 |
+
|
| 143 |
+
const orgsAtRisk = await prisma.organization.findMany({
|
| 144 |
+
where: { walletBalance: { lte: 500 }, isHardStopped: false, subscriptionStatus: 'ACTIVE' },
|
| 145 |
+
select: { id: true, name: true, walletBalance: true },
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
for (const org of orgsAtRisk) {
|
| 149 |
+
// Calculate 7-day burn rate
|
| 150 |
+
const debits = await prisma.walletTransaction.aggregate({
|
| 151 |
+
where: { organizationId: org.id, amount: { lt: 0 }, createdAt: { gte: weekAgo } },
|
| 152 |
+
_sum: { amount: true },
|
| 153 |
+
});
|
| 154 |
+
const weeklyDebit = Math.abs(debits._sum.amount ?? 0);
|
| 155 |
+
const dailyBurn = weeklyDebit / 7;
|
| 156 |
+
const daysLeft = dailyBurn > 0 ? org.walletBalance / dailyBurn : null;
|
| 157 |
+
|
| 158 |
+
// Only alert if < 3 days of runway (and we have a burn rate)
|
| 159 |
+
if (daysLeft === null || daysLeft > 3) continue;
|
| 160 |
+
|
| 161 |
+
const admin = await findOrgAdminEmail(org.id);
|
| 162 |
+
if (!admin) continue;
|
| 163 |
+
|
| 164 |
+
const alertKey = `wallet:alert:${org.id}`;
|
| 165 |
+
// Avoid re-alerting within 6 hours for same org
|
| 166 |
+
const { redis } = await import('./lib/redis');
|
| 167 |
+
const alreadyAlerted = await redis.get(alertKey);
|
| 168 |
+
if (alreadyAlerted) continue;
|
| 169 |
+
|
| 170 |
+
logger.warn({ organizationId: org.id, daysLeft: daysLeft.toFixed(1) }, '[WALLET-MONITOR] Sending low-wallet alert');
|
| 171 |
+
await EmailService.sendEmail({
|
| 172 |
+
to: admin.email,
|
| 173 |
+
subject: `⚠️ Solde faible — ${org.name} (${Math.round(daysLeft)} jour(s) restant${daysLeft < 1 ? ' — SERVICE EN DANGER' : ''})`,
|
| 174 |
+
htmlContent: `
|
| 175 |
+
<div style="font-family:sans-serif;max-width:600px;margin:auto;padding:20px;border:1px solid #eee;border-radius:10px;">
|
| 176 |
+
<h2 style="color:${daysLeft < 1 ? '#dc2626' : '#d97706'};">
|
| 177 |
+
${daysLeft < 1 ? '🚨 Service en danger' : '⚠️ Solde faible'} — ${org.name}
|
| 178 |
+
</h2>
|
| 179 |
+
<p>Bonjour ${admin.name},</p>
|
| 180 |
+
<p>À votre rythme de consommation actuel (<strong>${Math.round(dailyBurn)} crédits/jour</strong>),
|
| 181 |
+
il reste environ <strong>${daysLeft < 1 ? 'moins d\'1 jour' : `${Math.round(daysLeft)} jour(s)`}</strong>
|
| 182 |
+
avant que le service soit suspendu.</p>
|
| 183 |
+
<div style="background:#fef3c7;border:1px solid #fbbf24;border-radius:8px;padding:16px;margin:20px 0;">
|
| 184 |
+
<p style="margin:0;font-size:1.25rem;font-weight:bold;">Solde actuel : ${org.walletBalance} crédits (= ${org.walletBalance * 10} FCFA)</p>
|
| 185 |
+
</div>
|
| 186 |
+
<p>Rechargez votre wallet pour éviter toute interruption de service.</p>
|
| 187 |
+
<a href="https://admin.xamle.studio/billing" style="display:inline-block;padding:12px 24px;background:#059669;color:white;text-decoration:none;border-radius:8px;font-weight:bold;">Recharger mon wallet</a>
|
| 188 |
+
<p style="color:#64748b;font-size:0.875rem;margin-top:40px;">L'équipe Xamlé Studio</p>
|
| 189 |
+
</div>`,
|
| 190 |
+
}).catch(e => logger.error({ e }, '[WALLET-MONITOR] Alert email failed'));
|
| 191 |
+
|
| 192 |
+
await redis.set(alertKey, '1', 'EX', 6 * 3600); // Suppress for 6h
|
| 193 |
+
}
|
| 194 |
+
} catch (err) {
|
| 195 |
+
logger.error({ err }, '[WALLET-MONITOR] Hourly check failed');
|
| 196 |
+
}
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
logger.info('[WALLET-MONITOR] Hourly wallet alert monitor initialized.');
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// ─── Weekly Report ───────────────────────────────────────────────────────────
|
| 203 |
+
// Runs every Monday at 07:00 UTC. Sends a usage summary to each org admin.
|
| 204 |
+
|
| 205 |
+
const FEATURE_LABELS: Record<string, string> = {
|
| 206 |
+
LESSON: 'Leçons', FEEDBACK: 'Feedbacks', DEEPDIVE: 'Approfondissements',
|
| 207 |
+
TRANSCRIPTION: 'Transcriptions audio', IMAGE_ANALYSIS: 'Analyses image',
|
| 208 |
+
CAMPAIGN: 'Campagnes', ONBOARDING: 'Onboarding', OTHER: 'Autres',
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
export function startWeeklyReportScheduler() {
|
| 212 |
+
cron.schedule('0 7 * * 1', async () => {
|
| 213 |
+
logger.info('[WEEKLY-REPORT] Generating weekly reports...');
|
| 214 |
+
try {
|
| 215 |
+
const weekAgo = new Date(Date.now() - 7 * 86_400_000);
|
| 216 |
+
const prevWeekAgo = new Date(Date.now() - 14 * 86_400_000);
|
| 217 |
+
|
| 218 |
+
const orgs = await prisma.organization.findMany({
|
| 219 |
+
where: { subscriptionStatus: 'ACTIVE' },
|
| 220 |
+
select: { id: true, name: true, walletBalance: true, whatsappMessagesSent: true, aiCreditsUsed: true, aiCreditsLimit: true },
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
for (const org of orgs) {
|
| 224 |
+
const admin = await findOrgAdminEmail(org.id);
|
| 225 |
+
if (!admin) continue;
|
| 226 |
+
|
| 227 |
+
const [thisWeek, prevWeek, breakdown] = await Promise.all([
|
| 228 |
+
prisma.usageEvent.aggregate({
|
| 229 |
+
where: { organizationId: org.id, createdAt: { gte: weekAgo }, type: { not: 'WHATSAPP_SENT' } },
|
| 230 |
+
_sum: { costUsd: true }, _count: { id: true },
|
| 231 |
+
}),
|
| 232 |
+
prisma.usageEvent.aggregate({
|
| 233 |
+
where: { organizationId: org.id, createdAt: { gte: prevWeekAgo, lt: weekAgo }, type: { not: 'WHATSAPP_SENT' } },
|
| 234 |
+
_count: { id: true },
|
| 235 |
+
}),
|
| 236 |
+
prisma.usageEvent.groupBy({
|
| 237 |
+
by: ['feature'],
|
| 238 |
+
where: { organizationId: org.id, createdAt: { gte: weekAgo }, type: { not: 'WHATSAPP_SENT' } },
|
| 239 |
+
_count: { id: true },
|
| 240 |
+
orderBy: { _count: { id: 'desc' } },
|
| 241 |
+
take: 1,
|
| 242 |
+
}),
|
| 243 |
+
]);
|
| 244 |
+
|
| 245 |
+
const calls = thisWeek._count.id;
|
| 246 |
+
const prevCalls = prevWeek._count.id;
|
| 247 |
+
const costFcfa = Math.round((thisWeek._sum.costUsd ?? 0) * 600);
|
| 248 |
+
const topFeat = FEATURE_LABELS[breakdown[0]?.feature ?? ''] ?? 'N/A';
|
| 249 |
+
const trend = prevCalls > 0 ? Math.round(((calls - prevCalls) / prevCalls) * 100) : 0;
|
| 250 |
+
const trendStr = trend >= 0 ? `+${trend}%` : `${trend}%`;
|
| 251 |
+
const trendColor = trend >= 0 ? '#059669' : '#dc2626';
|
| 252 |
+
|
| 253 |
+
if (calls === 0 && org.walletBalance > 500) continue; // Skip inactive orgs with healthy balance
|
| 254 |
+
|
| 255 |
+
await EmailService.sendEmail({
|
| 256 |
+
to: admin.email,
|
| 257 |
+
subject: `📊 Rapport hebdomadaire Xamlé — ${org.name}`,
|
| 258 |
+
htmlContent: `
|
| 259 |
+
<div style="font-family:sans-serif;max-width:600px;margin:auto;padding:20px;border:1px solid #eee;border-radius:10px;">
|
| 260 |
+
<h2 style="color:#1e293b;">📊 Rapport hebdomadaire — ${org.name}</h2>
|
| 261 |
+
<p>Bonjour ${admin.name}, voici votre résumé de la semaine écoulée.</p>
|
| 262 |
+
<table style="width:100%;border-collapse:collapse;margin:20px 0;">
|
| 263 |
+
<tr style="background:#f8fafc;">
|
| 264 |
+
<td style="padding:10px;border:1px solid #e2e8f0;">Appels IA cette semaine</td>
|
| 265 |
+
<td style="padding:10px;border:1px solid #e2e8f0;font-weight:bold;">${calls.toLocaleString('fr-FR')} <span style="color:${trendColor};font-size:0.875rem;">(${trendStr} vs sem. précédente)</span></td>
|
| 266 |
+
</tr>
|
| 267 |
+
<tr>
|
| 268 |
+
<td style="padding:10px;border:1px solid #e2e8f0;">Coût IA</td>
|
| 269 |
+
<td style="padding:10px;border:1px solid #e2e8f0;font-weight:bold;">${costFcfa.toLocaleString('fr-FR')} FCFA</td>
|
| 270 |
+
</tr>
|
| 271 |
+
<tr style="background:#f8fafc;">
|
| 272 |
+
<td style="padding:10px;border:1px solid #e2e8f0;">Fonctionnalité principale</td>
|
| 273 |
+
<td style="padding:10px;border:1px solid #e2e8f0;font-weight:bold;">${topFeat}</td>
|
| 274 |
+
</tr>
|
| 275 |
+
<tr>
|
| 276 |
+
<td style="padding:10px;border:1px solid #e2e8f0;">Solde wallet</td>
|
| 277 |
+
<td style="padding:10px;border:1px solid #e2e8f0;font-weight:bold;color:${org.walletBalance < 200 ? '#dc2626' : '#059669'};">${org.walletBalance} crédits (${org.walletBalance * 10} FCFA)</td>
|
| 278 |
+
</tr>
|
| 279 |
+
<tr style="background:#f8fafc;">
|
| 280 |
+
<td style="padding:10px;border:1px solid #e2e8f0;">Crédits IA (mois en cours)</td>
|
| 281 |
+
<td style="padding:10px;border:1px solid #e2e8f0;font-weight:bold;">${org.aiCreditsUsed} / ${org.aiCreditsLimit}</td>
|
| 282 |
+
</tr>
|
| 283 |
+
</table>
|
| 284 |
+
<a href="https://admin.xamle.studio/analytics" style="display:inline-block;padding:12px 24px;background:#4f46e5;color:white;text-decoration:none;border-radius:8px;font-weight:bold;">Voir le détail complet</a>
|
| 285 |
+
<p style="color:#64748b;font-size:0.875rem;margin-top:40px;">L'équipe Xamlé Studio</p>
|
| 286 |
+
</div>`,
|
| 287 |
+
}).catch(e => logger.error({ e, orgId: org.id }, '[WEEKLY-REPORT] Email failed'));
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
logger.info('[WEEKLY-REPORT] Weekly reports sent.');
|
| 291 |
+
} catch (err) {
|
| 292 |
+
logger.error({ err }, '[WEEKLY-REPORT] Failed');
|
| 293 |
+
}
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
logger.info('[WEEKLY-REPORT] Weekly report scheduler initialized (cron: every Monday 07:00 UTC).');
|
| 297 |
+
}
|
|
@@ -198,27 +198,41 @@ export class IndexingService {
|
|
| 198 |
}
|
| 199 |
|
| 200 |
/**
|
| 201 |
-
* Searches for relevant chunks
|
|
|
|
|
|
|
| 202 |
*/
|
| 203 |
-
static async searchRelevantContext(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
try {
|
| 205 |
const queryEmbedding = await this.generateEmbedding(query);
|
| 206 |
// Prisma.raw is safe here: embedding is machine-generated floats from OpenAI, not user input
|
| 207 |
const vecRaw = Prisma.raw(`'[${queryEmbedding.join(',')}]'::vector`);
|
| 208 |
|
| 209 |
-
// Cosine similarity search
|
| 210 |
-
const results:
|
| 211 |
-
SELECT content, 1 - (embedding <=> ${vecRaw})
|
| 212 |
FROM "KnowledgeBaseEntry"
|
| 213 |
WHERE "organizationId" = ${organizationId}
|
|
|
|
| 214 |
ORDER BY embedding <=> ${vecRaw}
|
| 215 |
LIMIT ${limit}
|
| 216 |
`);
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
return results.map(r => r.content).join('\n\n---\n\n');
|
| 219 |
} catch (error) {
|
| 220 |
logger.error(`[RETRIEVAL] Search failed: ${error}`);
|
| 221 |
-
return
|
| 222 |
}
|
| 223 |
}
|
| 224 |
}
|
|
|
|
| 198 |
}
|
| 199 |
|
| 200 |
/**
|
| 201 |
+
* Searches for relevant chunks using cosine similarity with a minimum relevance threshold.
|
| 202 |
+
* Returns empty string if no chunk exceeds the threshold — the agent will then respond
|
| 203 |
+
* honestly that it doesn't have specific information on the topic.
|
| 204 |
*/
|
| 205 |
+
static async searchRelevantContext(
|
| 206 |
+
organizationId: string,
|
| 207 |
+
query: string,
|
| 208 |
+
limit: number = 3,
|
| 209 |
+
threshold: number = 0.70
|
| 210 |
+
): Promise<string> {
|
| 211 |
try {
|
| 212 |
const queryEmbedding = await this.generateEmbedding(query);
|
| 213 |
// Prisma.raw is safe here: embedding is machine-generated floats from OpenAI, not user input
|
| 214 |
const vecRaw = Prisma.raw(`'[${queryEmbedding.join(',')}]'::vector`);
|
| 215 |
|
| 216 |
+
// Cosine similarity search via HNSW index (if created) — threshold filters irrelevant chunks
|
| 217 |
+
const results: Array<{ content: string; similarity: number }> = await prisma.$queryRaw(Prisma.sql`
|
| 218 |
+
SELECT content, 1 - (embedding <=> ${vecRaw}) AS similarity
|
| 219 |
FROM "KnowledgeBaseEntry"
|
| 220 |
WHERE "organizationId" = ${organizationId}
|
| 221 |
+
AND 1 - (embedding <=> ${vecRaw}) > ${threshold}
|
| 222 |
ORDER BY embedding <=> ${vecRaw}
|
| 223 |
LIMIT ${limit}
|
| 224 |
`);
|
| 225 |
|
| 226 |
+
if (results.length === 0) {
|
| 227 |
+
logger.debug(`[RETRIEVAL] No chunks above threshold ${threshold} for org ${organizationId}`);
|
| 228 |
+
return '';
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
logger.debug(`[RETRIEVAL] Found ${results.length} relevant chunks (best: ${results[0]?.similarity?.toFixed(3)})`);
|
| 232 |
return results.map(r => r.content).join('\n\n---\n\n');
|
| 233 |
} catch (error) {
|
| 234 |
logger.error(`[RETRIEVAL] Search failed: ${error}`);
|
| 235 |
+
return '';
|
| 236 |
}
|
| 237 |
}
|
| 238 |
}
|
|
@@ -0,0 +1,1339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Audit Complet Xamlé — Fonctionnalités, IA Agentique & Avancées Technologiques
|
| 2 |
+
|
| 3 |
+
> **Date :** 13 mai 2026
|
| 4 |
+
> **Scope :** Audit exhaustif de toute la plateforme (frontend, backend, worker, IA, database, UX/UI) + roadmap agentique complète
|
| 5 |
+
> **Objectif :** Cartographier tout ce qui existe, identifier tout ce qui peut être automatisé, et exploiter les dernières avancées technologiques disponibles aujourd'hui
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Table des matières
|
| 10 |
+
|
| 11 |
+
1. [Vue d'ensemble de la plateforme](#1-vue-densemble)
|
| 12 |
+
2. [Inventaire complet — Base de données](#2-base-de-données)
|
| 13 |
+
3. [Inventaire complet — Backend (API Fastify)](#3-backend-api-fastify)
|
| 14 |
+
4. [Inventaire complet — Worker BullMQ](#4-worker-bullmq)
|
| 15 |
+
5. [Inventaire complet — SDK IA](#5-sdk-ia)
|
| 16 |
+
6. [Inventaire complet — Frontend Admin](#6-frontend-admin)
|
| 17 |
+
7. [Inventaire complet — Prompts & Templates IA](#7-prompts--templates-ia)
|
| 18 |
+
8. [Tarification & Wallet](#8-tarification--wallet)
|
| 19 |
+
9. [Lacunes & dette technique identifiées](#9-lacunes--dette-technique)
|
| 20 |
+
10. [Roadmap IA Agentique — tout ce qui peut être automatisé](#10-roadmap-ia-agentique)
|
| 21 |
+
11. [Dernières avancées technologiques applicables](#11-dernières-avancées-technologiques)
|
| 22 |
+
12. [Matrice de priorisation](#12-matrice-de-priorisation)
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 1. Vue d'ensemble
|
| 27 |
+
|
| 28 |
+
### Qu'est-ce que Xamlé ?
|
| 29 |
+
|
| 30 |
+
Xamlé est une **plateforme SaaS multi-tenant d'automatisation WhatsApp Business** destinée aux entreprises et organisations africaines. Elle permet de gérer la relation client, la formation, et le support via WhatsApp en combinant IA générative, workflows métier et facturation à la consommation.
|
| 31 |
+
|
| 32 |
+
### Architecture globale
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
┌──────────────────────────────────────────────────────────────────┐
|
| 36 |
+
│ Meta WhatsApp Business API │
|
| 37 |
+
└─────────────────────────┬────────────────────────────────────────┘
|
| 38 |
+
│ Webhook POST
|
| 39 |
+
▼
|
| 40 |
+
┌──────────────────────────────────────────────────────────────────┐
|
| 41 |
+
│ apps/api (Fastify v4 — port 3000) │
|
| 42 |
+
│ • Répond 200 OK < 100ms (règle absolue) │
|
| 43 |
+
│ • Vérifie X-Hub-Signature-256 │
|
| 44 |
+
│ • Enfile le job dans BullMQ via bridge localhost:8082 │
|
| 45 |
+
└─────────────────────────┬────────────────────────────────────────┘
|
| 46 |
+
│ HTTP → BullMQ
|
| 47 |
+
▼
|
| 48 |
+
┌──────────────────────────────────────────────────────────────────┐
|
| 49 |
+
│ apps/whatsapp-worker (BullMQ consumer) │
|
| 50 |
+
│ • Handlers en chaîne : AIAgent → Onboarding → Command → │
|
| 51 |
+
│ Navigation → Exercise │
|
| 52 |
+
│ • Toute la logique métier ici │
|
| 53 |
+
└──────────┬─────────────────────────────────┬────────────────────┘
|
| 54 |
+
│ │
|
| 55 |
+
▼ ▼
|
| 56 |
+
┌──────────────────────┐ ┌─────────────────────────────────┐
|
| 57 |
+
│ packages/database │ │ packages/ai-sdk │
|
| 58 |
+
│ (Prisma + pgvector) │ │ OpenAI / Gemini / BYOK │
|
| 59 |
+
│ Neon PostgreSQL │ │ Whisper STT / DALL-E / TTS │
|
| 60 |
+
└──────────────────────┘ └─────────────────────────────────┘
|
| 61 |
+
│
|
| 62 |
+
▼
|
| 63 |
+
┌──────────────────────────────────────────────────────────────────┐
|
| 64 |
+
│ apps/admin (React/Vite — port 5173) │
|
| 65 |
+
│ Dashboard multi-tenant pour les super admins et org admins │
|
| 66 |
+
└──────────────────────────────────────────────────────────────────┘
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### 4 modes d'opération
|
| 70 |
+
|
| 71 |
+
| Mode | Usage | Différenciateur |
|
| 72 |
+
|------|-------|-----------------|
|
| 73 |
+
| **EDTECH** | Formations structurées 21 jours via WhatsApp | Parcours pédagogique jour/jour + feedback IA |
|
| 74 |
+
| **CRM_MARKETING** | Campagnes broadcast, gestion contacts | Listes de diffusion + templates approuvés Meta |
|
| 75 |
+
| **AI_AGENT** | Bot autonome 24h/24 | RAG sur base de connaissance + personnalité configurée |
|
| 76 |
+
| **CUSTOMER_SERVICE** | Support client avec escalade humaine | Mix bot + agent humain |
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## 2. Base de données
|
| 81 |
+
|
| 82 |
+
**Technologie :** PostgreSQL (Neon serverless) + Prisma ORM + pgvector extension
|
| 83 |
+
|
| 84 |
+
### 2.1 Modèles principaux
|
| 85 |
+
|
| 86 |
+
#### Organization (Racine multi-tenant)
|
| 87 |
+
```
|
| 88 |
+
id, name, slug
|
| 89 |
+
mode: EDTECH | WEBHOOK | AI_AGENT | CRM_MARKETING | PEDAGOGY | CUSTOMER_SERVICE
|
| 90 |
+
wabaId, metaBusinessId, systemUserToken, systemUserTokenIssuedAt
|
| 91 |
+
customPrompt, personalityConfig (JSON), flowConfig (JSON), brandingData (JSON)
|
| 92 |
+
subscriptionPlan: STARTER | GROWTH | SCALE | ENTERPRISE
|
| 93 |
+
aiCreditsUsed, aiCreditsLimit, walletBalance, isHardStopped
|
| 94 |
+
openAiApiKey, googleAiApiKey (BYOK — chiffré au repos)
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
#### User (Apprenants & membres)
|
| 98 |
+
```
|
| 99 |
+
phone (unique par org), email, name
|
| 100 |
+
role: STUDENT | ADMIN | ORG_MEMBER | ORG_ADMIN | SUPER_ADMIN
|
| 101 |
+
language: FR | EN | ES | PT | WOLOF
|
| 102 |
+
activity (secteur), currentStreak, longestStreak, lastActivityAt
|
| 103 |
+
businessProfile → JSON (données entrepreneuriales)
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
#### Track & TrackDay (Contenu pédagogique)
|
| 107 |
+
```
|
| 108 |
+
Track: title, durationDays, language, isPremium, price
|
| 109 |
+
TrackDay:
|
| 110 |
+
dayNumber (float — permet jour 1.5 "bis")
|
| 111 |
+
lessonText / audioUrl / imageUrl / videoUrl
|
| 112 |
+
exerciseType: TEXT | AUDIO | BUTTON
|
| 113 |
+
exercisePrompt, exerciseCriteria (JSON)
|
| 114 |
+
buttonsJson (choix interactifs multi-langue)
|
| 115 |
+
unlockCondition
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
#### Enrollment (État d'apprentissage)
|
| 119 |
+
```
|
| 120 |
+
userId + trackId (unique)
|
| 121 |
+
status: ACTIVE | COMPLETED | DROPPED
|
| 122 |
+
currentDay (float), lastActivityAt
|
| 123 |
+
startedAt, completedAt
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
#### UserProgress (Machine à états exercice)
|
| 127 |
+
```
|
| 128 |
+
exerciseStatus: PENDING → PENDING_REMEDIATION → COMPLETED
|
| 129 |
+
→ PENDING_DEEPDIVE → COMPLETED
|
| 130 |
+
→ PENDING_REVIEW (révision humaine)
|
| 131 |
+
badges (JSON), behavioralScoring (JSON)
|
| 132 |
+
confidenceScore, iterationCount
|
| 133 |
+
adminTranscription, overrideAudioUrl
|
| 134 |
+
previousResponses (JSON array)
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
#### KnowledgeBaseEntry (RAG — Agent IA)
|
| 138 |
+
```
|
| 139 |
+
content (chunk texte), embedding (pgvector)
|
| 140 |
+
metadata (JSON : source, page, title)
|
| 141 |
+
organizationId
|
| 142 |
+
createdAt
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
#### Message & Contact
|
| 146 |
+
```
|
| 147 |
+
Message: userId | contactId, direction (INBOUND/OUTBOUND)
|
| 148 |
+
mediaUrl, mediaId, status (SENT/DELIVERED/READ)
|
| 149 |
+
content, createdAt
|
| 150 |
+
Contact: phoneNumber, name, attributes (JSON colonnes Excel dynamiques)
|
| 151 |
+
language, organizationId
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
#### WalletTransaction & UsageEvent
|
| 155 |
+
```
|
| 156 |
+
WalletTransaction: amount, type (TOP_UP/DEBIT_AI/DEBIT_BROADCAST)
|
| 157 |
+
balanceAfter, byok (flag BYOK)
|
| 158 |
+
UsageEvent: type (AI_TEXT/AI_AUDIO/AI_IMAGE/WHATSAPP_SENT)
|
| 159 |
+
feature (LESSON/FEEDBACK/DEEPDIVE/TRANSCRIPTION/CAMPAIGN...)
|
| 160 |
+
provider (GEMINI/OPENAI/META)
|
| 161 |
+
tokensIn, tokensOut, costUsd, durationMs
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
#### CampaignHistory & AnalyticsLog
|
| 165 |
+
```
|
| 166 |
+
CampaignHistory: status (SENT/DELIVERED/READ/FAILED), whatsappMessageId
|
| 167 |
+
AnalyticsLog: eventType (CLICK/READ/RESPONSE), metadata
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
### 2.2 Enums clés
|
| 171 |
+
- `Language` : FR, EN, ES, PT, WOLOF
|
| 172 |
+
- `ExerciseStatus` : PENDING, PENDING_REMEDIATION, PENDING_REVIEW, COMPLETED, PENDING_DEEPDIVE
|
| 173 |
+
- `SubscriptionPlan` : STARTER, GROWTH, SCALE, ENTERPRISE
|
| 174 |
+
- `UsageFeature` : LESSON, FEEDBACK, DEEPDIVE, TRANSCRIPTION, IMAGE_ANALYSIS, CAMPAIGN, ONBOARDING, OTHER
|
| 175 |
+
|
| 176 |
+
### 2.3 Particularités techniques
|
| 177 |
+
- **pgvector** : Colonne `embedding` de type `Unsupported("vector")` — recherche cosinus pour RAG
|
| 178 |
+
- **Multi-tenant via AsyncLocalStorage** : `tenantContext` injecte `organizationId` dans toutes les requêtes Prisma automatiquement
|
| 179 |
+
- **Extension Prisma** : `packages/database/src/extension.ts` — auto-filtre chaque query par `organizationId`
|
| 180 |
+
- **Modèles exclus du filtre tenant** : `Organization`, `TrainingData`, `NormalizationRule`
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
## 3. Backend — API Fastify
|
| 185 |
+
|
| 186 |
+
**Localisation :** `apps/api/src/routes/`
|
| 187 |
+
|
| 188 |
+
### 3.1 Authentification & Middleware
|
| 189 |
+
|
| 190 |
+
```
|
| 191 |
+
x-api-key (ADMIN_API_KEY, min 32 chars) → bypass JWT (appels worker→API)
|
| 192 |
+
JWT via @fastify/jwt → { id, role, organizationId }
|
| 193 |
+
rateLimit (plugin Fastify) → par route
|
| 194 |
+
enforceOrgIsolation.ts → vérifie que l'org du JWT = l'org de la requête
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
### 3.2 Routes WhatsApp (`/v1/whatsapp/`)
|
| 198 |
+
|
| 199 |
+
| Méthode | Route | Fonction |
|
| 200 |
+
|---------|-------|----------|
|
| 201 |
+
| GET | `/webhook` | Vérification Meta (hub.verify_token) |
|
| 202 |
+
| POST | `/webhook` | Réception messages entrants (200 OK immédiat + queue) |
|
| 203 |
+
| GET | `/templates` | Liste templates Meta pour l'org |
|
| 204 |
+
| POST | `/templates` | Création template WhatsApp Business |
|
| 205 |
+
|
| 206 |
+
**Fonctionnalités critiques :**
|
| 207 |
+
- Vérification HMAC `X-Hub-Signature-256` sur le corps brut
|
| 208 |
+
- Mise à jour `CampaignHistory` sur les status updates (DELIVERED/READ)
|
| 209 |
+
- Forward asynchrone vers worker bridge (localhost:8082)
|
| 210 |
+
|
| 211 |
+
### 3.3 Routes Admin (`/v1/admin/`)
|
| 212 |
+
|
| 213 |
+
| Méthode | Route | Fonction |
|
| 214 |
+
|---------|-------|----------|
|
| 215 |
+
| GET | `/stats` | Stats dashboard (users, actifs, complétés, revenue) |
|
| 216 |
+
| GET | `/users` | Liste paginée des utilisateurs |
|
| 217 |
+
| GET | `/users/:id/messages` | Historique conversation |
|
| 218 |
+
| GET | `/enrollments` | Inscriptions paginées |
|
| 219 |
+
| GET | `/live-feed` | Exercices PENDING_REVIEW (révision humaine) |
|
| 220 |
+
| POST | `/override-feedback` | Transcription manuelle + override audio admin |
|
| 221 |
+
| GET/POST/PUT/DELETE | `/tracks` | CRUD parcours de formation |
|
| 222 |
+
| GET/POST/PUT/DELETE | `/tracks/:id/days` | CRUD jours de formation |
|
| 223 |
+
| GET | `/training/audios` | Audios en attente de correction STT |
|
| 224 |
+
| POST | `/training/submit` | Soumission correction manuelle |
|
| 225 |
+
| GET | `/training/suggestions` | Suggestions d'amélioration WER |
|
| 226 |
+
| POST | `/training/apply-suggestions` | Application batch des règles |
|
| 227 |
+
| POST | `/training/recalculate-wer` | Recalcul global WER avec règles |
|
| 228 |
+
| POST | `/training/upload` | Upload + transcription audio |
|
| 229 |
+
|
| 230 |
+
### 3.4 Routes IA (`/v1/ai/`)
|
| 231 |
+
|
| 232 |
+
| Méthode | Route | Fonction |
|
| 233 |
+
|---------|-------|----------|
|
| 234 |
+
| POST | `/onepager` | Génération PDF one-pager + image IA |
|
| 235 |
+
| POST | `/deck` | Génération PPTX pitch deck avec images IA |
|
| 236 |
+
| POST | `/personalize-lesson` | Réécriture leçon selon activité utilisateur |
|
| 237 |
+
| POST | `/tts` | Synthèse vocale texte → audio |
|
| 238 |
+
| POST | `/transcribe` | Transcription audio → texte (confidence + isSuspect) |
|
| 239 |
+
| POST | `/store-audio` | Archivage média vers R2 |
|
| 240 |
+
| POST | `/generate-feedback` | Feedback exercice complet (2 branches) |
|
| 241 |
+
| POST | `/extract-profile` | Extraction profil business depuis texte libre |
|
| 242 |
+
| POST | `/chat` | Prompt système + question → réponse |
|
| 243 |
+
| POST | `/crm/generate-campaign` | Message personnalisé par contact |
|
| 244 |
+
| POST | `/crm/command` | Classification intention + routing action |
|
| 245 |
+
| POST | `/crm/voice-command` | Commande vocale → action CRM |
|
| 246 |
+
| POST | `/crm/send-bulk` | Envoi messages liste contacts |
|
| 247 |
+
|
| 248 |
+
### 3.5 Routes Analytics (`/v1/analytics/`)
|
| 249 |
+
|
| 250 |
+
| GET `/usage` | Messages, tokens estimés, coût |
|
| 251 |
+
|---|---|
|
| 252 |
+
| GET `/pedagogy` | Taux complétion, score moyen, temps moyen |
|
| 253 |
+
| GET `/campaigns` | Funnel campagne (SENT → DELIVERED → READ) |
|
| 254 |
+
|
| 255 |
+
### 3.6 Routes Organisations (`/v1/organizations/`)
|
| 256 |
+
|
| 257 |
+
| CRUD orgs | `/` → liste, POST créer, GET/:id, PUT/:id |
|
| 258 |
+
|---|---|
|
| 259 |
+
| WhatsApp | `/:id/whatsapp-setup` — échange token OAuth Meta |
|
| 260 |
+
| WhatsApp | `/:id/whatsapp-status` — validité token |
|
| 261 |
+
| Personnalité | `PATCH /:id/personality` — mission, ton, nom bot |
|
| 262 |
+
| Base de connaissance | `POST /:id/upload-kb` — upload + indexation |
|
| 263 |
+
| KB stats | `GET /:id/kb-stats` — chunks, coverage |
|
| 264 |
+
| KB gestion | `GET /:id/kb`, `DELETE /:id/kb/:entryId` |
|
| 265 |
+
| Contacts | `POST /:id/contacts/import` — import Excel |
|
| 266 |
+
| Contacts | `GET/DELETE /:id/contacts` — CRUD contacts |
|
| 267 |
+
| Messages | `GET /:id/messages`, `POST /:id/messages/reply` |
|
| 268 |
+
| Campagnes | `GET /:id/campaign-history` |
|
| 269 |
+
|
| 270 |
+
### 3.7 Routes Billing (`/v1/billing/`)
|
| 271 |
+
|
| 272 |
+
| GET `/summary` | Usage période courante + wallet |
|
| 273 |
+
|---|---|
|
| 274 |
+
| GET `/history?days=30` | Détail jour par jour |
|
| 275 |
+
| GET `/breakdown` | Ventilation par fonctionnalité |
|
| 276 |
+
| **POST `/chat`** | **Copilote IA admin (agentique)** |
|
| 277 |
+
| POST `/template-generate` | Générateur template IA |
|
| 278 |
+
| POST `/agent-test` | Test personnalité agent IA |
|
| 279 |
+
| GET `/wallet` | Solde + 20 dernières transactions |
|
| 280 |
+
| POST `/admin/allocate` | Recharge wallet (SUPER_ADMIN) |
|
| 281 |
+
|
| 282 |
+
### 3.8 Routes Paiements (`/v1/payments/`)
|
| 283 |
+
|
| 284 |
+
- `POST /initiate` — Initialiser session paiement
|
| 285 |
+
- `POST /verify` — Vérifier paiement (via webhook gateway)
|
| 286 |
+
- `GET /history` — Historique paiements org
|
| 287 |
+
|
| 288 |
+
---
|
| 289 |
+
|
| 290 |
+
## 4. Worker BullMQ
|
| 291 |
+
|
| 292 |
+
**Localisation :** `apps/whatsapp-worker/src/`
|
| 293 |
+
|
| 294 |
+
### 4.1 Queues
|
| 295 |
+
|
| 296 |
+
| Queue | Jobs |
|
| 297 |
+
|-------|------|
|
| 298 |
+
| `whatsapp-queue` | inbound-message, inbound-media, send-message, send-content, enroll, nudge, broadcast, kb-process, generate-feedback, send-admin-audio-override |
|
| 299 |
+
| `notification-queue` | email |
|
| 300 |
+
|
| 301 |
+
**Retry policy :** 3 tentatives avec backoff exponentiel
|
| 302 |
+
|
| 303 |
+
### 4.2 Chaîne de handlers (ordre strict)
|
| 304 |
+
|
| 305 |
+
```
|
| 306 |
+
WhatsAppLogic.handleIncomingMessage()
|
| 307 |
+
1. EntityResolver.resolve() → User/Contact/Org/Enrollment
|
| 308 |
+
2. Message log (async, non-bloquant)
|
| 309 |
+
3. Credit guard → WalletExhaustedError si solde = 0
|
| 310 |
+
4. AIAgentHandler.canHandle() → mode === AI_AGENT
|
| 311 |
+
5. OnboardingHandler.canHandle() → INSCRIPTION / sélection langue / secteur
|
| 312 |
+
6. CommandHandler.canHandle() → SEED / RECHARGE / DAY{N}_{ACTION}
|
| 313 |
+
7. NavigationHandler.canHandle() → SUITE / APPROFONDIR
|
| 314 |
+
8. ExerciseHandler.canHandle() → réponse exercice (texte/audio/image)
|
| 315 |
+
9. Fallback → message de bienvenue
|
| 316 |
+
```
|
| 317 |
+
|
| 318 |
+
### 4.3 Handler AIAgentHandler
|
| 319 |
+
|
| 320 |
+
**Condition :** `organization.mode === 'AI_AGENT'`
|
| 321 |
+
|
| 322 |
+
**Flux :**
|
| 323 |
+
1. Construire system prompt depuis `customPrompt` + directive langue
|
| 324 |
+
2. Si KB existe → `IndexingService.searchRelevantContext()` (top 3 chunks cosinus)
|
| 325 |
+
3. Appel `AIPedagogyService.generateChat()` → réponse
|
| 326 |
+
4. Envoi via `whatsapp-queue`
|
| 327 |
+
|
| 328 |
+
**Lacunes :**
|
| 329 |
+
- Pas de classification d'intention avant la recherche KB
|
| 330 |
+
- Pas de seuil de pertinence (retourne toujours top-3, même peu pertinents)
|
| 331 |
+
- Pas de mémoire conversationnelle (chaque message est traité indépendamment)
|
| 332 |
+
- Pas de handoff humain automatique si confidence < seuil
|
| 333 |
+
|
| 334 |
+
### 4.4 Handler OnboardingHandler
|
| 335 |
+
|
| 336 |
+
**Flux :**
|
| 337 |
+
1. Mot-clé `INSCRIPTION` → réinitialisation cascade (supprime enrollments/progress/responses)
|
| 338 |
+
2. Sélection langue (LANG_FR, LANG_WO, LANG_EN, LANG_ES, LANG_PT)
|
| 339 |
+
3. Sélection secteur (liste prédéfinie ou saisie libre)
|
| 340 |
+
4. Auto-enroll via `defaultTrackId` (flowConfig)
|
| 341 |
+
|
| 342 |
+
**Lacunes :**
|
| 343 |
+
- Reset INSCRIPTION = suppression définitive (pas de soft-delete)
|
| 344 |
+
- Secteurs hardcodés ou via flowConfig (fragile)
|
| 345 |
+
- Pas de vérification téléphone / OTP
|
| 346 |
+
|
| 347 |
+
### 4.5 Handler ExerciseHandler
|
| 348 |
+
|
| 349 |
+
**Flux complexe :**
|
| 350 |
+
1. Fetch `userProgress` pour le track actif
|
| 351 |
+
2. Résolution du jour effectif (time-travel Redis OU currentDay)
|
| 352 |
+
3. Validation longueur réponse (min 3 mots sauf boutons/images)
|
| 353 |
+
4. Envoi message "spinner" (feedback en cours)
|
| 354 |
+
5. Queue `generate-feedback` avec 40+ paramètres de contexte
|
| 355 |
+
|
| 356 |
+
**Paramètres envoyés au générateur de feedback :**
|
| 357 |
+
- exercicePrompt, exerciseCriteria, userResponse
|
| 358 |
+
- previousResponses, businessProfile, language
|
| 359 |
+
- iterationCount, exerciseStatus, dayNumber
|
| 360 |
+
- trackTitle, userActivity, isDeepDive
|
| 361 |
+
|
| 362 |
+
**Lacunes :**
|
| 363 |
+
- Pas de détection de doublon dans les 30 secondes
|
| 364 |
+
- Validation longueur simpliste (split whitespace)
|
| 365 |
+
|
| 366 |
+
### 4.6 Handler FeedbackHandler
|
| 367 |
+
|
| 368 |
+
**Flux :**
|
| 369 |
+
1. `aiService.generateFeedback()` avec contexte complet
|
| 370 |
+
2. **2 branches selon isQualified :**
|
| 371 |
+
- ❌ Échec : Message de relance + indication pour réessayer
|
| 372 |
+
- ✅ Succès : Feedback enrichi + conseils actionnables + prompt deep-dive
|
| 373 |
+
3. Update atomique `exerciseStatus` AVANT envoi message (règle d'atomicité)
|
| 374 |
+
4. Envoi via queue
|
| 375 |
+
5. **Jour 11 spécial :** Extraction membre équipe depuis image si qualifié
|
| 376 |
+
|
| 377 |
+
### 4.7 Scheduler (Tâches planifiées)
|
| 378 |
+
|
| 379 |
+
```
|
| 380 |
+
UTC 08:00 quotidien (= 08:00 Dakar) :
|
| 381 |
+
Pour chaque enrollment ACTIVE :
|
| 382 |
+
Si exercice PENDING + 24h sans activité → Nudge ENCOURAGEMENT
|
| 383 |
+
Si exercice PENDING + 72h sans activité → Nudge RESURRECTION
|
| 384 |
+
Sinon → Queue send-content (leçon du jour suivant)
|
| 385 |
+
|
| 386 |
+
Chaque lundi 09:00 UTC :
|
| 387 |
+
Pour chaque org avec systemUserToken :
|
| 388 |
+
50 jours → Alerte WARNING (token va expirer dans ~10j)
|
| 389 |
+
55 jours → Alerte CRITICAL
|
| 390 |
+
60 jours → Alerte EXPIRÉ
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
**Lacunes :**
|
| 394 |
+
- Nudges non personnalisés (même message par langue)
|
| 395 |
+
- Pas de tentative de renouvellement token automatique
|
| 396 |
+
- Alertes token uniquement dans les logs (pas d'email/push)
|
| 397 |
+
|
| 398 |
+
### 4.8 Scoring comportemental
|
| 399 |
+
|
| 400 |
+
**4 dimensions (0-100) :**
|
| 401 |
+
- `discipline_financiere` — Gestion finances
|
| 402 |
+
- `organisation` — Structure opérationnelle
|
| 403 |
+
- `relation_client` — Qualité service client
|
| 404 |
+
- `risque_management` — Gestion des risques
|
| 405 |
+
|
| 406 |
+
**Niveaux :** Informel → Structuration → Organisé → Avancé
|
| 407 |
+
|
| 408 |
+
### 4.9 ContentHandler (Livraison leçons)
|
| 409 |
+
|
| 410 |
+
**Flux :**
|
| 411 |
+
1. `sendLessonDay()` depuis `pedagogy.ts`
|
| 412 |
+
2. Personnalisation IA (timeout 15s, fallback texte brut)
|
| 413 |
+
3. Envoi visuel (vidéo → fallback image)
|
| 414 |
+
4. Audio (TTS généré ou pré-enregistré)
|
| 415 |
+
5. Boutons exercice interactifs
|
| 416 |
+
6. **Logique de graduation :** Si pas de jour N+1 → COMPLETED + auto-enroll T{N+1}-LANG
|
| 417 |
+
|
| 418 |
+
### 4.10 KBProcessor (Indexation base de connaissance)
|
| 419 |
+
|
| 420 |
+
**Flux :**
|
| 421 |
+
1. Parse document (PDF, Excel, site web HTML)
|
| 422 |
+
2. Découpage en chunks (1000 chars, overlap 200)
|
| 423 |
+
3. Génération embeddings via OpenAI batch
|
| 424 |
+
4. Insertion pgvector via SQL brut (`prisma.$executeRaw`)
|
| 425 |
+
|
| 426 |
+
**Lacunes :**
|
| 427 |
+
- Pas de déduplication de chunks
|
| 428 |
+
- Ré-indexation efface les anciens (pas de versioning)
|
| 429 |
+
- Pas de progress tracking pour gros fichiers
|
| 430 |
+
- Crawl web limité (depth 2, 10 liens/page)
|
| 431 |
+
|
| 432 |
+
---
|
| 433 |
+
|
| 434 |
+
## 5. SDK IA
|
| 435 |
+
|
| 436 |
+
**Localisation :** `packages/ai-sdk/`
|
| 437 |
+
|
| 438 |
+
### 5.1 Architecture multi-provider
|
| 439 |
+
|
| 440 |
+
```
|
| 441 |
+
AIService
|
| 442 |
+
└── ProviderRegistry
|
| 443 |
+
├── GeminiProvider (priority: 100) → TEXT, VISION, AUDIO
|
| 444 |
+
├── OpenAIProvider (priority: 50) → TEXT, AUDIO, IMAGE, SPEECH
|
| 445 |
+
└── TenantProviders (priority: 1000) → BYOK par org
|
| 446 |
+
```
|
| 447 |
+
|
| 448 |
+
**Failover automatique :** Si le provider principal échoue, le suivant est essayé.
|
| 449 |
+
|
| 450 |
+
### 5.2 Capacités par provider
|
| 451 |
+
|
| 452 |
+
| Capacité | Gemini | OpenAI |
|
| 453 |
+
|----------|--------|--------|
|
| 454 |
+
| Texte (TEXT) | Flash/Pro | GPT-4o |
|
| 455 |
+
| Vision (IMAGE) | Flash (inlineData base64) | GPT-4o Vision |
|
| 456 |
+
| Transcription audio | — | Whisper |
|
| 457 |
+
| Génération parole (TTS) | — | TTS-1 |
|
| 458 |
+
| Génération image | — | DALL-E 3 |
|
| 459 |
+
| Recherche web | Grounding API | — |
|
| 460 |
+
|
| 461 |
+
**Note Gemini :** Toujours base64 `inlineData` pour la vision (jamais URL — instable).
|
| 462 |
+
|
| 463 |
+
### 5.3 Coûts modèles (Mai 2026)
|
| 464 |
+
|
| 465 |
+
| Modèle | Input (/1M tokens) | Output (/1M tokens) |
|
| 466 |
+
|--------|-------------------|---------------------|
|
| 467 |
+
| GPT-4o | $7.50 | $15.00 |
|
| 468 |
+
| GPT-4o-mini | $0.15 | $0.60 |
|
| 469 |
+
| Gemini Flash | $0.075 | $0.30 |
|
| 470 |
+
| Gemini Pro | $3.50 | $10.50 |
|
| 471 |
+
| Whisper | $0.006/min | — |
|
| 472 |
+
|
| 473 |
+
### 5.4 Cache tenant
|
| 474 |
+
|
| 475 |
+
- Configuration personnalité mise en cache Redis (TTL 1h)
|
| 476 |
+
- Cache template PromptLoader en mémoire (durée de vie du processus)
|
| 477 |
+
- **Pas** de cache au niveau requête LLM
|
| 478 |
+
|
| 479 |
+
### 5.5 BYOK (Bring Your Own Key)
|
| 480 |
+
|
| 481 |
+
- Plan SCALE uniquement
|
| 482 |
+
- `openAiApiKey` et `googleAiApiKey` par org dans Prisma (chiffrés)
|
| 483 |
+
- Providers tenant créés dynamiquement avec ces clés (priority 1000)
|
| 484 |
+
- Débit wallet flagué `byok: true` (non facturé en crédits normaux)
|
| 485 |
+
|
| 486 |
+
---
|
| 487 |
+
|
| 488 |
+
## 6. Frontend Admin
|
| 489 |
+
|
| 490 |
+
**Technologie :** React 18 + Vite + Tailwind CSS + react-i18next (FR/EN/ES/PT)
|
| 491 |
+
|
| 492 |
+
### 6.1 Pages et composants
|
| 493 |
+
|
| 494 |
+
#### Dashboard (`DashboardPage.tsx`)
|
| 495 |
+
- Cartes stats : utilisateurs totaux, actifs, complétés, tracks, revenue
|
| 496 |
+
- Table inscriptions paginée + tri
|
| 497 |
+
- Export CSV inscriptions
|
| 498 |
+
- Sélecteur d'organisation (multi-tenant)
|
| 499 |
+
- Timeout 15s avec retry
|
| 500 |
+
|
| 501 |
+
#### Analytics (`AnalyticsPage.tsx`)
|
| 502 |
+
- Graphiques Recharts (Bar, Pie)
|
| 503 |
+
- Usage IA : appels, tokens, coût FCFA
|
| 504 |
+
- Pédagogie : taux complétion, score moyen, temps moyen
|
| 505 |
+
- Campagnes : funnel SENT → DELIVERED → READ (4 couleurs)
|
| 506 |
+
|
| 507 |
+
#### AI Agent Setup (`AIAgentSetup.tsx`)
|
| 508 |
+
- Éditeur mission (coreMission)
|
| 509 |
+
- Sélecteur de ton (Professionnel/Amical/Direct/Pédagogue) avec descriptions i18n
|
| 510 |
+
- Recommandation ton selon mode org
|
| 511 |
+
- Upload base de connaissance (PDF/DOCX/XLSX/CSV)
|
| 512 |
+
- Stats KB : nb chunks, % coverage
|
| 513 |
+
- Chat test en temps réel
|
| 514 |
+
- Indicateur qualité KB (Excellent / Bon / Insuffisant)
|
| 515 |
+
|
| 516 |
+
#### Billing (`BillingPage.tsx`)
|
| 517 |
+
- Résumé wallet (crédits, FCFA, statut)
|
| 518 |
+
- Graphique historique 30j
|
| 519 |
+
- Ventilation par fonctionnalité
|
| 520 |
+
- Alertes solde bas / service suspendu
|
| 521 |
+
|
| 522 |
+
#### Contacts CRM (`ContactsPage.tsx`)
|
| 523 |
+
- Import Excel avec colonnes dynamiques
|
| 524 |
+
- Table contacts avec recherche/filtre
|
| 525 |
+
- Envoi message direct depuis l'interface
|
| 526 |
+
- Suppression bulk
|
| 527 |
+
|
| 528 |
+
#### Templates (`TemplatesPage.tsx`)
|
| 529 |
+
- Liste templates Meta avec statut (PENDING/APPROVED/REJECTED)
|
| 530 |
+
- Création template avec générateur IA
|
| 531 |
+
- Variables `{{1}}`, `{{2}}` dans éditeur
|
| 532 |
+
- Aperçu avant soumission
|
| 533 |
+
- Sélection catégorie (MARKETING/UTILITY)
|
| 534 |
+
|
| 535 |
+
#### Tracks (`TrackListPage.tsx` + `TrackFormPage.tsx` + `TrackDaysPage.tsx`)
|
| 536 |
+
- CRUD complet parcours de formation
|
| 537 |
+
- Gestion jours (leçon + exercice + boutons)
|
| 538 |
+
- Support multi-langue par contenu
|
| 539 |
+
- Éditeur jours avec drag & drop (jour 1.5 supporté)
|
| 540 |
+
|
| 541 |
+
#### Users (`UserListPage.tsx`)
|
| 542 |
+
- Liste utilisateurs avec filtres
|
| 543 |
+
- Détail conversation par utilisateur
|
| 544 |
+
- Actions : override feedback, voir historique
|
| 545 |
+
|
| 546 |
+
#### Training Lab (`TrainingLab.tsx`)
|
| 547 |
+
- Révision manuelle transcriptions Whisper
|
| 548 |
+
- Correction + calcul WER (Word Error Rate)
|
| 549 |
+
- Suggestions normalization (règles de post-traitement)
|
| 550 |
+
- Application batch des règles + recalcul global
|
| 551 |
+
- Upload audio manuel pour test
|
| 552 |
+
|
| 553 |
+
#### Knowledge Base (`KnowledgeBasePage.tsx`)
|
| 554 |
+
- Gestion chunks KB
|
| 555 |
+
- Suppression chunks individuels
|
| 556 |
+
- Stats indexation
|
| 557 |
+
|
| 558 |
+
#### Settings (`SettingsPage.tsx`)
|
| 559 |
+
- Configuration mode, WhatsApp (WABA ID, Business ID, token)
|
| 560 |
+
- Gestion clés API BYOK (plan SCALE)
|
| 561 |
+
- Configuration avancée JSON (flowConfig)
|
| 562 |
+
- Statut token WhatsApp avec alerte expiration
|
| 563 |
+
|
| 564 |
+
#### CRM Inbox (`CrmInbox.tsx`)
|
| 565 |
+
- Conversations temps réel
|
| 566 |
+
- Réponse directe depuis l'interface
|
| 567 |
+
- Statuts messages (lu/délivré)
|
| 568 |
+
|
| 569 |
+
#### AdminChat (`AdminChat.tsx`)
|
| 570 |
+
- Copilote IA contextuel par page
|
| 571 |
+
- 6 pages : billing, settings, templates, agent, onboarding, general
|
| 572 |
+
- Questions suggérées pré-remplies par page
|
| 573 |
+
- **Agentic :** Peut changer le mode, mettre à jour la personnalité, lire la config
|
| 574 |
+
- 4 langues (FR/EN/ES/PT)
|
| 575 |
+
|
| 576 |
+
#### Campaign History (`CampaignHistoryPage.tsx`)
|
| 577 |
+
- Historique campagnes avec funnel
|
| 578 |
+
- Détail par campagne (SENT/DELIVERED/READ/FAILED)
|
| 579 |
+
|
| 580 |
+
### 6.2 Internationalisation
|
| 581 |
+
- 4 langues : FR (défaut), EN, ES, PT
|
| 582 |
+
- Fichiers : `apps/admin/src/locales/{fr,en,es,pt}.json`
|
| 583 |
+
- Hook `useTranslation()` partout
|
| 584 |
+
- `LanguageSwitcher` composant global
|
| 585 |
+
|
| 586 |
+
### 6.3 État de l'art UX actuel
|
| 587 |
+
|
| 588 |
+
**Points forts :**
|
| 589 |
+
- Design cohérent Tailwind CSS
|
| 590 |
+
- Multi-tenant natif (sélecteur org)
|
| 591 |
+
- Copilote IA intégré sur chaque page
|
| 592 |
+
- Internationalisation complète
|
| 593 |
+
|
| 594 |
+
**Lacunes UX :**
|
| 595 |
+
- Pas de notifications temps réel (polling manuel)
|
| 596 |
+
- Pas de mode sombre
|
| 597 |
+
- Pas de raccourcis clavier
|
| 598 |
+
- Pas de tour guidé (onboarding admin)
|
| 599 |
+
- Pas de visualisation de la progression utilisateur en temps réel
|
| 600 |
+
- Tableau de bord non personnalisable (widgets fixes)
|
| 601 |
+
- Pas d'export PDF des rapports
|
| 602 |
+
|
| 603 |
+
---
|
| 604 |
+
|
| 605 |
+
## 7. Prompts & Templates IA
|
| 606 |
+
|
| 607 |
+
**Localisation :** `packages/prompts/src/templates/`
|
| 608 |
+
|
| 609 |
+
### 7.1 Templates disponibles
|
| 610 |
+
|
| 611 |
+
| Fichier | Usage |
|
| 612 |
+
|---------|-------|
|
| 613 |
+
| `feedback-base.md` | Feedback exercice — structure de base |
|
| 614 |
+
| `action-feedback-standard.md` | Feedback exercice — variante action |
|
| 615 |
+
| `personalized-lesson.md` | Réécriture leçon selon profil utilisateur |
|
| 616 |
+
| `business-profile-extraction.md` | Extraction profil business depuis texte |
|
| 617 |
+
| `crm-campaign.md` | Génération message campagne personnalisé |
|
| 618 |
+
| `crm-assistant-system.md` | System prompt assistant CRM |
|
| 619 |
+
| `broadcast-router.md` | Routage messages broadcast |
|
| 620 |
+
| `one-pager.md` | Génération PDF one-pager |
|
| 621 |
+
| `pitch-deck.md` | Génération PPTX pitch deck |
|
| 622 |
+
|
| 623 |
+
### 7.2 Système de compilation
|
| 624 |
+
|
| 625 |
+
```typescript
|
| 626 |
+
PromptLoader.compile(templateName, variables, personality)
|
| 627 |
+
// Inject variables: {{variableName}}
|
| 628 |
+
// Inject personality: {{botName}}, {{coreMission}}, {{toneDescription}}, {{constraints}}
|
| 629 |
+
// Cache en mémoire (durée de vie du processus)
|
| 630 |
+
```
|
| 631 |
+
|
| 632 |
+
### 7.3 Personnalité par défaut (fallback)
|
| 633 |
+
|
| 634 |
+
```
|
| 635 |
+
botName: "XAMLÉ COACH"
|
| 636 |
+
coreMission: "expert business pour entrepreneurs d'Afrique de l'Ouest"
|
| 637 |
+
toneDescription: "direct, dynamique et encourageant. Style WhatsApp (gras *texte*, emojis)"
|
| 638 |
+
constraints: ["JAMAIS ANGLAIS", "Ne jamais citer 'Manga Deaf'"]
|
| 639 |
+
```
|
| 640 |
+
|
| 641 |
+
---
|
| 642 |
+
|
| 643 |
+
## 8. Tarification & Wallet
|
| 644 |
+
|
| 645 |
+
**Table de prix :** `packages/database/src/credit-pricing.ts`
|
| 646 |
+
|
| 647 |
+
```
|
| 648 |
+
1 crédit = 10 FCFA
|
| 649 |
+
|
| 650 |
+
WHATSAPP_CONVERSATION : 1 crédit (tout message entrant ou sortant)
|
| 651 |
+
AI_TEXT : 3 crédits (génération texte, non-BYOK)
|
| 652 |
+
AI_AUDIO : 2 crédits (transcription Whisper, non-BYOK)
|
| 653 |
+
BROADCAST_PER_USER : 3 crédits (par destinataire campagne)
|
| 654 |
+
|
| 655 |
+
Seuils d'alerte wallet :
|
| 656 |
+
LOW : 200 crédits (bannière orange)
|
| 657 |
+
CRITICAL : 50 crédits (alerte rouge urgente)
|
| 658 |
+
```
|
| 659 |
+
|
| 660 |
+
**Plans :**
|
| 661 |
+
|
| 662 |
+
| Plan | Crédits IA/mois | BYOK | SLA |
|
| 663 |
+
|------|----------------|------|-----|
|
| 664 |
+
| STARTER | 500 | ❌ | Standard |
|
| 665 |
+
| GROWTH | 3 000 | ❌ | Standard |
|
| 666 |
+
| SCALE | 10 000 | ✅ | Prioritaire |
|
| 667 |
+
| ENTERPRISE | Illimité | ✅ | Dédié |
|
| 668 |
+
|
| 669 |
+
---
|
| 670 |
+
|
| 671 |
+
## 9. Lacunes & Dette technique
|
| 672 |
+
|
| 673 |
+
### 9.1 Sécurité (priorité haute)
|
| 674 |
+
|
| 675 |
+
| # | Problème | Impact | Solution recommandée |
|
| 676 |
+
|---|----------|--------|---------------------|
|
| 677 |
+
| S1 | `organizationId` depuis header (non JWT) | Usurpation d'identité possible | Extraire de `req.user.organizationId` uniquement |
|
| 678 |
+
| S2 | Pas de rate-limiting webhook Meta | DDoS vulnérable | `@fastify/rate-limit` avec whitelist Meta IPs |
|
| 679 |
+
| S3 | Tools IA agentiques sans garde-fous de rôle | N'importe quel admin change le mode | Exiger SUPER_ADMIN pour `change_organization_mode` |
|
| 680 |
+
| S4 | Pas d'audit log | Impossible de tracer qui a changé quoi | Middleware Prisma → AuditLog sur toutes les updates critiques |
|
| 681 |
+
|
| 682 |
+
### 9.2 Données & intégrité
|
| 683 |
+
|
| 684 |
+
| # | Problème | Impact | Solution |
|
| 685 |
+
|---|----------|--------|----------|
|
| 686 |
+
| D1 | Pas de soft-delete | Perte irréversible sur INSCRIPTION reset | `deletedAt` sur User/Enrollment/UserProgress |
|
| 687 |
+
| D2 | Pas de contrôle de concurrence | Double soumission exercice possible | Version field (optimistic locking) ou Redis lock |
|
| 688 |
+
| D3 | Coûts IA estimés, pas réels | Billing inexact | API usage OpenAI/Google pour coûts réels |
|
| 689 |
+
| D4 | Pas de validation schemas JSON | `personalityConfig`, `flowConfig` peuvent être malformés | Zod validation à l'entrée |
|
| 690 |
+
|
| 691 |
+
### 9.3 IA & LLM
|
| 692 |
+
|
| 693 |
+
| # | Problème | Impact | Solution |
|
| 694 |
+
|---|----------|--------|----------|
|
| 695 |
+
| A1 | Pas de retry sur échec LLM | Silence si timeout | Exponential backoff + dead-letter queue |
|
| 696 |
+
| A2 | Pas de cache prompts | Recoût inutile sur leçons identiques | Redis cache par (lesson_id, activity) |
|
| 697 |
+
| A3 | RAG naïf (top-3 sans seuil) | Réponses hors-sujet si KB sparse | Relevance threshold (cosine > 0.75) |
|
| 698 |
+
| A4 | Pas de validation output LLM | `isQualified` pourrait être mal parsé | Structured outputs / JSON mode strict |
|
| 699 |
+
| A5 | Pas de mémoire conversationnelle AI_AGENT | Contexte perdu entre messages | Redis sliding window (5 derniers messages) |
|
| 700 |
+
| A6 | Transcription sans détection langue | Wolof mal transcrit | Whisper `language` param basé sur `user.language` |
|
| 701 |
+
|
| 702 |
+
### 9.4 Performance & scalabilité
|
| 703 |
+
|
| 704 |
+
| # | Problème | Impact | Solution |
|
| 705 |
+
|---|----------|--------|----------|
|
| 706 |
+
| P1 | N+1 dans `sendLessonDay()` | Lenteur avec > 100 inscriptions actives | Batch fetch avec Prisma `include` |
|
| 707 |
+
| P2 | Embeddings synchrones pour gros fichiers | Timeout worker sur upload KB | Chunking + embeddings en batch asynchrone |
|
| 708 |
+
| P3 | Pas de connection pooling explicite | Saturation pool Prisma (défaut 5) | `connection_limit` selon nb threads worker |
|
| 709 |
+
|
| 710 |
+
### 9.5 Observabilité
|
| 711 |
+
|
| 712 |
+
| # | Problème | Impact | Solution |
|
| 713 |
+
|---|----------|--------|----------|
|
| 714 |
+
| O1 | Pas de traceId propagé aux jobs async | Impossible de tracer une requête bout-en-bout | Passer traceId dans `job.data` |
|
| 715 |
+
| O2 | Pas de distributed tracing | Vision nulle sur latences inter-services | OpenTelemetry (OTEL) collector |
|
| 716 |
+
| O3 | Quota alerts dans logs seulement | Admin ne reçoit pas d'alerte solde bas | Email + push notification temps réel |
|
| 717 |
+
|
| 718 |
+
### 9.6 Fonctionnalités manquantes
|
| 719 |
+
|
| 720 |
+
| # | Fonctionnalité | Valeur | Effort |
|
| 721 |
+
|---|---------------|--------|--------|
|
| 722 |
+
| F1 | Scheduling campagnes (`sendAt`) | Élevée | Faible |
|
| 723 |
+
| F2 | Segmentation contacts par tags | Élevée | Moyen |
|
| 724 |
+
| F3 | A/B testing feedback prompts | Moyenne | Moyen |
|
| 725 |
+
| F4 | Bulk enroll via API | Élevée | Faible |
|
| 726 |
+
| F5 | Notifications temps réel admin (WebSocket/SSE) | Élevée | Moyen |
|
| 727 |
+
| F6 | Export PDF rapports | Moyenne | Faible |
|
| 728 |
+
| F7 | Mémoire conversationnelle AI_AGENT | Élevée | Faible |
|
| 729 |
+
| F8 | Versioning KB | Moyenne | Moyen |
|
| 730 |
+
| F9 | Rapport hebdomadaire par email | Élevée | Faible |
|
| 731 |
+
| F10 | Tableau de bord personnalisable | Moyenne | Élevé |
|
| 732 |
+
|
| 733 |
+
---
|
| 734 |
+
|
| 735 |
+
## 10. Roadmap IA Agentique
|
| 736 |
+
|
| 737 |
+
> **Définition :** Un agent IA est un système qui perçoit son environnement, prend des décisions, et exécute des actions de manière autonome — en boucle, avec des outils, sans intervention humaine sur chaque étape.
|
| 738 |
+
|
| 739 |
+
### 10.1 Ce qui est déjà agentique (en production)
|
| 740 |
+
|
| 741 |
+
| Fonctionnalité | Description | Outils utilisés |
|
| 742 |
+
|---------------|-------------|-----------------|
|
| 743 |
+
| **Copilote Admin** | Change le mode org, met à jour la personnalité | `change_organization_mode`, `update_ai_agent_personality`, `get_organization_settings` |
|
| 744 |
+
| **Feedback exercice 2-branches** | Décide qualifié/non, génère feedback personnalisé | `generateFeedback()` → mise à jour DB → envoi message |
|
| 745 |
+
| **RAG Agent IA** | Cherche dans la KB, formule réponse contextuelle | `searchRelevantContext()` → `generateChat()` |
|
| 746 |
+
| **Graduation automatique** | Détecte fin de track, inscrit au niveau suivant | `ContentHandler` + détection T{N}→T{N+1} |
|
| 747 |
+
| **Scheduler Nudge** | Analyse inactivité, envoie relances | `scheduler.ts` → BullMQ jobs |
|
| 748 |
+
|
| 749 |
+
### 10.2 Agentique — Gains immédiats (0-4 semaines)
|
| 750 |
+
|
| 751 |
+
#### 10.2.1 Mémoire conversationnelle pour AI_AGENT
|
| 752 |
+
|
| 753 |
+
**Problème actuel :** Chaque message est traité indépendamment — l'agent "oublie" ce qu'il vient de dire.
|
| 754 |
+
|
| 755 |
+
**Solution :**
|
| 756 |
+
```typescript
|
| 757 |
+
// Dans AIAgentHandler.ts
|
| 758 |
+
const conversationHistory = await redis.lrange(`conv:${userId}`, 0, 9); // 10 derniers messages
|
| 759 |
+
const messages = [
|
| 760 |
+
{ role: 'system', content: systemPrompt },
|
| 761 |
+
...conversationHistory.map(m => JSON.parse(m)),
|
| 762 |
+
{ role: 'user', content: text }
|
| 763 |
+
];
|
| 764 |
+
await redis.lpush(`conv:${userId}`, JSON.stringify({ role: 'user', content: text }));
|
| 765 |
+
// Après réponse :
|
| 766 |
+
await redis.lpush(`conv:${userId}`, JSON.stringify({ role: 'assistant', content: answer }));
|
| 767 |
+
await redis.ltrim(`conv:${userId}`, 0, 19); // Garder 20 messages max
|
| 768 |
+
await redis.expire(`conv:${userId}`, 86400); // TTL 24h
|
| 769 |
+
```
|
| 770 |
+
|
| 771 |
+
**Impact :** Conversations cohérentes, expérience client transformée.
|
| 772 |
+
|
| 773 |
+
#### 10.2.2 Alertes intelligentes solde wallet
|
| 774 |
+
|
| 775 |
+
**Problème actuel :** L'admin apprend que le service est suspendu quand c'est trop tard.
|
| 776 |
+
|
| 777 |
+
**Solution — Agent de surveillance financière :**
|
| 778 |
+
```typescript
|
| 779 |
+
// Dans scheduler.ts — ajouter toutes les heures
|
| 780 |
+
const orgsAtRisk = await prisma.organization.findMany({
|
| 781 |
+
where: { walletBalance: { lte: 200 }, isHardStopped: false }
|
| 782 |
+
});
|
| 783 |
+
for (const org of orgsAtRisk) {
|
| 784 |
+
// Calculer burn rate 7 derniers jours
|
| 785 |
+
const weeklyDebit = ...; const daysLeft = org.walletBalance / (weeklyDebit / 7);
|
| 786 |
+
if (daysLeft < 3) await sendAlertEmail(org, daysLeft);
|
| 787 |
+
if (daysLeft < 1) await sendUrgentPush(org);
|
| 788 |
+
}
|
| 789 |
+
```
|
| 790 |
+
|
| 791 |
+
#### 10.2.3 Rapport hebdomadaire automatique
|
| 792 |
+
|
| 793 |
+
**Un email tous les lundis avec :**
|
| 794 |
+
- Stats semaine (messages, complétion, coût)
|
| 795 |
+
- Comparaison avec semaine précédente (+/- %)
|
| 796 |
+
- Top 3 utilisateurs les plus actifs
|
| 797 |
+
- Recommandation IA : "Votre taux de complétion a baissé de 15% — pensez à vérifier le contenu du Jour 7"
|
| 798 |
+
|
| 799 |
+
**Implémentation :** Job cron lundi 07:00, génération HTML email via GPT-4o-mini, envoi via `notification-queue`.
|
| 800 |
+
|
| 801 |
+
#### 10.2.4 Seuil de pertinence RAG
|
| 802 |
+
|
| 803 |
+
```typescript
|
| 804 |
+
// Dans AIAgentHandler.ts
|
| 805 |
+
const chunks = await indexingService.searchRelevantContext(text, organizationId, { threshold: 0.75 });
|
| 806 |
+
if (chunks.length === 0) {
|
| 807 |
+
// Répondre honnêtement "Je n'ai pas d'information sur ce sujet"
|
| 808 |
+
return "Je ne dispose pas d'informations précises sur ce sujet. Puis-je vous aider autrement ?";
|
| 809 |
+
}
|
| 810 |
+
```
|
| 811 |
+
|
| 812 |
+
#### 10.2.5 Détection et handoff humain automatique
|
| 813 |
+
|
| 814 |
+
**Pour mode CUSTOMER_SERVICE :**
|
| 815 |
+
```typescript
|
| 816 |
+
const needsHuman = await detectEscalation(text); // Sentiment très négatif, "parler à un humain", etc.
|
| 817 |
+
if (needsHuman) {
|
| 818 |
+
await sendToHumanQueue(userId, organizationId, text);
|
| 819 |
+
await sendMessage(phone, "Je vous transfère à un conseiller humain. Merci de patienter.");
|
| 820 |
+
}
|
| 821 |
+
```
|
| 822 |
+
|
| 823 |
+
### 10.3 Agentique — Gains stratégiques (1-3 mois)
|
| 824 |
+
|
| 825 |
+
#### 10.3.1 Agent Créateur de Contenu
|
| 826 |
+
|
| 827 |
+
**Problème :** Créer un Track de 21 jours prend des semaines manuellement.
|
| 828 |
+
|
| 829 |
+
**Vision :** L'admin décrit son objectif pédagogique → l'agent génère tout le curriculum.
|
| 830 |
+
|
| 831 |
+
**Outils :**
|
| 832 |
+
```typescript
|
| 833 |
+
tools: [
|
| 834 |
+
{ name: 'create_track', description: 'Crée un Track avec titre et durée' },
|
| 835 |
+
{ name: 'create_track_day', description: 'Crée un jour (leçon + exercice + critères)' },
|
| 836 |
+
{ name: 'generate_lesson_content', description: 'Génère le texte de la leçon' },
|
| 837 |
+
{ name: 'generate_exercise', description: 'Génère l\'exercice et ses critères de validation' },
|
| 838 |
+
{ name: 'validate_curriculum', description: 'Vérifie la cohérence pédagogique du parcours' }
|
| 839 |
+
]
|
| 840 |
+
```
|
| 841 |
+
|
| 842 |
+
**Flux agent :**
|
| 843 |
+
1. Admin : "Crée un programme de 7 jours sur la gestion financière pour commerçants s��négalais"
|
| 844 |
+
2. Agent → `generate_curriculum_outline()` → 7 thèmes progressifs
|
| 845 |
+
3. Agent → `create_track()` → Track créé
|
| 846 |
+
4. Boucle 7 fois → `create_track_day()` avec contenu généré
|
| 847 |
+
5. Agent → `validate_curriculum()` → Vérification cohérence
|
| 848 |
+
6. Agent → Rapport final : "Programme créé. 7 leçons, 7 exercices. Prêt à être publié."
|
| 849 |
+
|
| 850 |
+
#### 10.3.2 Agent Optimiseur de Campagnes
|
| 851 |
+
|
| 852 |
+
**Vision :** Analyse les performances passées et suggère les meilleures heures/jours/messages pour les prochaines campagnes.
|
| 853 |
+
|
| 854 |
+
**Outils :**
|
| 855 |
+
```typescript
|
| 856 |
+
tools: [
|
| 857 |
+
{ name: 'get_campaign_analytics', description: 'Récupère les métriques des campagnes passées' },
|
| 858 |
+
{ name: 'segment_contacts', description: 'Segmente les contacts par comportement' },
|
| 859 |
+
{ name: 'generate_message_variants', description: 'Génère des variantes de messages A/B' },
|
| 860 |
+
{ name: 'schedule_campaign', description: 'Programme la campagne au meilleur moment' },
|
| 861 |
+
{ name: 'measure_ab_results', description: 'Compare les résultats des variantes' }
|
| 862 |
+
]
|
| 863 |
+
```
|
| 864 |
+
|
| 865 |
+
**Exemple d'analyse :**
|
| 866 |
+
- "Les messages envoyés entre 10h-12h ont 34% de taux de lecture vs 12% le soir"
|
| 867 |
+
- "Les messages < 80 caractères ont 2x plus de réponses"
|
| 868 |
+
- "Le segment Dakar répond 40% mieux aux offres en wolof"
|
| 869 |
+
|
| 870 |
+
#### 10.3.3 Agent Conseiller Pédagogique
|
| 871 |
+
|
| 872 |
+
**Vision :** Analyse les résultats des apprenants et propose des interventions personnalisées à l'admin.
|
| 873 |
+
|
| 874 |
+
**Outils :**
|
| 875 |
+
```typescript
|
| 876 |
+
tools: [
|
| 877 |
+
{ name: 'get_cohort_analytics', description: 'Analyse les résultats d\'une cohorte' },
|
| 878 |
+
{ name: 'identify_at_risk_students', description: 'Identifie les apprenants en difficulté' },
|
| 879 |
+
{ name: 'suggest_intervention', description: 'Propose une action pour chaque apprenant à risque' },
|
| 880 |
+
{ name: 'queue_personalized_nudge', description: 'Envoie un nudge personnalisé' },
|
| 881 |
+
{ name: 'adjust_track_content', description: 'Modifie le contenu d\'un jour si trop difficile' }
|
| 882 |
+
]
|
| 883 |
+
```
|
| 884 |
+
|
| 885 |
+
**Rapport hebdomadaire automatique :**
|
| 886 |
+
```
|
| 887 |
+
📊 Analyse de votre cohorte (semaine du 6-12 mai)
|
| 888 |
+
- 23 apprenants actifs, 8 inactifs depuis > 3 jours
|
| 889 |
+
- Jour 7 : 67% d'échec → Le critère est trop strict ou le contenu insuffisant
|
| 890 |
+
- Recommandation : Assouplir les critères du Jour 7 OU envoyer un message d'encouragement
|
| 891 |
+
- Action automatique : Nudge personnalisé envoyé aux 8 inactifs ✅
|
| 892 |
+
```
|
| 893 |
+
|
| 894 |
+
#### 10.3.4 Agent de Configuration Onboarding
|
| 895 |
+
|
| 896 |
+
**Vision :** Guider un nouvel admin (organistion) à travers toute la configuration en conversation naturelle.
|
| 897 |
+
|
| 898 |
+
**Flux conversationnel :**
|
| 899 |
+
```
|
| 900 |
+
Agent: "Bonjour ! Je vais vous aider à configurer Xamlé. Quel est votre secteur d'activité ?"
|
| 901 |
+
Admin: "Nous faisons de la formation en comptabilité"
|
| 902 |
+
Agent: "Parfait ! Je recommande le mode EDTECH. Avez-vous déjà un compte WhatsApp Business ?"
|
| 903 |
+
Admin: "Non"
|
| 904 |
+
Agent: → tool: create_whatsapp_embedded_signup_link()
|
| 905 |
+
Agent: "Cliquez sur ce lien pour créer votre compte WhatsApp Business en 5 minutes : [lien]"
|
| 906 |
+
Admin: [connecté]
|
| 907 |
+
Agent: → tool: verify_whatsapp_connection()
|
| 908 |
+
Agent: "✅ WhatsApp connecté ! Voulez-vous créer votre premier programme de formation maintenant ?"
|
| 909 |
+
Admin: "Oui, sur les bases de la comptabilité, 10 jours"
|
| 910 |
+
Agent: → tool: generate_curriculum(topic="comptabilité", days=10)
|
| 911 |
+
Agent: "J'ai créé un programme de 10 jours. Voici le plan :
|
| 912 |
+
Jour 1 : Introduction aux bilans...
|
| 913 |
+
Approuvez-vous ce plan ?"
|
| 914 |
+
```
|
| 915 |
+
|
| 916 |
+
#### 10.3.5 Agent Détection de Fraude / Anomalies
|
| 917 |
+
|
| 918 |
+
**Vision :** Surveiller automatiquement les comportements anormaux.
|
| 919 |
+
|
| 920 |
+
**Signaux surveillés :**
|
| 921 |
+
- INSCRIPTION répétée par même numéro (> 3x en 24h) → possible bot
|
| 922 |
+
- Consommation crédits anormalement haute (> 2x moyenne 7j)
|
| 923 |
+
- Messages envoyés depuis IPs non-Meta → webhook forgé
|
| 924 |
+
- Contact importé en masse avec même numéro → déduplication manquante
|
| 925 |
+
|
| 926 |
+
**Actions automatiques :**
|
| 927 |
+
- Log d'alerte avec contexte complet
|
| 928 |
+
- Notification email super admin
|
| 929 |
+
- Possible suspension temporaire de l'org si score de fraude > seuil
|
| 930 |
+
|
| 931 |
+
#### 10.3.6 Agent Génération de Knowledge Base
|
| 932 |
+
|
| 933 |
+
**Vision :** L'admin décrit son activité en langage naturel → l'agent génère une FAQ et l'indexe automatiquement.
|
| 934 |
+
|
| 935 |
+
```
|
| 936 |
+
Admin: "Je gère un restaurant à Dakar. Nous servons des plats sénégalais traditionnels.
|
| 937 |
+
Prix : Thieboudienne 3500 FCFA, Yassa 2500 FCFA, Mafé 3000 FCFA.
|
| 938 |
+
Livraison disponible dans un rayon de 5km. Horaires : 11h-22h."
|
| 939 |
+
|
| 940 |
+
Agent → generate_faq(description)
|
| 941 |
+
→ "Q: Quels sont vos horaires ? R: Nous sommes ouverts de 11h à 22h."
|
| 942 |
+
→ "Q: Faites-vous la livraison ? R: Oui, dans un rayon de 5km de notre restaurant."
|
| 943 |
+
→ "Q: Quel est le prix du Thieboudienne ? R: 3500 FCFA."
|
| 944 |
+
→ tool: index_knowledge_base(chunks=[...])
|
| 945 |
+
Agent: "✅ Base de connaissance créée avec 12 questions/réponses. Votre agent IA est prêt !"
|
| 946 |
+
```
|
| 947 |
+
|
| 948 |
+
#### 10.3.7 Agent Multimodal — Analyse Qualité Exercices
|
| 949 |
+
|
| 950 |
+
**Vision :** Analyser automatiquement la qualité des exercices et suggérer des améliorations.
|
| 951 |
+
|
| 952 |
+
**Données analysées :**
|
| 953 |
+
- Taux d'échec par exercice (> 40% → trop difficile ou critères trop stricts)
|
| 954 |
+
- Temps moyen de réponse par exercice (> 30min → exercice trop complexe)
|
| 955 |
+
- Distribution des scores (bimodale → exercice polarisant)
|
| 956 |
+
- Mots les plus fréquents dans les réponses échouées → identifier les points de blocage
|
| 957 |
+
|
| 958 |
+
**Sorties :**
|
| 959 |
+
- Rapport "Top 3 exercices à améliorer cette semaine"
|
| 960 |
+
- Suggestions de reformulation des critères
|
| 961 |
+
- Proposition de contenu remédial (leçon supplémentaire)
|
| 962 |
+
|
| 963 |
+
### 10.4 Agentique — Vision à long terme (3-12 mois)
|
| 964 |
+
|
| 965 |
+
#### 10.4.1 Agent Autonome Multi-Étapes (ReAct Pattern)
|
| 966 |
+
|
| 967 |
+
Implémentation du pattern **ReAct** (Reasoning + Acting) pour des workflows admin complexes :
|
| 968 |
+
|
| 969 |
+
```
|
| 970 |
+
Pensée : "L'utilisateur veut lancer une campagne de relance sur les clients inactifs"
|
| 971 |
+
Action : get_inactive_contacts(days=30)
|
| 972 |
+
Observation : 145 contacts inactifs
|
| 973 |
+
Pensée : "Je dois segmenter par langue pour personnaliser les messages"
|
| 974 |
+
Action : segment_by_language(contacts)
|
| 975 |
+
Observation : 89 FR, 34 WOLOF, 22 EN
|
| 976 |
+
Pensée : "Générer 3 variantes de message"
|
| 977 |
+
Action : generate_messages(segments, goal="réactivation")
|
| 978 |
+
Observation : messages générés
|
| 979 |
+
Pensée : "Valider les templates puis envoyer"
|
| 980 |
+
Action : validate_template_compliance(messages)
|
| 981 |
+
Action : schedule_broadcast(contacts, messages, sendAt="2026-05-14T10:00:00Z")
|
| 982 |
+
Résultat : "Campagne planifiée pour demain 10h. 145 messages. Coût estimé : 435 crédits."
|
| 983 |
+
```
|
| 984 |
+
|
| 985 |
+
#### 10.4.2 Personnalisation Adaptive (Apprentissage par renforcement léger)
|
| 986 |
+
|
| 987 |
+
- Analyser quels styles de feedback → meilleurs résultats (score final)
|
| 988 |
+
- A/B test automatique entre 2 variantes de prompt
|
| 989 |
+
- Converger vers le prompt gagnant sans intervention humaine
|
| 990 |
+
- **Technologie :** DSPy (Declarative Self-improving Python) ou OPRO (Optimizing Prompts by RL)
|
| 991 |
+
|
| 992 |
+
#### 10.4.3 Agent Multilingue avec Détection Automatique
|
| 993 |
+
|
| 994 |
+
- Détecter la langue de l'utilisateur sans qu'il le choisisse
|
| 995 |
+
- Whisper `detect_language` + LangDetect sur texte
|
| 996 |
+
- Basculer automatiquement (même en wolof)
|
| 997 |
+
|
| 998 |
+
#### 10.4.4 Agent de Benchmarking Inter-Organisations (anonymisé)
|
| 999 |
+
|
| 1000 |
+
- Comparer anonymement les métriques de performance entre orgs similaires
|
| 1001 |
+
- "Votre taux de complétion (42%) est en dessous de la médiane (61%) pour les orgs EDTECH du secteur Finance"
|
| 1002 |
+
- Recommandations basées sur ce qui fonctionne pour les orgs similaires les plus performantes
|
| 1003 |
+
|
| 1004 |
+
#### 10.4.5 Voice-First Admin Interface
|
| 1005 |
+
|
| 1006 |
+
- Commandes vocales complètes pour gérer l'organisation
|
| 1007 |
+
- "Envoie un message de relance à tous les clients inactifs depuis 7 jours"
|
| 1008 |
+
- Transcription + interprétation + confirmation + exécution
|
| 1009 |
+
- Accessibilité pour admins sans formation technique
|
| 1010 |
+
|
| 1011 |
+
---
|
| 1012 |
+
|
| 1013 |
+
## 11. Dernières avancées technologiques applicables
|
| 1014 |
+
|
| 1015 |
+
### 11.1 LLM & Génération
|
| 1016 |
+
|
| 1017 |
+
#### Claude 4 (Anthropic, 2026)
|
| 1018 |
+
|
| 1019 |
+
**Disponible aujourd'hui.** Models : `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5`
|
| 1020 |
+
|
| 1021 |
+
- **Prompt caching natif** (jusqu'à 90% de réduction de coût sur prompts répétitifs)
|
| 1022 |
+
- Application directe : leçons personnalisées (même leçon, différents utilisateurs)
|
| 1023 |
+
- Cache le system prompt + PLATFORM_KNOWLEDGE → économie ~$0.10/1000 appels
|
| 1024 |
+
- **Extended Thinking** (Opus 4.7) : Raisonnement profond pour évaluation complexe exercices
|
| 1025 |
+
- Application : Scoring multi-critères exercices avancés (business plan, étude de marché)
|
| 1026 |
+
- **Computer Use** : Claude peut naviguer une interface web
|
| 1027 |
+
- Application future : Agent qui vérifie le statut d'approbation Meta directement
|
| 1028 |
+
|
| 1029 |
+
**Considération :** Passer progressivement les appels critiques (feedback) de GPT-4o à Claude Sonnet 4.6 (moins cher, aussi performant sur le français).
|
| 1030 |
+
|
| 1031 |
+
#### GPT-4o-mini avec Structured Outputs
|
| 1032 |
+
|
| 1033 |
+
**Disponible maintenant.** JSON Schema strict dans les réponses.
|
| 1034 |
+
|
| 1035 |
+
```typescript
|
| 1036 |
+
const completion = await openai.chat.completions.create({
|
| 1037 |
+
model: 'gpt-4o-mini',
|
| 1038 |
+
response_format: {
|
| 1039 |
+
type: 'json_schema',
|
| 1040 |
+
json_schema: {
|
| 1041 |
+
name: 'feedback_result',
|
| 1042 |
+
schema: {
|
| 1043 |
+
type: 'object',
|
| 1044 |
+
properties: {
|
| 1045 |
+
isQualified: { type: 'boolean' },
|
| 1046 |
+
score: { type: 'integer', minimum: 0, maximum: 100 },
|
| 1047 |
+
mainFeedback: { type: 'string', maxLength: 500 },
|
| 1048 |
+
improvements: { type: 'array', items: { type: 'string' } }
|
| 1049 |
+
},
|
| 1050 |
+
required: ['isQualified', 'score', 'mainFeedback']
|
| 1051 |
+
},
|
| 1052 |
+
strict: true
|
| 1053 |
+
}
|
| 1054 |
+
}
|
| 1055 |
+
});
|
| 1056 |
+
```
|
| 1057 |
+
|
| 1058 |
+
**Impact :** Éliminer tous les `try { JSON.parse(...) } catch {}` actuels dans le codebase.
|
| 1059 |
+
|
| 1060 |
+
#### Gemini 2.0 Flash (Google, 2026)
|
| 1061 |
+
|
| 1062 |
+
**Disponible maintenant.** 2x moins cher que Flash 1.5, plus rapide.
|
| 1063 |
+
|
| 1064 |
+
- **Multimodal natif** : Image + texte + audio en une seule requête
|
| 1065 |
+
- Application : Évaluer exercices photo (contenu + mise en page + professionnalisme en 1 appel)
|
| 1066 |
+
- **2M tokens context window** : Peut ingérer un track complet entier pour personnalisation cohérente
|
| 1067 |
+
- **Grounding avec Google Search** : Réponses factuelles vérifiées en temps réel
|
| 1068 |
+
- Application : Agent IA qui répond à des questions avec des données récentes (prix marché, actualités secteur)
|
| 1069 |
+
|
| 1070 |
+
#### Whisper v3 Large + Timestamps
|
| 1071 |
+
|
| 1072 |
+
- Transcription avec horodatage mot par mot
|
| 1073 |
+
- Application : Identifier précisément où l'apprenant bute dans sa réponse orale
|
| 1074 |
+
- `language` parameter : Améliore la précision sur le wolof
|
| 1075 |
+
|
| 1076 |
+
### 11.2 RAG & Base de Connaissance
|
| 1077 |
+
|
| 1078 |
+
#### RAG Hiérarchique (HyDE + Reranking)
|
| 1079 |
+
|
| 1080 |
+
**Technologie :** Cohere Rerank API ou cross-encoder local (bge-reranker)
|
| 1081 |
+
|
| 1082 |
+
**Amélioration RAG actuelle :**
|
| 1083 |
+
```
|
| 1084 |
+
Actuel : query → embedding → cosinus → top-3 chunks
|
| 1085 |
+
Amélioré: query → HyDE (génère une réponse hypothétique) → embedding → cosinus top-20
|
| 1086 |
+
→ Reranker (cross-encoder) → top-3 pertinents
|
| 1087 |
+
```
|
| 1088 |
+
|
| 1089 |
+
**Impact :** Précision RAG +40% sur les questions ambiguës.
|
| 1090 |
+
|
| 1091 |
+
#### pgvector 0.7 (Déjà disponible sur Neon)
|
| 1092 |
+
|
| 1093 |
+
- **HNSW indexing** : Recherche 10x plus rapide sur > 100k vecteurs
|
| 1094 |
+
```sql
|
| 1095 |
+
CREATE INDEX ON "KnowledgeBaseEntry" USING hnsw (embedding vector_cosine_ops)
|
| 1096 |
+
WITH (m = 16, ef_construction = 64);
|
| 1097 |
+
```
|
| 1098 |
+
- **Quantisation des vecteurs** : Réduction 4x de la taille (int8 vs float32)
|
| 1099 |
+
|
| 1100 |
+
#### Chunking Contextuel (Anthropic Contextual Retrieval, 2025)
|
| 1101 |
+
|
| 1102 |
+
Au lieu de chunker naïvement par taille, ajouter le contexte du document à chaque chunk :
|
| 1103 |
+
|
| 1104 |
+
```
|
| 1105 |
+
Chunk brut : "Le taux de TVA est 18%."
|
| 1106 |
+
Chunk contextuel : "Document: Guide fiscal Sénégal 2026 — Chapitre: TVA
|
| 1107 |
+
Le taux de TVA est 18%."
|
| 1108 |
+
```
|
| 1109 |
+
|
| 1110 |
+
**Impact :** Réduction des "missed retrievals" de 67%.
|
| 1111 |
+
|
| 1112 |
+
### 11.3 Infrastructure & Déploiement
|
| 1113 |
+
|
| 1114 |
+
#### BullMQ 5.x — Worker Threads
|
| 1115 |
+
|
| 1116 |
+
**Disponible maintenant.** Workers dans des threads Node.js séparés.
|
| 1117 |
+
|
| 1118 |
+
```typescript
|
| 1119 |
+
new Worker('whatsapp-queue', processor, {
|
| 1120 |
+
concurrency: 50,
|
| 1121 |
+
useWorkerThreads: true, // ← Nouveau dans BullMQ 5
|
| 1122 |
+
workerThreadsOptions: { execArgv: ['--max-old-space-size=512'] }
|
| 1123 |
+
});
|
| 1124 |
+
```
|
| 1125 |
+
|
| 1126 |
+
**Impact :** +3x débit sur les jobs CPU-intensifs (embedding generation).
|
| 1127 |
+
|
| 1128 |
+
#### Neon Branching pour tests
|
| 1129 |
+
|
| 1130 |
+
- Créer une branche DB identique à la production en < 1s (copy-on-write)
|
| 1131 |
+
- Application : Tests d'intégration sur données réelles sans risque
|
| 1132 |
+
```bash
|
| 1133 |
+
neon branch create --name test-$(date +%Y%m%d)
|
| 1134 |
+
```
|
| 1135 |
+
|
| 1136 |
+
#### Cloudflare Workers pour le webhook Edge
|
| 1137 |
+
|
| 1138 |
+
- Déplacer la réception webhook Meta sur Cloudflare Workers (< 10ms au lieu de ~100ms)
|
| 1139 |
+
- Validation HMAC à l'edge, mise en queue directe Redis
|
| 1140 |
+
- Zéro cold start, 100ms global response time
|
| 1141 |
+
|
| 1142 |
+
#### OpenTelemetry (OTEL) — Tracing distribué
|
| 1143 |
+
|
| 1144 |
+
```typescript
|
| 1145 |
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
| 1146 |
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
| 1147 |
+
|
| 1148 |
+
const sdk = new NodeSDK({
|
| 1149 |
+
traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_ENDPOINT }),
|
| 1150 |
+
instrumentations: [getNodeAutoInstrumentations()]
|
| 1151 |
+
});
|
| 1152 |
+
```
|
| 1153 |
+
|
| 1154 |
+
**Impact :** Visibilité complète webhook → queue → worker → DB → IA → réponse.
|
| 1155 |
+
|
| 1156 |
+
### 11.4 UX / Frontend
|
| 1157 |
+
|
| 1158 |
+
#### Server-Sent Events (SSE) pour updates temps réel
|
| 1159 |
+
|
| 1160 |
+
Remplacer le polling manuel par SSE (sans WebSocket, plus simple avec Fastify) :
|
| 1161 |
+
|
| 1162 |
+
```typescript
|
| 1163 |
+
// API
|
| 1164 |
+
fastify.get('/v1/admin/live', async (req, reply) => {
|
| 1165 |
+
reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
| 1166 |
+
const unsubscribe = pubsub.subscribe(`org:${orgId}:events`, (event) => {
|
| 1167 |
+
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
| 1168 |
+
});
|
| 1169 |
+
req.raw.on('close', unsubscribe);
|
| 1170 |
+
});
|
| 1171 |
+
|
| 1172 |
+
// Frontend
|
| 1173 |
+
const eventSource = new EventSource('/v1/admin/live');
|
| 1174 |
+
eventSource.onmessage = (e) => updateDashboard(JSON.parse(e.data));
|
| 1175 |
+
```
|
| 1176 |
+
|
| 1177 |
+
**Cas d'usage :**
|
| 1178 |
+
- Nouveau message reçu → update CRM Inbox en temps réel
|
| 1179 |
+
- Exercice complété → update compteur dashboard
|
| 1180 |
+
- Solde wallet change → alert immédiate
|
| 1181 |
+
|
| 1182 |
+
#### React Server Components + Suspense
|
| 1183 |
+
|
| 1184 |
+
Pour les pages analytics lentes (gros volumes de données) — rendu progressif sans skeleton loaders manuels.
|
| 1185 |
+
|
| 1186 |
+
#### AI-powered Search dans le dashboard
|
| 1187 |
+
|
| 1188 |
+
Recherche en langage naturel sur les données de l'org :
|
| 1189 |
+
- "Montre-moi les utilisateurs inactifs depuis 2 semaines"
|
| 1190 |
+
- "Quels sont les 5 exercices les plus échoués ce mois ?"
|
| 1191 |
+
- "Combien de crédits ont été consommés en transcription audio ?"
|
| 1192 |
+
|
| 1193 |
+
**Technologie :** Text-to-SQL via GPT-4o-mini sur le schéma Prisma.
|
| 1194 |
+
|
| 1195 |
+
### 11.5 IA Agentique — Frameworks
|
| 1196 |
+
|
| 1197 |
+
#### Vercel AI SDK (avec tools)
|
| 1198 |
+
|
| 1199 |
+
**Recommandé pour remplacer les appels OpenAI directs.** Supporte :
|
| 1200 |
+
- Streaming natif
|
| 1201 |
+
- Tool calling avec retry automatique
|
| 1202 |
+
- Multi-provider (OpenAI, Anthropic, Google)
|
| 1203 |
+
- `generateObject` avec Zod validation
|
| 1204 |
+
|
| 1205 |
+
```typescript
|
| 1206 |
+
import { generateText } from 'ai';
|
| 1207 |
+
import { openai } from '@ai-sdk/openai';
|
| 1208 |
+
|
| 1209 |
+
const result = await generateText({
|
| 1210 |
+
model: openai('gpt-4o-mini'),
|
| 1211 |
+
tools: { changeMode, updatePersonality, getSettings },
|
| 1212 |
+
maxSteps: 5, // Boucle agentique max 5 étapes
|
| 1213 |
+
messages: [...],
|
| 1214 |
+
});
|
| 1215 |
+
```
|
| 1216 |
+
|
| 1217 |
+
#### LangGraph (pour agents multi-étapes complexes)
|
| 1218 |
+
|
| 1219 |
+
Pour les workflows agentiques avec état persistant (ex: création curriculum) :
|
| 1220 |
+
|
| 1221 |
+
```typescript
|
| 1222 |
+
const graph = new StateGraph({ channels: { messages, currentStep, orgId } })
|
| 1223 |
+
.addNode('plan', planCurriculumNode)
|
| 1224 |
+
.addNode('create', createTrackDayNode)
|
| 1225 |
+
.addNode('validate', validateCurriculumNode)
|
| 1226 |
+
.addEdge('plan', 'create')
|
| 1227 |
+
.addConditionalEdges('validate', shouldContinue);
|
| 1228 |
+
```
|
| 1229 |
+
|
| 1230 |
+
**Avantage :** Reprise sur erreur (le graph sauvegarde son état), debuggable.
|
| 1231 |
+
|
| 1232 |
+
#### DSPy pour optimisation des prompts
|
| 1233 |
+
|
| 1234 |
+
Au lieu de modifier manuellement les prompts, DSPy les optimise automatiquement :
|
| 1235 |
+
|
| 1236 |
+
```python
|
| 1237 |
+
class FeedbackModule(dspy.Module):
|
| 1238 |
+
def forward(self, response, criteria):
|
| 1239 |
+
return self.generate(response=response, criteria=criteria)
|
| 1240 |
+
|
| 1241 |
+
# Optimiser automatiquement sur 50 exemples validés
|
| 1242 |
+
optimizer = dspy.MIPROv2()
|
| 1243 |
+
optimized = optimizer.compile(FeedbackModule(), trainset=training_examples)
|
| 1244 |
+
```
|
| 1245 |
+
|
| 1246 |
+
**Impact :** Amélioration mesurable du taux de qualification exercices sans ajustement manuel.
|
| 1247 |
+
|
| 1248 |
+
### 11.6 Sécurité & Conformité
|
| 1249 |
+
|
| 1250 |
+
#### Chiffrement BYOK avec AWS KMS ou Vault
|
| 1251 |
+
|
| 1252 |
+
Les clés API des orgs doivent être chiffrées :
|
| 1253 |
+
|
| 1254 |
+
```typescript
|
| 1255 |
+
// Utiliser une clé AES-256 dérivée par org
|
| 1256 |
+
const encryptedKey = await kms.encrypt({
|
| 1257 |
+
KeyId: `alias/org-${organizationId}`,
|
| 1258 |
+
Plaintext: Buffer.from(apiKey)
|
| 1259 |
+
});
|
| 1260 |
+
await prisma.organization.update({
|
| 1261 |
+
where: { id: organizationId },
|
| 1262 |
+
data: { openAiApiKey: encryptedKey.CiphertextBlob.toString('base64') }
|
| 1263 |
+
});
|
| 1264 |
+
```
|
| 1265 |
+
|
| 1266 |
+
#### PII Redaction avant logs
|
| 1267 |
+
|
| 1268 |
+
Les numéros de téléphone et clés API ne doivent jamais apparaître dans les logs :
|
| 1269 |
+
|
| 1270 |
+
```typescript
|
| 1271 |
+
// Middleware de sanitisation
|
| 1272 |
+
const sanitize = (obj: any) => JSON.stringify(obj).replace(/\+?\d{10,15}/g, '[PHONE]');
|
| 1273 |
+
```
|
| 1274 |
+
|
| 1275 |
+
---
|
| 1276 |
+
|
| 1277 |
+
## 12. Matrice de priorisation
|
| 1278 |
+
|
| 1279 |
+
### Impact × Effort
|
| 1280 |
+
|
| 1281 |
+
| # | Fonctionnalité | Impact | Effort | Priorité |
|
| 1282 |
+
|---|---------------|--------|--------|----------|
|
| 1283 |
+
| 1 | **Mémoire conversationnelle AI_AGENT** | 🔴 Critique | 🟢 Faible | **P0 — Immédiat** |
|
| 1284 |
+
| 2 | **Structured outputs (isQualified JSON strict)** | 🔴 Critique | 🟢 Faible | **P0 — Immédiat** |
|
| 1285 |
+
| 3 | **Alertes wallet temps réel (email + push)** | 🔴 Critique | 🟢 Faible | **P0 — Immédiat** |
|
| 1286 |
+
| 4 | **Rapport hebdomadaire auto** | 🟠 Élevé | 🟢 Faible | **P1 — Cette semaine** |
|
| 1287 |
+
| 5 | **SSE temps réel dashboard** | 🟠 Élevé | 🟡 Moyen | **P1 — Cette semaine** |
|
| 1288 |
+
| 6 | **HNSW index pgvector** | 🟠 Élevé | 🟢 Faible | **P1 — Cette semaine** |
|
| 1289 |
+
| 7 | **Seuil pertinence RAG (0.75)** | 🟠 Élevé | 🟢 Faible | **P1 — Cette semaine** |
|
| 1290 |
+
| 8 | **Passage Claude Sonnet 4.6 pour feedback** | 🟠 Élevé | 🟡 Moyen | **P1 — Cette semaine** |
|
| 1291 |
+
| 9 | **Scheduling campagnes (sendAt)** | 🟠 Élevé | 🟢 Faible | **P1 — Cette semaine** |
|
| 1292 |
+
| 10 | **Soft-delete + audit trail** | 🟠 Élevé | 🟡 Moyen | **P2 — Ce mois** |
|
| 1293 |
+
| 11 | **Agent Créateur de Contenu** | 🔴 Critique | 🟡 Moyen | **P2 — Ce mois** |
|
| 1294 |
+
| 12 | **Détection handoff humain CUSTOMER_SERVICE** | 🟠 Élevé | 🟡 Moyen | **P2 — Ce mois** |
|
| 1295 |
+
| 13 | **Segmentation contacts par tags** | 🟠 Élevé | 🟡 Moyen | **P2 — Ce mois** |
|
| 1296 |
+
| 14 | **Text-to-SQL search dashboard** | 🟡 Moyen | 🟡 Moyen | **P3 — Q3 2026** |
|
| 1297 |
+
| 15 | **Agent Conseiller Pédagogique** | 🔴 Critique | 🔴 Élevé | **P3 — Q3 2026** |
|
| 1298 |
+
| 16 | **A/B testing feedback prompts (DSPy)** | 🟡 Moyen | 🔴 Élevé | **P4 — Q4 2026** |
|
| 1299 |
+
| 17 | **OpenTelemetry distributed tracing** | 🟠 Élevé | 🔴 Élevé | **P4 — Q4 2026** |
|
| 1300 |
+
| 18 | **ReAct Pattern campagnes autonomes** | 🔴 Critique | 🔴 Élevé | **P4 — Q4 2026** |
|
| 1301 |
+
| 19 | **Benchmarking inter-orgs anonymisé** | 🟡 Moyen | 🔴 Élevé | **Backlog** |
|
| 1302 |
+
| 20 | **Voice-first admin interface** | 🟡 Moyen | 🔴 Élevé | **Backlog** |
|
| 1303 |
+
|
| 1304 |
+
### Prochaines 4 semaines — Plan d'action
|
| 1305 |
+
|
| 1306 |
+
**Semaine 1 :** ✅ Complétée le 13/05/2026
|
| 1307 |
+
- [x] **Mémoire conversationnelle AI_AGENT** — Redis sliding window 20 messages (TTL 24h), historique injecté dans system prompt. → `apps/whatsapp-worker/src/handlers/AIAgentHandler.ts`
|
| 1308 |
+
- [x] **Structured outputs** — Déjà implémenté via `zodResponseFormat` + `FeedbackSchema` Zod en production dans `OpenAIProvider`.
|
| 1309 |
+
- [x] **HNSW index pgvector** — Script one-shot prêt → `packages/database/scripts/add-hnsw-index.ts`. Commande : `pnpm --filter @repo/database exec tsx scripts/add-hnsw-index.ts`
|
| 1310 |
+
- [x] **Seuil pertinence RAG (0.70)** — `searchRelevantContext()` filtre `WHERE similarity > 0.70`. Retourne `''` si vide → agent répond honnêtement. → `apps/whatsapp-worker/src/services/indexing.ts`
|
| 1311 |
+
- [x] **Alertes wallet** — Scheduler horaire + email Brevo si < 3 jours runway. Suppression 6h anti-spam Redis. → `apps/whatsapp-worker/src/scheduler.ts`
|
| 1312 |
+
- [x] **Rapport hebdomadaire** — Scheduler lundi 07:00 UTC : résumé appels IA, coût FCFA, trend vs semaine précédente, solde wallet. → `apps/whatsapp-worker/src/scheduler.ts`
|
| 1313 |
+
|
| 1314 |
+
**Semaine 2 :** (en cours)
|
| 1315 |
+
- [ ] Scheduling campagnes (`sendAt` dans BroadcastHandler)
|
| 1316 |
+
- [ ] SSE pour updates temps réel CRM Inbox + Dashboard
|
| 1317 |
+
- [ ] Segmentation contacts par tags
|
| 1318 |
+
|
| 1319 |
+
**Semaine 3 :** (à venir)
|
| 1320 |
+
- [ ] Passage Claude Sonnet 4.6 pour les appels feedback (coût / qualité)
|
| 1321 |
+
- [ ] Agent Créateur de Contenu (prototype — génère curriculum depuis description)
|
| 1322 |
+
|
| 1323 |
+
**Semaine 4 :** (à venir)
|
| 1324 |
+
- [ ] Détection handoff humain (mots-clés + sentiment négatif)
|
| 1325 |
+
- [ ] Soft-delete User/Enrollment/UserProgress
|
| 1326 |
+
|
| 1327 |
+
---
|
| 1328 |
+
|
| 1329 |
+
## Conclusion
|
| 1330 |
+
|
| 1331 |
+
Xamlé est une plateforme mature avec une architecture solide. Les opportunités de valeur les plus importantes se trouvent dans **trois axes** :
|
| 1332 |
+
|
| 1333 |
+
1. **Fiabilité IA** : Mémoire conversationnelle + structured outputs + seuil RAG → transformation de l'expérience utilisateur AI_AGENT (aujourd'hui stateless et parfois hors-sujet, demain cohérent et précis).
|
| 1334 |
+
|
| 1335 |
+
2. **Proactivité admin** : Alertes temps réel + rapports automatiques + agent conseiller → l'admin n'a plus besoin d'aller chercher l'information, elle vient à lui.
|
| 1336 |
+
|
| 1337 |
+
3. **Agent Créateur de Contenu** : Générer un curriculum complet en 5 minutes au lieu de 2 semaines → démultiplicateur de productivité massif pour les orgs EDTECH.
|
| 1338 |
+
|
| 1339 |
+
Ces trois axes combinés font de Xamlé une plateforme où **l'IA fait le travail à la place de l'admin**, pas seulement un outil que l'admin utilise.
|
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* One-time migration: adds HNSW index on KnowledgeBaseEntry.embedding
|
| 3 |
+
* for fast cosine-similarity search via pgvector.
|
| 4 |
+
*
|
| 5 |
+
* Run once against production:
|
| 6 |
+
* pnpm --filter @repo/database exec tsx scripts/add-hnsw-index.ts
|
| 7 |
+
*
|
| 8 |
+
* Safe to re-run — uses CREATE INDEX IF NOT EXISTS.
|
| 9 |
+
* CONCURRENTLY means it does not lock the table during creation.
|
| 10 |
+
*/
|
| 11 |
+
import { PrismaClient } from '@prisma/client';
|
| 12 |
+
|
| 13 |
+
const prisma = new PrismaClient();
|
| 14 |
+
|
| 15 |
+
async function run() {
|
| 16 |
+
console.log('[HNSW] Ensuring pgvector extension is enabled...');
|
| 17 |
+
await prisma.$executeRawUnsafe(`CREATE EXTENSION IF NOT EXISTS vector;`);
|
| 18 |
+
|
| 19 |
+
console.log('[HNSW] Creating HNSW index on KnowledgeBaseEntry.embedding (this may take a minute)...');
|
| 20 |
+
await prisma.$executeRawUnsafe(`
|
| 21 |
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS kb_embedding_hnsw_idx
|
| 22 |
+
ON "KnowledgeBaseEntry" USING hnsw (embedding vector_cosine_ops)
|
| 23 |
+
WITH (m = 16, ef_construction = 64);
|
| 24 |
+
`);
|
| 25 |
+
|
| 26 |
+
console.log('[HNSW] Setting ef_search for query-time accuracy/speed trade-off...');
|
| 27 |
+
await prisma.$executeRawUnsafe(`SET hnsw.ef_search = 40;`);
|
| 28 |
+
|
| 29 |
+
console.log('[HNSW] ✅ Index created. Cosine search is now ~10x faster on large knowledge bases.');
|
| 30 |
+
await prisma.$disconnect();
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
run().catch(err => {
|
| 34 |
+
console.error('[HNSW] ❌ Failed:', err);
|
| 35 |
+
process.exit(1);
|
| 36 |
+
});
|