CognxSafeTrack commited on
Commit
eac938a
·
1 Parent(s): 4d733e8

feat: Genspark-Standard upgrade, MLOps audit fixes, and XAMLÉ branding

Browse files
apps/admin/src/pages/TrainingLab.tsx CHANGED
@@ -1,6 +1,6 @@
1
- import React, { useState, useEffect } from 'react';
2
  import { useAuth } from '../App';
3
- import { Upload, Database, Save, Activity, CheckCircle, AlertTriangle, RefreshCw } from 'lucide-react';
4
 
5
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
6
 
@@ -17,7 +17,7 @@ interface TrainingData {
17
 
18
  export default function TrainingLab() {
19
  const { apiKey, logout } = useAuth();
20
- const [mode, setMode] = useState<'db' | 'upload'>('db');
21
  const [audios, setAudios] = useState<TrainingData[]>([]);
22
  const [selectedAudio, setSelectedAudio] = useState<TrainingData | null>(null);
23
  const [manualCorrection, setManualCorrection] = useState('');
@@ -25,6 +25,11 @@ export default function TrainingLab() {
25
  const [submitting, setSubmitting] = useState(false);
26
  const [result, setResult] = useState<{ rawWER: number, normalizedWER: number, missingWords: string[] } | null>(null);
27
 
 
 
 
 
 
28
  const fetchAudios = async () => {
29
  setLoading(true);
30
  try {
@@ -86,6 +91,61 @@ export default function TrainingLab() {
86
  }
87
  };
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  return (
90
  <div className="p-8 max-w-5xl mx-auto">
91
  <div className="flex items-center gap-3 mb-8">
@@ -106,11 +166,115 @@ export default function TrainingLab() {
106
  >
107
  <Upload className="w-4 h-4" /> Upload Manuel
108
  </button>
 
 
 
 
 
 
109
  </div>
110
 
111
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  {/* Left Sidebar: List */}
113
- <div className="bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm h-[600px] flex flex-col">
114
  <div className="p-4 border-b border-slate-100 bg-slate-50 flex items-center justify-between">
115
  <h2 className="font-semibold text-slate-800 flex items-center gap-2">
116
  <Database className="w-4 h-4 text-slate-400" />
@@ -143,7 +307,7 @@ export default function TrainingLab() {
143
  </div>
144
 
145
  {/* Right Area: Editor */}
146
- <div className="lg:col-span-2 space-y-6">
147
  {mode === 'upload' && !selectedAudio && (
148
  <div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-10 text-center">
149
  <Upload className="w-12 h-12 text-slate-300 mx-auto mb-4" />
 
1
+ import { useState, useEffect } from 'react';
2
  import { useAuth } from '../App';
3
+ import { Upload, Database, Save, Activity, CheckCircle, AlertTriangle, RefreshCw, Lightbulb } from 'lucide-react';
4
 
5
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
6
 
 
17
 
18
  export default function TrainingLab() {
19
  const { apiKey, logout } = useAuth();
20
+ const [mode, setMode] = useState<'db' | 'upload' | 'suggestions'>('db');
21
  const [audios, setAudios] = useState<TrainingData[]>([]);
22
  const [selectedAudio, setSelectedAudio] = useState<TrainingData | null>(null);
23
  const [manualCorrection, setManualCorrection] = useState('');
 
25
  const [submitting, setSubmitting] = useState(false);
26
  const [result, setResult] = useState<{ rawWER: number, normalizedWER: number, missingWords: string[] } | null>(null);
27
 
28
+ const [suggestions, setSuggestions] = useState<{ original: string, replacement: string, count: number }[]>([]);
29
+ const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set());
30
+ const [recalculating, setRecalculating] = useState(false);
31
+ const [recalcResult, setRecalcResult] = useState<{ processed: number, avgRawWER: number, avgNormalizedWER: number, improvementPercent: number } | null>(null);
32
+
33
  const fetchAudios = async () => {
34
  setLoading(true);
35
  try {
 
91
  }
92
  };
93
 
94
+ const fetchSuggestions = async () => {
95
+ setLoading(true);
96
+ try {
97
+ const res = await fetch(`${API_URL}/v1/admin/training/suggestions`, {
98
+ headers: { 'Authorization': `Bearer ${apiKey}` }
99
+ });
100
+ if (res.status === 401) return logout();
101
+ const data = await res.json();
102
+ setSuggestions(data);
103
+ setSelectedSuggestions(new Set(data.map((d: any) => `${d.original}->${d.replacement}`)));
104
+ } catch (err) {
105
+ console.error(err);
106
+ } finally {
107
+ setLoading(false);
108
+ }
109
+ };
110
+
111
+ const applySuggestions = async () => {
112
+ const payload = suggestions.filter(s => selectedSuggestions.has(`${s.original}->${s.replacement}`));
113
+ if (payload.length === 0) return;
114
+ setSubmitting(true);
115
+ try {
116
+ const res = await fetch(`${API_URL}/v1/admin/training/apply-suggestions`, {
117
+ method: 'POST',
118
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
119
+ body: JSON.stringify({ suggestions: payload })
120
+ });
121
+ if (res.status === 401) return logout();
122
+ const json = await res.json();
123
+ alert(`Succès! ${json.injectedCount} règles ont été injectées dans le dictionnaire.`);
124
+ fetchSuggestions();
125
+ } catch (err) {
126
+ console.error(err);
127
+ } finally {
128
+ setSubmitting(false);
129
+ }
130
+ };
131
+
132
+ const recalculateWER = async () => {
133
+ setRecalculating(true);
134
+ try {
135
+ const res = await fetch(`${API_URL}/v1/admin/training/recalculate-wer`, {
136
+ method: 'POST',
137
+ headers: { 'Authorization': `Bearer ${apiKey}` }
138
+ });
139
+ if (res.status === 401) return logout();
140
+ const json = await res.json();
141
+ setRecalcResult(json);
142
+ } catch (err) {
143
+ console.error(err);
144
+ } finally {
145
+ setRecalculating(false);
146
+ }
147
+ };
148
+
149
  return (
150
  <div className="p-8 max-w-5xl mx-auto">
151
  <div className="flex items-center gap-3 mb-8">
 
166
  >
167
  <Upload className="w-4 h-4" /> Upload Manuel
168
  </button>
169
+ <button
170
+ onClick={() => { setMode('suggestions'); fetchSuggestions(); }}
171
+ className={`flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium transition ${mode === 'suggestions' ? 'bg-slate-900 text-white shadow' : 'text-slate-600 hover:bg-slate-50'}`}
172
+ >
173
+ <Lightbulb className="w-4 h-4" /> Suggestions Auto-Normalisation
174
+ </button>
175
  </div>
176
 
177
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
178
+ {mode === 'suggestions' && (
179
+ <div className="lg:col-span-3 space-y-6">
180
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-8">
181
+ <div className="flex items-center justify-between mb-6">
182
+ <div>
183
+ <h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
184
+ <Lightbulb className="w-5 h-5 text-amber-500" />
185
+ Auto-Normalisation (Top 20)
186
+ </h2>
187
+ <p className="text-sm text-slate-500 mt-1">Ces mots ont été fréquemment corrigés manuellement. Validez-les pour les injecter dans le dictionnaire Wolof.</p>
188
+ </div>
189
+ <div className="flex gap-3">
190
+ <button
191
+ onClick={recalculateWER}
192
+ disabled={recalculating}
193
+ className="flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 font-medium rounded-lg transition disabled:opacity-50"
194
+ >
195
+ <RefreshCw className={`w-4 h-4 ${recalculating ? 'animate-spin' : ''}`} />
196
+ Recalculer WER Global
197
+ </button>
198
+ <button
199
+ onClick={applySuggestions}
200
+ disabled={submitting || selectedSuggestions.size === 0}
201
+ className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition disabled:opacity-50"
202
+ >
203
+ <Save className="w-4 h-4" />
204
+ Injecter ({selectedSuggestions.size}) Règles
205
+ </button>
206
+ </div>
207
+ </div>
208
+
209
+ {recalcResult && (
210
+ <div className="mb-8 p-4 bg-emerald-50 border border-emerald-100 rounded-xl flex items-start gap-4">
211
+ <CheckCircle className="w-6 h-6 text-emerald-500 shrink-0" />
212
+ <div>
213
+ <h3 className="font-bold text-emerald-800 mb-1">Benchmark Terminé ({recalcResult.processed} audios)</h3>
214
+ <div className="flex gap-6 mt-2">
215
+ <div>
216
+ <p className="text-xs text-emerald-600 uppercase">WER Brut Moy</p>
217
+ <p className="text-xl font-bold text-emerald-900">{Math.round(recalcResult.avgRawWER * 100)}%</p>
218
+ </div>
219
+ <div>
220
+ <p className="text-xs text-emerald-600 uppercase">WER Normalisé Moy</p>
221
+ <p className="text-xl font-bold text-emerald-900">{Math.round(recalcResult.avgNormalizedWER * 100)}%</p>
222
+ </div>
223
+ <div>
224
+ <p className="text-xs text-emerald-600 uppercase">Gain de Précision</p>
225
+ <p className="text-xl font-bold text-emerald-900">+{recalcResult.improvementPercent.toFixed(2)}%</p>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ )}
231
+
232
+ {loading ? <p className="text-slate-500 py-10 text-center flex justify-center"><RefreshCw className="animate-spin text-indigo-500" /></p> : suggestions.length === 0 ? (
233
+ <div className="text-center py-12 px-4 bg-slate-50 rounded-xl border border-dashed border-slate-200">
234
+ <CheckCircle className="w-12 h-12 text-slate-300 mx-auto mb-3" />
235
+ <p className="text-slate-500">Aucune nouvelle suggestion détectée.</p>
236
+ </div>
237
+ ) : (
238
+ <div className="overflow-x-auto">
239
+ <table className="w-full text-left text-sm text-slate-600">
240
+ <thead className="text-xs text-slate-500 uppercase bg-slate-50">
241
+ <tr>
242
+ <th className="px-6 py-3 rounded-tl-xl"><input type="checkbox" checked={selectedSuggestions.size === suggestions.length} onChange={(e) => setSelectedSuggestions(e.target.checked ? new Set(suggestions.map(s => `${s.original}->${s.replacement}`)) : new Set())} /></th>
243
+ <th className="px-6 py-3">Erreur (Whisper)</th>
244
+ <th className="px-6 py-3">Correction (Humain)</th>
245
+ <th className="px-6 py-3 rounded-tr-xl">Fréquence</th>
246
+ </tr>
247
+ </thead>
248
+ <tbody>
249
+ {suggestions.map(s => {
250
+ const key = `${s.original}->${s.replacement}`;
251
+ return (
252
+ <tr key={key} className="border-b border-slate-100 last:border-0 hover:bg-slate-50">
253
+ <td className="px-6 py-4">
254
+ <input type="checkbox" checked={selectedSuggestions.has(key)} onChange={(e) => {
255
+ const newSet = new Set(selectedSuggestions);
256
+ if (e.target.checked) newSet.add(key); else newSet.delete(key);
257
+ setSelectedSuggestions(newSet);
258
+ }} />
259
+ </td>
260
+ <td className="px-6 py-4 font-mono text-orange-600">{s.original}</td>
261
+ <td className="px-6 py-4 font-mono text-emerald-600 font-bold">{s.replacement}</td>
262
+ <td className="px-6 py-4">
263
+ <span className="bg-indigo-50 text-indigo-700 px-2.5 py-1 rounded-full text-xs font-semibold">{s.count} fois</span>
264
+ </td>
265
+ </tr>
266
+ );
267
+ })}
268
+ </tbody>
269
+ </table>
270
+ </div>
271
+ )}
272
+ </div>
273
+ </div>
274
+ )}
275
+
276
  {/* Left Sidebar: List */}
277
+ <div className={`bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm h-[600px] flex flex-col ${mode === 'suggestions' ? 'hidden' : ''}`}>
278
  <div className="p-4 border-b border-slate-100 bg-slate-50 flex items-center justify-between">
279
  <h2 className="font-semibold text-slate-800 flex items-center gap-2">
280
  <Database className="w-4 h-4 text-slate-400" />
 
307
  </div>
308
 
309
  {/* Right Area: Editor */}
310
+ <div className={`lg:col-span-2 space-y-6 ${mode === 'suggestions' ? 'hidden' : ''}`}>
311
  {mode === 'upload' && !selectedAudio && (
312
  <div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-10 text-center">
313
  <Upload className="w-12 h-12 text-slate-300 mx-auto mb-4" />
apps/api/output_log.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [AI_SERVICE] Initializing OpenAI Provider...
2
+ [OPENAI] Initializing SDK with custom fetch wrapper...
3
+ 🚀 Starting Whisper Confidence Calibration Stress-Test...
4
+ ♻️ Using cached Hugging Face samples...
5
+
6
+ 🎧 Processing 25 samples through Whisper STT...
7
+ [FLEURS 1/25] Transcribing...
8
+ [OPENAI] Transcribing audio file sample_0.wav (hint: WOLOF)...
9
+ [FLEURS 2/25] Transcribing...
10
+ [OPENAI] Transcribing audio file sample_1.wav (hint: WOLOF)...
11
+ [FLEURS 3/25] Transcribing...
12
+ [OPENAI] Transcribing audio file sample_2.wav (hint: WOLOF)...
13
+ [FLEURS 4/25] Transcribing...
14
+ [OPENAI] Transcribing audio file sample_3.wav (hint: WOLOF)...
15
+ [FLEURS 5/25] Transcribing...
16
+ [OPENAI] Transcribing audio file sample_4.wav (hint: WOLOF)...
17
+ [FLEURS 6/25] Transcribing...
18
+ [OPENAI] Transcribing audio file sample_5.wav (hint: WOLOF)...
19
+ [FLEURS 7/25] Transcribing...
20
+ [OPENAI] Transcribing audio file sample_6.wav (hint: WOLOF)...
apps/api/package.json CHANGED
@@ -16,6 +16,7 @@
16
  "@repo/shared-types": "workspace:*",
17
  "axios": "^1.13.5",
18
  "bullmq": "^5.1.0",
 
19
  "dotenv": "^16.4.7",
20
  "fast-levenshtein": "^3.0.0",
21
  "fastify": "^4.0.0",
@@ -29,6 +30,7 @@
29
  },
30
  "devDependencies": {
31
  "@repo/tsconfig": "workspace:*",
 
32
  "@types/dotenv": "^8.2.3",
33
  "@types/fast-levenshtein": "^0.0.4",
34
  "@types/node": "^20.0.0",
 
16
  "@repo/shared-types": "workspace:*",
17
  "axios": "^1.13.5",
18
  "bullmq": "^5.1.0",
19
+ "diff": "^8.0.3",
20
  "dotenv": "^16.4.7",
21
  "fast-levenshtein": "^3.0.0",
22
  "fastify": "^4.0.0",
 
30
  },
31
  "devDependencies": {
32
  "@repo/tsconfig": "workspace:*",
33
+ "@types/diff": "^8.0.0",
34
  "@types/dotenv": "^8.2.3",
35
  "@types/fast-levenshtein": "^0.0.4",
36
  "@types/node": "^20.0.0",
apps/api/src/index.ts CHANGED
@@ -113,13 +113,13 @@ server.get('/health', async () => {
113
  });
114
 
115
  // ── Privacy Policy (required by Meta for app publication) ──────────────────────
116
- server.get('/privacy', async (_req, reply) => {
117
  const html = `<!DOCTYPE html>
118
  <html lang="fr">
119
  <head>
120
  <meta charset="UTF-8" />
121
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
122
- <title>Politique de Confidentialité — SafeTrack.edu</title>
123
  <style>
124
  body { font-family: system-ui, sans-serif; max-width: 760px; margin: 40px auto; padding: 0 20px; color: #1e293b; line-height: 1.7; }
125
  h1 { font-size: 2rem; margin-bottom: 4px; }
@@ -131,10 +131,10 @@ server.get('/privacy', async (_req, reply) => {
131
  </head>
132
  <body>
133
  <h1>Politique de Confidentialité</h1>
134
- <p class="updated">Dernière mise à jour : 21 février 2026</p>
135
 
136
  <h2>1. Qui sommes-nous ?</h2>
137
- <p>SafeTrack.edu est une plateforme d'éducation entrepreneuriale accessible via WhatsApp, opérée par SafeTrack. Contact : <a href="mailto:seydi@safetrack.tech">seydi@safetrack.tech</a></p>
138
 
139
  <h2>2. Données collectées</h2>
140
  <p>Lors de votre inscription et utilisation du service, nous collectons :</p>
@@ -166,7 +166,7 @@ server.get('/privacy', async (_req, reply) => {
166
  </ul>
167
 
168
  <h2>5. Conservation des données</h2>
169
- <p>Vos données sont conservées pendant toute la durée de votre inscription active. Vous pouvez demander la suppression de vos données à tout moment en envoyant un e-mail à <a href="mailto:seydi@safetrack.tech">seydi@safetrack.tech</a>.</p>
170
 
171
  <h2>6. Vos droits</h2>
172
  <p>Conformément au RGPD et aux lois applicables, vous disposez du droit de :</p>
@@ -176,7 +176,7 @@ server.get('/privacy', async (_req, reply) => {
176
  <li>Demander la suppression de vos données</li>
177
  <li>Vous opposer au traitement de vos données</li>
178
  </ul>
179
- <p>Pour exercer ces droits, contactez-nous à : <a href="mailto:seydi@safetrack.tech">seydi@safetrack.tech</a></p>
180
 
181
  <h2>7. Sécurité</h2>
182
  <p>Vos données sont protégées par chiffrement (TLS en transit, AES au repos). L'accès aux données est strictement limité aux systèmes nécessaires au fonctionnement du service.</p>
@@ -185,7 +185,7 @@ server.get('/privacy', async (_req, reply) => {
185
  <p>Cette politique peut être mise à jour. En cas de modification majeure, vous serez informé via WhatsApp.</p>
186
 
187
  <h2>9. Contact</h2>
188
- <p>Pour toute question relative à cette politique : <a href="mailto:seydi@safetrack.tech">seydi@safetrack.tech</a></p>
189
  </body>
190
  </html>`;
191
  return reply.code(200).type('text/html').send(html);
 
113
  });
114
 
115
  // ── Privacy Policy (required by Meta for app publication) ──────────────────────
116
+ server.get('/v1/privacy', async (_req, reply) => {
117
  const html = `<!DOCTYPE html>
118
  <html lang="fr">
119
  <head>
120
  <meta charset="UTF-8" />
121
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
122
+ <title>Politique de Confidentialité — XAMLÉ Studio</title>
123
  <style>
124
  body { font-family: system-ui, sans-serif; max-width: 760px; margin: 40px auto; padding: 0 20px; color: #1e293b; line-height: 1.7; }
125
  h1 { font-size: 2rem; margin-bottom: 4px; }
 
131
  </head>
132
  <body>
133
  <h1>Politique de Confidentialité</h1>
134
+ <p class="updated">Dernière mise à jour : 7 mars 2026</p>
135
 
136
  <h2>1. Qui sommes-nous ?</h2>
137
+ <p>XAMLÉ Studio est une plateforme d'éducation entrepreneuriale accessible via WhatsApp, opérée par xamlé.studio. Contact : <a href="mailto:contact@xamle.studio">contact@xamle.studio</a></p>
138
 
139
  <h2>2. Données collectées</h2>
140
  <p>Lors de votre inscription et utilisation du service, nous collectons :</p>
 
166
  </ul>
167
 
168
  <h2>5. Conservation des données</h2>
169
+ <p>Vos données sont conservées pendant toute la durée de votre inscription active. Vous pouvez demander la suppression de vos données à tout moment en envoyant un e-mail à <a href="mailto:contact@xamle.studio">contact@xamle.studio</a>.</p>
170
 
171
  <h2>6. Vos droits</h2>
172
  <p>Conformément au RGPD et aux lois applicables, vous disposez du droit de :</p>
 
176
  <li>Demander la suppression de vos données</li>
177
  <li>Vous opposer au traitement de vos données</li>
178
  </ul>
179
+ <p>Pour exercer ces droits, contactez-nous à : <a href="mailto:contact@xamle.studio">contact@xamle.studio</a></p>
180
 
181
  <h2>7. Sécurité</h2>
182
  <p>Vos données sont protégées par chiffrement (TLS en transit, AES au repos). L'accès aux données est strictement limité aux systèmes nécessaires au fonctionnement du service.</p>
 
185
  <p>Cette politique peut être mise à jour. En cas de modification majeure, vous serez informé via WhatsApp.</p>
186
 
187
  <h2>9. Contact</h2>
188
+ <p>Pour toute question relative à cette politique : <a href="mailto:contact@xamle.studio">contact@xamle.studio</a></p>
189
  </body>
190
  </html>`;
191
  return reply.code(200).type('text/html').send(html);
apps/api/src/routes/admin.ts CHANGED
@@ -375,6 +375,142 @@ export async function adminRoutes(fastify: FastifyInstance) {
375
  return reply.send({ data, missingWords, rawWER, normalizedWER });
376
  });
377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  fastify.post('/training/upload', async (_req, reply) => {
379
  // Just a placeholder until full R2 integration for standalone uploads
380
  return reply.code(501).send({ error: "Not Implemented Yet" });
 
375
  return reply.send({ data, missingWords, rawWER, normalizedWER });
376
  });
377
 
378
+ // Suggest new dictionary rules from TrainingData
379
+ fastify.get('/training/suggestions', async (_req, reply) => {
380
+ const diff = require('diff');
381
+ const trainingData = await prisma.trainingData.findMany({
382
+ where: { status: 'REVIEWED' }
383
+ });
384
+
385
+ const substitutionCounts: Record<string, { original: string, replacement: string, count: number }> = {};
386
+
387
+ trainingData.forEach(item => {
388
+ if (!item.manualCorrection) return;
389
+ const changes = diff.diffWords(item.transcription, item.manualCorrection);
390
+
391
+ // Look for adjacent pairs of [removed] then [added]
392
+ for (let i = 0; i < changes.length - 1; i++) {
393
+ if (changes[i].removed && changes[i + 1].added) {
394
+ const original = changes[i].value.trim().toLowerCase().replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "");
395
+ const replacement = changes[i + 1].value.trim().toLowerCase().replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "");
396
+
397
+ if (original && replacement && original !== replacement && original.split(' ').length === 1 && replacement.split(' ').length === 1) {
398
+ const key = `${original}->${replacement}`;
399
+ if (!substitutionCounts[key]) substitutionCounts[key] = { original, replacement, count: 0 };
400
+ substitutionCounts[key].count++;
401
+ }
402
+ }
403
+ }
404
+ });
405
+
406
+ const suggestions = Object.values(substitutionCounts)
407
+ .sort((a, b) => b.count - a.count)
408
+ .slice(0, 20);
409
+
410
+ return reply.send(suggestions);
411
+ });
412
+
413
+ // Apply suggestions directly into normalizeWolof.ts files
414
+ fastify.post('/training/apply-suggestions', async (req, reply) => {
415
+ const schema = z.object({
416
+ suggestions: z.array(z.object({
417
+ original: z.string(),
418
+ replacement: z.string()
419
+ }))
420
+ });
421
+ const body = schema.safeParse(req.body);
422
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
423
+
424
+ if (body.data.suggestions.length === 0) return reply.send({ ok: true, message: "No suggestions provided" });
425
+
426
+ const fs = require('fs');
427
+ const path = require('path');
428
+
429
+ const targetFiles = [
430
+ path.join(__dirname, '../scripts/normalizeWolof.ts'),
431
+ path.join(__dirname, '../../../whatsapp-worker/src/normalizeWolof.ts')
432
+ ];
433
+
434
+ let rulesToInject = "";
435
+ body.data.suggestions.forEach(s => {
436
+ rulesToInject += ` "${s.original}": "${s.replacement}",\n`;
437
+ });
438
+
439
+ for (const file of targetFiles) {
440
+ if (fs.existsSync(file)) {
441
+ let content = fs.readFileSync(file, 'utf8');
442
+ const insertPos = content.indexOf('const NORMALIZATION_RULES: Record<string, string> = {\n');
443
+ if (insertPos !== -1) {
444
+ const offset = insertPos + 'const NORMALIZATION_RULES: Record<string, string> = {\n'.length;
445
+ content = content.slice(0, offset) + rulesToInject + content.slice(offset);
446
+ fs.writeFileSync(file, content, 'utf8');
447
+ }
448
+ }
449
+ }
450
+
451
+ // Auto-recalculate WER after injection
452
+ return reply.send({ ok: true, injectedCount: body.data.suggestions.length });
453
+ });
454
+
455
+ // Recalculate WER across all reviewed TrainingData with the current dictionary
456
+ fastify.post('/training/recalculate-wer', async (_req, reply) => {
457
+ const trainingData = await prisma.trainingData.findMany({
458
+ where: { status: 'REVIEWED' }
459
+ });
460
+
461
+ const calculateWER = (reference: string, hypothesis: string): number => {
462
+ const levenshtein = require('fast-levenshtein');
463
+ const refWords = reference.toLowerCase().replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "").split(/\s+/).filter(w => w);
464
+ const hypWords = hypothesis.toLowerCase().replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "").split(/\s+/).filter(w => w);
465
+ if (refWords.length === 0) return 0;
466
+ const wordMap = new Map<string, string>();
467
+ let charCode = 0xE000;
468
+ const getChar = (word: string) => {
469
+ if (!wordMap.has(word)) wordMap.set(word, String.fromCharCode(charCode++));
470
+ return wordMap.get(word)!;
471
+ };
472
+ const refChars = refWords.map(getChar).join('');
473
+ const hypChars = hypWords.map(getChar).join('');
474
+ return levenshtein.get(refChars, hypChars) / refWords.length;
475
+ };
476
+
477
+ // We need to bust the require cache to load the newly written normalizeWolof.ts
478
+ const normalizeWolofPath = require.resolve('../scripts/normalizeWolof');
479
+ delete require.cache[normalizeWolofPath];
480
+ const { normalizeWolof } = require('../scripts/normalizeWolof');
481
+
482
+ let totalRawWER = 0;
483
+ let totalNormalizedWER = 0;
484
+ let count = 0;
485
+
486
+ for (const item of trainingData) {
487
+ if (!item.manualCorrection) continue;
488
+
489
+ const rawWER = calculateWER(item.manualCorrection, item.transcription);
490
+ const normResult = normalizeWolof(item.transcription);
491
+ const normalizedWER = calculateWER(item.manualCorrection, normResult.normalizedText);
492
+
493
+ await prisma.trainingData.update({
494
+ where: { id: item.id },
495
+ data: { rawWER, normalizedWER }
496
+ });
497
+
498
+ totalRawWER += rawWER;
499
+ totalNormalizedWER += normalizedWER;
500
+ count++;
501
+ }
502
+
503
+ const avgRaw = count > 0 ? totalRawWER / count : 0;
504
+ const avgNorm = count > 0 ? totalNormalizedWER / count : 0;
505
+
506
+ return reply.send({
507
+ processed: count,
508
+ avgRawWER: avgRaw,
509
+ avgNormalizedWER: avgNorm,
510
+ improvementPercent: count > 0 && avgRaw > 0 ? ((avgRaw - avgNorm) / avgRaw) * 100 : 0
511
+ });
512
+ });
513
+
514
  fastify.post('/training/upload', async (_req, reply) => {
515
  // Just a placeholder until full R2 integration for standalone uploads
516
  return reply.code(501).send({ error: "Not Implemented Yet" });
apps/api/src/routes/ai.ts CHANGED
@@ -15,14 +15,28 @@ export async function aiRoutes(fastify: FastifyInstance) {
15
  fastify.post('/onepager', async (request) => {
16
  const bodySchema = z.object({
17
  userContext: z.string(),
18
- language: z.string().optional().default('FR')
 
19
  });
20
- const { userContext, language } = bodySchema.parse(request.body);
21
 
22
  console.log(`Generating One-Pager (${language}) for context:`, userContext.substring(0, 50));
23
 
24
  // Step 1: LLM generates structured JSON
25
- const onePagerData = await aiService.generateOnePagerData(userContext, language);
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  // Step 2: Renderer creates PDF Buffer
28
  const pdfBuffer = await pdfRenderer.render(onePagerData);
@@ -37,14 +51,30 @@ export async function aiRoutes(fastify: FastifyInstance) {
37
  fastify.post('/deck', async (request) => {
38
  const bodySchema = z.object({
39
  userContext: z.string(),
40
- language: z.string().optional().default('FR')
 
41
  });
42
- const { userContext, language } = bodySchema.parse(request.body);
43
 
44
  console.log(`Generating Pitch Deck (${language}) for context:`, userContext.substring(0, 50));
45
 
46
  // Step 1: LLM generates structured JSON (slides)
47
- const deckData = await aiService.generatePitchDeckData(userContext, language);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  // Step 2: Renderer creates PPTX Buffer
50
  const pptxBuffer = await pptxRenderer.render(deckData);
@@ -207,7 +237,8 @@ export async function aiRoutes(fastify: FastifyInstance) {
207
  isQualified: feedback.isQualified,
208
  missingElements: feedback.missingElements || [],
209
  confidence: feedback.confidence,
210
- notes: feedback.notes
 
211
  };
212
  } catch (err: any) {
213
  if (err?.name === 'QuotaExceededError') {
 
15
  fastify.post('/onepager', async (request) => {
16
  const bodySchema = z.object({
17
  userContext: z.string(),
18
+ language: z.string().optional().default('FR'),
19
+ businessProfile: z.any().optional()
20
  });
21
+ const { userContext, language, businessProfile } = bodySchema.parse(request.body);
22
 
23
  console.log(`Generating One-Pager (${language}) for context:`, userContext.substring(0, 50));
24
 
25
  // Step 1: LLM generates structured JSON
26
+ const onePagerData = await aiService.generateOnePagerData(userContext, language, businessProfile);
27
+
28
+ // Step 1.5: Generate Brand Image if needed
29
+ if (onePagerData.mainImage && !onePagerData.mainImage.startsWith('http')) {
30
+ console.log(`[AI_ROUTE] Generating brand image for One-Pager: ${onePagerData.title}`);
31
+ try {
32
+ const imageUrl = await (aiService as any).provider.generateImage(onePagerData.mainImage);
33
+ if (imageUrl) {
34
+ onePagerData.mainImage = imageUrl;
35
+ }
36
+ } catch (imgErr) {
37
+ console.error(`[AI_ROUTE] Image generation failed for One-Pager:`, imgErr);
38
+ }
39
+ }
40
 
41
  // Step 2: Renderer creates PDF Buffer
42
  const pdfBuffer = await pdfRenderer.render(onePagerData);
 
51
  fastify.post('/deck', async (request) => {
52
  const bodySchema = z.object({
53
  userContext: z.string(),
54
+ language: z.string().optional().default('FR'),
55
+ businessProfile: z.any().optional()
56
  });
57
+ const { userContext, language, businessProfile } = bodySchema.parse(request.body);
58
 
59
  console.log(`Generating Pitch Deck (${language}) for context:`, userContext.substring(0, 50));
60
 
61
  // Step 1: LLM generates structured JSON (slides)
62
+ const deckData = await aiService.generatePitchDeckData(userContext, language, businessProfile);
63
+
64
+ // Step 1.5: Generate AI Images for specific slides if requested
65
+ for (const slide of deckData.slides) {
66
+ if (slide.visualType === 'IMAGE' && slide.visualData && typeof slide.visualData === 'string' && !slide.visualData.startsWith('http')) {
67
+ console.log(`[AI_ROUTE] Generating image for slide: ${slide.title}`);
68
+ try {
69
+ const imageUrl = await (aiService as any).provider.generateImage(slide.visualData);
70
+ if (imageUrl) {
71
+ slide.visualData = imageUrl;
72
+ }
73
+ } catch (imgErr) {
74
+ console.error(`[AI_ROUTE] Image generation failed for slide ${slide.title}:`, imgErr);
75
+ }
76
+ }
77
+ }
78
 
79
  // Step 2: Renderer creates PPTX Buffer
80
  const pptxBuffer = await pptxRenderer.render(deckData);
 
237
  isQualified: feedback.isQualified,
238
  missingElements: feedback.missingElements || [],
239
  confidence: feedback.confidence,
240
+ notes: feedback.notes,
241
+ searchResults: feedback.searchResults
242
  };
243
  } catch (err: any) {
244
  if (err?.name === 'QuotaExceededError') {
apps/api/src/scripts/audit-inventory.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PrismaClient } from '@prisma/client';
2
+
3
+ const prisma = new PrismaClient();
4
+
5
+ async function runAudit() {
6
+ console.log("🔍 Démarrage de l'audit d'inventaire XAMLÉ V1...");
7
+ console.log("=================================================");
8
+
9
+ const tracks = await prisma.track.findMany({
10
+ include: {
11
+ days: {
12
+ orderBy: { dayNumber: 'asc' }
13
+ }
14
+ }
15
+ });
16
+
17
+ const report: {
18
+ secteur: string;
19
+ lang: string;
20
+ day: number;
21
+ errors: string[]
22
+ }[] = [];
23
+
24
+ // Group tracks by "Secteur" intuitively from title or id (e.g. T1-FR -> T1 is not a sector maybe?)
25
+ // Actually the user mentioned "Couture", "Restauration", so these might be in track titles!
26
+
27
+ for (const track of tracks) {
28
+ // Find language
29
+ const lang = track.language || (track.id.endsWith('-WO') ? 'WOLOF' : 'FR');
30
+
31
+ for (const day of track.days) {
32
+ const errors: string[] = [];
33
+
34
+ // 1. Audio check
35
+ if (lang === 'WOLOF' && !day.audioUrl) {
36
+ errors.push("🎵 Audio manquant (Obligatoire en Wolof)");
37
+ }
38
+
39
+ // 2. Visual check
40
+ if (!day.imageUrl && !day.videoUrl) {
41
+ errors.push("🖼️ Visuel manquant (Image ou Vidéo absente)");
42
+ }
43
+
44
+ // 3. Multilingual JSON Validation
45
+ // Let's check buttonsJson or criteria to see if it's explicitly malformed
46
+ if (day.buttonsJson) {
47
+ try {
48
+ // const parsed = typeof day.buttonsJson === 'string' ? JSON.parse(day.buttonsJson) : day.buttonsJson;
49
+ // If parsed is not fine, it will throw
50
+ } catch (e) {
51
+ errors.push("⚠️ JSON Multilingue Invalide (buttonsJson corrompu)");
52
+ }
53
+ }
54
+
55
+ // 4. Pitch Deck Validation on Day 12
56
+ if (day.dayNumber === 12) {
57
+ let hasPitchTrigger = false;
58
+ // Check if badges include PITCH_DECK or exerciseType is FINAL, or buttonsJson has specific trigger
59
+ if (day.badges && JSON.stringify(day.badges).includes("PITCH_DECK")) hasPitchTrigger = true;
60
+ if (day.badges && JSON.stringify(day.badges).includes("PITCH_AI")) hasPitchTrigger = true;
61
+ if (day.badges && JSON.stringify(day.badges).includes("DOCUMENT")) hasPitchTrigger = true;
62
+ if (JSON.stringify(day.buttonsJson || "").includes("DOCUMENT_GENERATION")) hasPitchTrigger = true;
63
+
64
+ if (!hasPitchTrigger) {
65
+ // Let's record metadata found to help debug
66
+ errors.push(`📝 Métadonnées Pitch Deck (fin de parcours) absentes (Badges: ${JSON.stringify(day.badges)})`);
67
+ }
68
+ }
69
+
70
+ if (errors.length > 0) {
71
+ report.push({
72
+ secteur: track.title,
73
+ lang,
74
+ day: day.dayNumber,
75
+ errors
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ console.log(`\n📋 RÉSULTAT DU SCAN : ${report.length} problèmes trouvés.\n`);
82
+
83
+ // Print Table format
84
+ for (const item of report) {
85
+ console.log(`[${item.lang}] ${item.secteur} - Jour ${item.day} :`);
86
+ item.errors.forEach(err => console.log(` ❌ ${err}`));
87
+ console.log("-".repeat(40));
88
+ }
89
+
90
+ if (report.length === 0) {
91
+ console.log("✅ Audit parfait ! Tous les parcours sont complets et prêts pour la V1.");
92
+ }
93
+
94
+ await prisma.$disconnect();
95
+ }
96
+
97
+ runAudit().catch(e => {
98
+ console.error(e);
99
+ process.exit(1);
100
+ });
apps/api/src/scripts/elite-journey-simulation.ts ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PrismaClient } from '@prisma/client';
2
+ import { aiService } from '../services/ai';
3
+ import { PptxDeckRenderer } from '../services/renderers/pptx-renderer';
4
+ import { PdfOnePagerRenderer } from '../services/renderers/pdf-renderer';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as dotenv from 'dotenv';
8
+
9
+ dotenv.config({ path: path.join(__dirname, '../../../../.env') });
10
+
11
+ const prisma = new PrismaClient();
12
+ const KAOLACK_PHONE = '221770000003';
13
+
14
+ async function runSimulation() {
15
+ console.log("🚀 Lancement du Stress Test MLOps : Grains de Kaolack");
16
+ console.log("======================================================");
17
+
18
+ // 1. Cleanup
19
+ console.log(`[1/5] Nettoyage des anciennes données pour ${KAOLACK_PHONE}...`);
20
+ await prisma.userProgress.deleteMany({ where: { user: { phone: KAOLACK_PHONE } } });
21
+ await prisma.response.deleteMany({ where: { user: { phone: KAOLACK_PHONE } } });
22
+ await prisma.enrollment.deleteMany({ where: { user: { phone: KAOLACK_PHONE } } });
23
+ await prisma.message.deleteMany({ where: { user: { phone: KAOLACK_PHONE } } });
24
+ await prisma.businessProfile.deleteMany({ where: { user: { phone: KAOLACK_PHONE } } });
25
+ await prisma.user.deleteMany({ where: { phone: KAOLACK_PHONE } });
26
+
27
+ // 2. User Creation (Grains de Kaolack - Transformation de céréales)
28
+ console.log(`[2/5] Création de l'utilisateur 'Grains de Kaolack' (Céréales, Kaolack, FR)...`);
29
+
30
+ const track = await prisma.track.findFirst({ where: { language: 'FR', title: { contains: "Comprendre" } } });
31
+ if (!track) throw new Error("Track T1-FR non trouvé.");
32
+
33
+ const user = await (prisma.user.create({
34
+ data: {
35
+ phone: KAOLACK_PHONE,
36
+ name: 'Grains de Kaolack',
37
+ language: 'FR',
38
+ activity: 'Transformation de céréales locales (Mil, Maïs)',
39
+ city: 'Kaolack',
40
+ businessProfile: {
41
+ create: {
42
+ activityLabel: 'Grains de Kaolack - Transformation Céréalière',
43
+ locationCity: 'Kaolack',
44
+ mainCustomer: 'Ménages urbains et boutiques de proximité',
45
+ mainProblem: 'Temps de préparation trop long et faible qualité des produits artisanaux',
46
+ promise: 'La saveur du terroir, la rapidité du moderne',
47
+ offerSimple: 'Mil pré-paré, Arraw et Thiakry haut de gamme',
48
+ marketData: {
49
+ source: "ANSD / Ministère de l'Agriculture 2023",
50
+ population_kaolack: "300,000",
51
+ market_opportunity: "Forte demande de substitution aux importations de riz",
52
+ benchmarking_uemoa: "Le marché UEMOA pour les céréales sèches transformées est estimé à plus de 200 Mds FCFA."
53
+ } as any
54
+ }
55
+ }
56
+ } as any,
57
+ include: { businessProfile: true } as any
58
+ }) as any);
59
+
60
+ // 3. Simulation of Enriched Responses (J1-J12)
61
+ console.log(`[3/5] Simulation des réponses denses (J1-J12)...`);
62
+ const enrollment = await prisma.enrollment.create({
63
+ data: {
64
+ userId: user.id,
65
+ trackId: track.id,
66
+ currentDay: 12,
67
+ status: 'COMPLETED'
68
+ }
69
+ });
70
+
71
+ const mockResponses = [
72
+ "Mon entreprise, Grains de Kaolack, transforme les céréales locales du bassin arachidier en produits nutritionnels prêts à l'emploi pour les familles.", // J1
73
+ "Nous ciblons principalement les mères de famille actives à Kaolack et Dakar qui cherchent à gagner du temps sans sacrifier la santé de leurs enfants.", // J2
74
+ "Le problème critique est la corvée de préparation manuelle du mil qui pousse les gens vers le riz importé, moins nutritif mais plus facile à cuisiner.", // J3
75
+ "Ma solution est une gamme de produits pré-cuits à la vapeur, conditionnés de manière hygiénique, gardant tout le goût authentique du mil frais.", // J4
76
+ "Nous nous différencions par une certification qualité stricte et une rapidité de cuisson imbattable (5 minutes contre 45 minutes pour l'artisanal).", // J5
77
+ "Nos sachets de 500g sont vendus à 600 FCFA, un prix accessible qui nous permet de dégager une marge de 25% grâce à l'achat direct aux producteurs.", // J6
78
+ "Nous distribuons via un réseau de boutiquiers partenaires à Kaolack et des points de vente stratégiques dans les gares routières vers Dakar.", // J7
79
+ "Notre force réside dans la fraîcheur de nos grains, récoltés localement, et l'absence totale de sable ou d'impuretés dans nos produits finis.", // J8
80
+ "XAMLÉ m'a appris à valoriser mon héritage culturel en le transformant en un business moderne capable de nourrir la nation durablement.", // J9
81
+ "Nos concurrents sont les produits importés et le mil en vrac du marché. Nous gagnons par la praticité et l'assurance d'une propreté parfaite.", // J10
82
+ "L'équipe comprend un technicien agro-alimentaire et 5 femmes expertes en transformation. Nous projetons un CA de 12 millions FCFA dès la première année.", // J11
83
+ "Je sollicite un financement de 8 millions FCFA pour automatiser mon emballage et acheter un moulin industriel plus performant à Kaolack.", // J12
84
+ ];
85
+
86
+ for (let i = 0; i < 12; i++) {
87
+ await prisma.response.create({
88
+ data: {
89
+ userId: user.id,
90
+ enrollmentId: enrollment.id,
91
+ dayNumber: i + 1,
92
+ content: mockResponses[i]
93
+ }
94
+ });
95
+ }
96
+
97
+ // Update UserProgress
98
+ await prisma.userProgress.create({
99
+ data: {
100
+ userId: user.id,
101
+ trackId: track.id,
102
+ exerciseStatus: 'COMPLETED',
103
+ marketData: user.businessProfile?.marketData,
104
+ competitorList: ["Importations riz/blé", "Vendeuses de marché informelles", "Industries agro-alimentaires"],
105
+ financialProjections: {
106
+ revenueY1: "12 000 000 FCFA",
107
+ revenueY3: "35 000 000 FCFA",
108
+ growthRate: "40% annuel"
109
+ },
110
+ fundingAsk: "8 000 000 FCFA pour automatisation et broyage industriel."
111
+ } as any
112
+ });
113
+
114
+ // 4. Document Generation
115
+ console.log(`[4/5] Déclenchement de la génération V4 (Audit Secteur Cereales)...`);
116
+ const userContext = `AUDIT : Transformation de Céréales à Kaolack.
117
+ Cet entrepreneur 'Grains de Kaolack' doit prouver que le système est agile.
118
+ Voici ses réponses stratégiques :
119
+ ${mockResponses.map((r, i) => `J${i + 1}: ${r}`).join('\n')}
120
+ Données Marché (ANSD 2023) : Forte opportunité de substitution riz/mil.`;
121
+
122
+ const deckData = await aiService.generatePitchDeckData(userContext, 'FR', user.businessProfile);
123
+ const pptxRenderer = new PptxDeckRenderer();
124
+ const pptxBuffer = await pptxRenderer.render(deckData);
125
+
126
+ const pdfData = await aiService.generateOnePagerData(userContext, 'FR', user.businessProfile);
127
+ const pdfRenderer = new PdfOnePagerRenderer();
128
+ const pdfBuffer = await pdfRenderer.render(pdfData);
129
+
130
+ // 5. Save locally
131
+ const docsDir = '/Volumes/sms/edtech/docs';
132
+ if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true });
133
+
134
+ const pptxPath = path.join(docsDir, `Pitch_Deck_Kaolack_${Date.now()}.pptx`);
135
+ const pdfPath = path.join(docsDir, `One_Pager_Kaolack_${Date.now()}.pdf`);
136
+
137
+ fs.writeFileSync(pptxPath, pptxBuffer);
138
+ fs.writeFileSync(pdfPath, pdfBuffer);
139
+
140
+ console.log(`\n======================================================`);
141
+ console.log(`✅ [AUDIT RÉUSSI] STRESS TEST TERMINÉ !`);
142
+ console.log(`📊 PITCH DECK (PPTX) : ${pptxPath}`);
143
+ console.log(`📄 ONE PAGER (PDF) : ${pdfPath}`);
144
+ console.log(`======================================================\n`);
145
+
146
+ await prisma.$disconnect();
147
+ }
148
+
149
+ runSimulation().catch(e => {
150
+ console.error(e);
151
+ process.exit(1);
152
+ });
apps/api/src/services/ai/index.ts CHANGED
@@ -1,7 +1,8 @@
1
  import { z } from 'zod';
2
- import { LLMProvider, OnePagerData, OnePagerSchema, PitchDeckData, PitchDeckSchema, PersonalizedLessonSchema } from './types';
3
  import { MockLLMProvider } from './mock-provider';
4
  import { OpenAIProvider } from './openai-provider';
 
5
 
6
  class AIService {
7
  private provider: LLMProvider;
@@ -21,14 +22,27 @@ class AIService {
21
  /**
22
  * Extracts a One-Pager JSON structure from raw user data.
23
  */
24
- async generateOnePagerData(userContext: string, language: string = 'FR'): Promise<OnePagerData> {
 
 
 
 
25
  const prompt = `
26
- Based on the following user input outlining their business idea, generate a concise one-pager summary.
 
27
 
28
  USER INPUT:
29
  ${userContext}
30
-
31
- LANGUAGE: Write EVERYTHING in ${language === 'WOLOF' ? 'WOLOF' : 'French'}. NO ENGLISH.
 
 
 
 
 
 
 
 
32
  `;
33
 
34
  return this.provider.generateStructuredData(prompt, OnePagerSchema);
@@ -37,15 +51,49 @@ class AIService {
37
  /**
38
  * Extracts a Slide Deck JSON structure from raw user data.
39
  */
40
- async generatePitchDeckData(userContext: string, language: string = 'FR'): Promise<PitchDeckData> {
 
 
 
 
41
  const prompt = `
42
- Based on the following user input outlining their business idea, generate a structured pitch deck presentation.
43
- Keep bullets under 5 words if possible, and STRICTLY maximum 5 bullets per slide.
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  USER INPUT:
46
  ${userContext}
47
-
48
- LANGUAGE: Write EVERYTHING in ${language === 'WOLOF' ? 'WOLOF' : 'French'}. NO ENGLISH.
 
 
 
 
 
 
 
 
 
 
 
 
49
  `;
50
 
51
  return this.provider.generateStructuredData(prompt, PitchDeckSchema);
@@ -66,15 +114,7 @@ class AIService {
66
  userRegion?: string,
67
  dayNumber?: number,
68
  previousResponses?: Array<{ day: number; response: string }>
69
- ): Promise<{
70
- rephrase: string,
71
- praise: string,
72
- action: string,
73
- isQualified: boolean,
74
- missingElements: string[],
75
- confidence: number,
76
- notes: string
77
- }> {
78
  const activityLabel = userActivity || businessProfile?.activityLabel || 'non précisé';
79
  const region = userRegion || businessProfile?.region || 'Sénégal';
80
  const customer = businessProfile?.mainCustomer || '';
@@ -96,63 +136,69 @@ class AIService {
96
  ? `\n📚 RÉPONSES PRÉCÉDENTES (pour cohérence et contexte) :\n${previousResponses.map(r => ` J${r.day}: "${r.response.substring(0, 150)}"`).join('\n')}\n`
97
  : '';
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  const criteriaContext = exerciseCriteria
100
  ? `CRITÈRES D'ÉVALUATION :\n${JSON.stringify(exerciseCriteria, null, 2)}`
101
  : 'CRITÈRES : Réponse concrète, personnelle, et spécifique à son business réel.';
102
 
103
  const prompt = `
104
- Tu es XAMLÉ COACH coach business expert pour entrepreneurs d'Afrique de l'Ouest.
105
- Style : exigeant, bienveillant, précis, jamais générique. CHAQUE PHRASE doit être utile.
106
-
107
- ${businessContext}
108
- ${prevContext}
109
- JOUR DE FORMATION : ${dayNumber || '?'}
110
- LEÇON DU JOUR : "${lessonContent.substring(0, 250)}"
111
- EXERCICE POSÉ : "${expectedExercise}"
112
- RÉPONSE DE L'ÉTUDIANT : "${userInput}"
113
-
114
- ${criteriaContext}
115
-
116
- ��══ MISSION DU COACH EXPERT ═══
117
-
118
- 1. RÈGLE D'OR : ANTI-HALLUCINATION
119
- Agnosticisme Sectoriel : Tu ne dois JAMAIS utiliser d'exemples liés aux forages, à l'irrigation, aux agriculteurs ou aux fourneaux, SAUF si l'activité déclarée de l'étudiant (${activityLabel}) y est explicitement liée.
120
- Zéro-Shot Contextuel : Adapte tous tes exemples UNIQUEMENT au métier réel de l'étudiant (${activityLabel}). Interdiction formelle d'inventer un secteur d'activité.
121
- Règle de Sécurité : Si l'utilisateur n'a pas défini son projet ou si l'activité est inconnue, utilise le terme générique "ton business" et reste sur des principes théoriques abstraits.
122
- Interdiction de généralisation paresseuse : Reste concis, précis et ancré dans la réalité de l'utilisateur.
123
-
124
- 2. LOGIQUE D'ÉVALUATION
125
- Pour chaque feedback, base-toi strictement sur les CRITÈRES D'ÉVALUATION fournis :
126
- - Vérifie la présence des concepts clés (ex: offre, client, zone).
127
- - Utilise le guide d'évaluation pour juger si la réponse est 'vague' ou 'concrète'.
128
 
129
- PRAISE : Validation enthousiaste citant un élément RÉEL et SPÉCIFIQUE de la réponse.
130
- REPHRASE : Reformulation professionnelle en 20 mots max en utilisant le vocabulaire du secteur de l'étudiant.
131
- ACTION : Si isQualified=true, un défi actionnable immédiat. Si isQualified=false, guide précisément sur CE QUI MANQUE.
132
 
133
- 3. NORMALISATION WOLOF & TONE
134
- STT Cleanup : Applique les règles de normalisation Wolof (ex: 'damae' -> 'damay', 'jendi' -> 'jënd') avant de générer ton feedback.
135
- Utilisation du Glossaire Officiel : Utilise exclusivement les termes validés (ex: Ñàkk pour perte, Xaalis pour argent, Denc pour épargne).
136
 
137
- CONTRAINTES DE FORMAT :
138
- - Maximum 280 caractères par message.
139
- - Maximum 2 messages dans ta réponse.
140
- - LANGUE : ${userLanguage === 'WOLOF' ? 'Wolof en priorité, suivi de la traduction française précédée de (FR)' : 'Français professionnel'}.
141
- - ZÉRO ANGLAIS. ZÉRO HALLUCINATION EN DEHORS DU SECTEUR ($activityLabel). Ne cite jamais "Manga Deaf".
142
- - FCFA pour tous les montants.
143
- `;
144
 
145
- const schema = z.object({
146
- rephrase: z.string().describe("Reformulation professionnelle (max 20 mots, vocabulaire du secteur de l'étudiant)"),
147
- praise: z.string().describe("Validation enthousiaste citant un élément RÉEL et SPÉCIFIQUE de la réponse"),
148
- action: z.string().describe("Conseil concret et immédiat adapté au secteur + région JAMAIS générique"),
149
- isQualified: z.boolean().describe("Vrai si réponse suffisamment concrète et personnalisée"),
150
- missingElements: z.array(z.string()).describe("Liste précise de ce qui manque dans la réponse"),
151
- confidence: z.number().describe("Niveau de confiance (0 à 1)"),
152
- notes: z.string().describe("Analyse interne du coaching")
153
- });
 
 
 
 
 
 
 
 
 
 
154
 
155
- return this.provider.generateStructuredData(prompt, schema);
 
 
 
 
156
  }
157
 
158
  /**
@@ -238,7 +284,7 @@ Wolof v4.0 si WOLOF : ñ (Waññi, Ñàkk), ë (Jënd), é (Liggéey). FCFA pour
238
  - Jour 2 : Cherche le type d'activité (Vente / Service / Production).
239
  - Jour 3 : Cherche le client principal (ex: "Étudiants", "Voisins").
240
  - Jour 4 : Cherche le problème principal qu'il résout.
241
- - Jour 7 : Cherche l'offre simple (ce qu'il vend exactement).
242
  - Jour 8 : Cherche la promesse (rapide, moins cher, etc.).
243
 
244
  EXTRACTION :
 
1
  import { z } from 'zod';
2
+ import { LLMProvider, OnePagerData, OnePagerSchema, PitchDeckData, PitchDeckSchema, PersonalizedLessonSchema, FeedbackSchema, FeedbackData } from './types';
3
  import { MockLLMProvider } from './mock-provider';
4
  import { OpenAIProvider } from './openai-provider';
5
+ import { searchService } from './search';
6
 
7
  class AIService {
8
  private provider: LLMProvider;
 
22
  /**
23
  * Extracts a One-Pager JSON structure from raw user data.
24
  */
25
+ async generateOnePagerData(userContext: string, language: string = 'FR', businessProfile?: any): Promise<OnePagerData> {
26
+ const marketDataInjected = businessProfile?.marketData
27
+ ? `\n🌐 DONNÉES DE MARCHÉ RÉELLES (Google Search) :\n${JSON.stringify(businessProfile.marketData, null, 2)}\n`
28
+ : '';
29
+
30
  const prompt = `
31
+ Basé sur l'activité de l'étudiant (${businessProfile?.activityLabel || 'non précisé'}) et son contexte, génère un One-Pager (Business Plan d'une page).
32
+ Utilise les données de marché ci-dessous pour rendre le document extrêmement professionnel et crédible.
33
 
34
  USER INPUT:
35
  ${userContext}
36
+ ${marketDataInjected}
37
+
38
+ STRICTES CONTRAINTES DE QUALITÉ "PREMIUM V4" :
39
+ - DENSITÉ RÉDACTIONNELLE : Chaque section (Problem, Solution, Target, Business Model) doit être un paragraphe détaillé, articulé et stratégique (minimum 3 phrases analytiques, 15-25 mots). Les réponses courtes de l'utilisateur DOIVENT être enrichies avec ton 'Knowledge Base' métier (ex: importance de l'hygiène pour l'agro, délais pour la couture).
40
+ - ANTI-JARGON SAAS : INTERDICTION FORMELLE d'utiliser les mots "Premium", "Trial", "Subscription", "Sign up" ou "SaaS". Adapte le Modèle Économique au secteur réel (Vente directe, prestation de service, acompte, etc).
41
+ - SOURCES (marketSources) : Si tu utilises les données de marché injectées, cite explicitement la source (ex: "Source: ANSD 2024").
42
+ - ANALYSE vs DESCRIPTION : Ne te contente pas d'énumérer. Explique l'impact business et le positionnement luxe.
43
+ - DÉTANCHÉITÉ LINGUISTIQUE : 100% ${language === 'WOLOF' ? 'WOLOF standardisé v4.0' : 'Français institutionnel'}.
44
+ - DATA STEWARD : Intègre les données de marché réelles (ANSD, UEMOA) et les projections financières.
45
+ LANGUAGE: Write EVERYTHING in ${language === 'WOLOF' ? 'WOLOF (ñ, ë, é) suivi de la traduction FR' : 'French'}. NO ENGLISH.
46
  `;
47
 
48
  return this.provider.generateStructuredData(prompt, OnePagerSchema);
 
51
  /**
52
  * Extracts a Slide Deck JSON structure from raw user data.
53
  */
54
+ async generatePitchDeckData(userContext: string, language: string = 'FR', businessProfile?: any): Promise<PitchDeckData> {
55
+ const marketDataInjected = businessProfile?.marketData
56
+ ? `\n🌐 DONNÉES DE MARCHÉ (RECHERCHE WEB) :\n${JSON.stringify(businessProfile.marketData, null, 2)}\n`
57
+ : '';
58
+
59
  const prompt = `
60
+ Tu es un expert en Pitch Decks internationaux (VC-ready).
61
+ Génère un deck de 13 slides STRICTEMENT basé sur la structure suivante pour ce business :
62
 
63
+ Secteur : ${businessProfile?.activityLabel || 'Entrepreneuriat'}
64
+ Région : ${businessProfile?.locationCity || 'Sénégal'}
65
+
66
+ STRUCTURE DES 13 SLIDES :
67
+ 1. Couverture : Logo, nom, slogan clair.
68
+ 2. Problème : Pain point étayé par des faits.
69
+ 3. Solution : Comment le service résout le problème.
70
+ 4. Produit/Techno : Démo ou captures (conceptuelles).
71
+ 5. Taille du Marché (TAM/SAM/SOM) : Un graphique en cercles concentriques. Calcule le marché total (Dakar ou Sénégal), le marché adressable et ta part cible à partir des données réelles.
72
+ 6. Business Model : Qui paie, combien, fréquence.
73
+ 7. Traction / Métriques : Preuves de validation.
74
+ 8. Go-to-Market : Stratégie d'acquisition.
75
+ 9. Concurrence : Paysage concurrentiel et avantage injuste (utilise les données réelles fournies).
76
+ 10. Équipe : Profils fondateurs et expertise (Bio issue du BusinessProfile).
77
+ 11. Projections Financières : Vision de croissance à 5 ans.
78
+ 12. L'Appel (The Ask) : Ce dont tu as besoin (financement, partenaires).
79
+ 13. Contact : Coordonnées et mot de la fin.
80
+
81
  USER INPUT:
82
  ${userContext}
83
+ ${marketDataInjected}
84
+
85
+ STRICTES CONTRAINTES DE QUALITÉ "GENSPARK-STANDARD" :
86
+ - STORYTELLING (CRUCIAL) : Rédige de véritables petits paragraphes narratifs (2 à 3 phrases, 15-25 mots par bloc). INTERDICTION TOTALE d'utiliser des listes à puces ("bullet points") classiques avec des mots isolés. Raconte une histoire convaincante pour un investisseur.
87
+ - RECHERCHE INTELLIGENTE INTEGREE : Tu dois IMPÉRATIVEMENT fusionner les [DONNÉES DE MARCHÉ] avec les mots de l'utilisateur. Ne te contente pas de citer, explique l'impact (ex: "Fort de 4,4M d'habitants à Dakar selon l'ANSD, le marché représente une opportunité...").
88
+ - ANTI-JARGON SAAS : INTERDICTION FORMELLE d'utiliser les mots "Premium", "Trial", "Subscription", "Sign up" ou "SaaS". Le Business Model doit être 100% réaliste pour le secteur local (Vente au kilo, Prestation, Acompte, GMS).
89
+ - ANALYSE vs DESCRIPTION : Ne décris pas, analyse. (Ex: Au lieu de 'Délais non respectés', dis 'L'instabilité chronique des délais de livraison artisanaux dégrade l'expérience client et réduit le taux de réachat').
90
+ - Slide 5 (Marché) : visualType = 'PIE_CHART', visualData = { labels: ["TAM (Total)", "SAM (Cible)", "SOM (Capturable)"], values: [nombre, nombre, nombre] }. Utilise le cascade : National > Régional > UEMOA. La source (ex: "Source: ANSD 2024") DOIT ÊTRE EXPLICITEMENT CITÉE en bas du slide dans content ou notes.
91
+ - Slide 11 (Finances) : visualType = 'BAR_CHART', visualData = { labels: ["Année 1", "Année 2", "Année 3", "Année 4", "Année 5"], values: [nombre, nombre, nombre, nombre, nombre] }. Explique la LOGIQUE de croissance financière de manière narrative.
92
+ - STRICTEMENT 3 à 4 blocs de texte par slide. Aucun bloc de moins de 15 mots.
93
+ - DÉTANCHÉITÉ LINGUISTIQUE :
94
+ * Si language === 'FR' : 100% Français de haut niveau (ton institutionnel, banquier d'affaires). ZÉRO mot en Wolof.
95
+ * Si language === 'WOLOF' : 100% Wolof standardisé v4.0. ZÉRO mot en Français.
96
+ - LANGUAGE: ${language === 'WOLOF' ? 'WOLOF' : 'FRENCH'}.
97
  `;
98
 
99
  return this.provider.generateStructuredData(prompt, PitchDeckSchema);
 
114
  userRegion?: string,
115
  dayNumber?: number,
116
  previousResponses?: Array<{ day: number; response: string }>
117
+ ): Promise<FeedbackData & { searchResults?: any[] }> {
 
 
 
 
 
 
 
 
118
  const activityLabel = userActivity || businessProfile?.activityLabel || 'non précisé';
119
  const region = userRegion || businessProfile?.region || 'Sénégal';
120
  const customer = businessProfile?.mainCustomer || '';
 
136
  ? `\n📚 RÉPONSES PRÉCÉDENTES (pour cohérence et contexte) :\n${previousResponses.map(r => ` J${r.day}: "${r.response.substring(0, 150)}"`).join('\n')}\n`
137
  : '';
138
 
139
+ let searchContext = '';
140
+ let searchResults: any[] | undefined = undefined;
141
+ if (dayNumber === 2 || dayNumber === 5 || dayNumber === 10) {
142
+ console.log(`[AI_SERVICE] 🔍 Triggering Market Search for Day ${dayNumber}...`);
143
+ const query = `${activityLabel} ${region} Sénégal marché concurrence`;
144
+ try {
145
+ const results = await searchService.search(query);
146
+ if (results && results.length > 0) {
147
+ searchResults = results;
148
+ searchContext = `\n🌐 DONNÉES DE MARCHÉ RÉELLES (Google Search) :\n${results.map(r => `- ${r.title}: ${r.snippet}`).join('\n')}\n`;
149
+ console.log(`[AI_SERVICE] ✅ Search enrichment added (${results.length} results).`);
150
+ }
151
+ } catch (err) {
152
+ console.error('[AI_SERVICE] Search enrichment failed:', err);
153
+ }
154
+ }
155
+
156
  const criteriaContext = exerciseCriteria
157
  ? `CRITÈRES D'ÉVALUATION :\n${JSON.stringify(exerciseCriteria, null, 2)}`
158
  : 'CRITÈRES : Réponse concrète, personnelle, et spécifique à son business réel.';
159
 
160
  const prompt = `
161
+ Tu es XAMLÉ COACH, expert business en Afrique de l'Ouest. Évalue la réponse de l'étudiant pour le JOUR ${dayNumber}.
162
+
163
+ ${businessContext}
164
+ ${prevContext}
165
+ ${criteriaContext}
166
+ ${searchContext}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
+ CONTENU DE LA LEÇON :
169
+ "${lessonContent.substring(0, 500)}"
 
170
 
171
+ EXERCICE ATTENDU :
172
+ "${expectedExercise}"
 
173
 
174
+ RÉPONSE DE L'ÉTUDIANT :
175
+ "${userInput}"
 
 
 
 
 
176
 
177
+ MISSIONS STRATÉGIQUES "PREMIUM V4" :
178
+ 1. Évaluation : Est-ce valide selon les critères ?
179
+ 2. Extraction Métriques : Concurrence (J10), Projections (J11), The Ask (J12).
180
+ 3. Feedback "Mini-Cours" : Rédige une Reformulation stratégique, un Praise enthousiaste et une Action (prochaine étape).
181
+
182
+ RÈGLE DE DENSITÉ RÉDACTIONNELLE :
183
+ - Le feedback ne doit pas être un simple 'bien'. Chaque réponse doit expliquer le POURQUOI stratégique (impact sur le business).
184
+
185
+ PROTOCOLE DATA STEWARD (Rigueur) :
186
+ - Cascade de Recherche : Régional > National > UEMOA. Interdiction du Niveau 1 (Quartier).
187
+ - TRANSPARENCE : Cite toujours la source (ex: ANSD).
188
+ - HONNÊTETÉ : Si aucune donnée fiable n'est trouvée, propose l'estimation de l'utilisateur.
189
+
190
+ TON INSTITUTIONNEL (Browsing) :
191
+ Utilise les données de marché pour impressionner l'élève :
192
+ "Félicitations ! Ton approche est validée par les chiffres : selon [Source], le marché de [Secteur] est évalué à [Valeur]. J'ai intégré cette preuve de marché dans ta Slide 5."
193
+
194
+ DÉTANCHÉITÉ LINGUISTIQUE : 100% ${userLanguage === 'WOLOF' ? 'WOLOF (ñ, ë, é)' : 'Français institutionnel'}. Zéro mélange.
195
+ `;
196
 
197
+ const feedback = await this.provider.generateStructuredData(prompt, FeedbackSchema);
198
+ return {
199
+ ...feedback,
200
+ searchResults
201
+ };
202
  }
203
 
204
  /**
 
284
  - Jour 2 : Cherche le type d'activité (Vente / Service / Production).
285
  - Jour 3 : Cherche le client principal (ex: "Étudiants", "Voisins").
286
  - Jour 4 : Cherche le problème principal qu'il résout.
287
+ - Jour 7 : Cherche l'offre simple (ce qu'in vend exactement).
288
  - Jour 8 : Cherche la promesse (rapide, moins cher, etc.).
289
 
290
  EXTRACTION :
apps/api/src/services/ai/mock-provider.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { LLMProvider, OnePagerSchema, PitchDeckSchema, PersonalizedLessonSchema } from './types';
2
 
3
  /**
4
  * A Provider for local development that doesn't require an API Key.
@@ -6,45 +6,126 @@ import { LLMProvider, OnePagerSchema, PitchDeckSchema, PersonalizedLessonSchema
6
  */
7
  export class MockLLMProvider implements LLMProvider {
8
  async generateStructuredData<T>(prompt: string, schema: any): Promise<T> {
9
- console.log('[MOCK LLM] Prompt received (ignoring content):', prompt.substring(0, 50) + '...');
10
 
11
- // Return mock data based on the schema being requested
12
- // Note: In a real advanced mock, you might use a library like JSON Schema Faker,
13
- // but for our specific use-case, hardcoding the two expected shapes is simpler and safer.
14
 
15
  if (schema === OnePagerSchema) {
16
- const mockOnePager = {
17
- title: "SafeTrack MVP",
18
- tagline: "Securing the future of agriculture",
19
- problem: "Farms lack real-time visibility into their operations and equipment.",
20
- solution: "An integrated dashboard tracking visits, tasks, and IoT sensor data in real-time.",
21
- targetAudience: "Farm owners and agricultural technicians.",
22
- businessModel: "SaaS Subscription per farm.",
23
- callToAction: "Sign up for a free 14-day trial."
24
- };
25
- return schema.parse(mockOnePager) as any;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
  if (schema === PitchDeckSchema) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  const mockDeck = {
30
- title: "SafeTrack Pitch Deck",
31
- subtitle: "Revolutionizing Farm Management",
32
  slides: [
33
- { title: "The Problem", content: ["Lack of visibility", "Inefficient technician routing", "Equipment failure goes unnoticed"] },
34
- { title: "Our Solution", content: ["Real-time IoT integration", "Automated visit scheduling", "Centralized dashboard"] },
35
- { title: "Market Size", content: ["$5B AgTech Market", "Growing at 15% YoY"] },
36
- { title: "Business Model", content: ["Tiered SaaS pricing", "Enterprise custom plans"] },
37
- { title: "Next Steps", content: ["Finalize MVP", "Launch Beta", "Raise Seed Round"] }
 
 
 
 
 
 
 
 
38
  ]
39
  };
40
  return schema.parse(mockDeck) as any;
41
  }
42
 
43
  if (schema === PersonalizedLessonSchema) {
44
- const mockPersonalized = {
45
- lessonText: "Voici une leçon adaptée: Pensez à votre ferme comme..."
46
- };
47
- return schema.parse(mockPersonalized) as any;
 
 
 
 
 
 
 
 
 
 
 
48
  }
49
 
50
  throw new Error("MockLLMProvider does not support this schema.");
@@ -59,4 +140,9 @@ export class MockLLMProvider implements LLMProvider {
59
  console.log(`[MOCK LLM] Generating speech for text: ${text.substring(0, 30)}...`);
60
  return Buffer.from("mock_audio_data");
61
  }
 
 
 
 
 
62
  }
 
1
+ import { LLMProvider, OnePagerSchema, PitchDeckSchema, PersonalizedLessonSchema, FeedbackSchema } from './types';
2
 
3
  /**
4
  * A Provider for local development that doesn't require an API Key.
 
6
  */
7
  export class MockLLMProvider implements LLMProvider {
8
  async generateStructuredData<T>(prompt: string, schema: any): Promise<T> {
9
+ console.log('[MOCK LLM] Prompt received:', prompt.substring(0, 100) + '...');
10
 
11
+ const isFish = prompt.includes('Kayar') || prompt.includes('Poisson') || prompt.includes('transformation');
12
+ const isCereal = prompt.includes('Kaolack') || prompt.includes('Céréales') || prompt.includes('mils');
 
13
 
14
  if (schema === OnePagerSchema) {
15
+ if (isCereal) {
16
+ return schema.parse({
17
+ title: "Grains de Kaolack : La Tradition du Mil Réinventée",
18
+ tagline: "Des céréales locales nutritives pour les familles sénégalaises modernes",
19
+ problem: "Les ménages de Kaolack et Dakar importent massivement du riz au détriment des céréales locales (mil, maïs, fonio) car ces dernières sont jugées longues à préparer et pauvres en qualité de conditionnement. Cette dépendance alimentaire fragilise l'économie rurale du bassin arachidier.",
20
+ solution: "Grains de Kaolack propose une gamme de céréales pré-cuites, enrichies et conditionnées sous atmosphère protectrice. Nos produits (Couscous de mil, Arraw, Thiakry) conservent toutes leurs valeurs nutritionnelles tout en étant prêts en 5 minutes, offrant une solution saine, rapide et patriotique à la classe moyenne.",
21
+ targetAudience: "Classe moyenne urbaine de Kaolack et Dakar, institutions scolaires et diaspora. Un marché estimé à 10 millions de consommateurs potentiels en Afrique de l'Ouest.",
22
+ businessModel: "Vente directe en sachets de 500g et 1kg via un réseau de distribution de proximité et grandes surfaces. Marge brute de 30% grâce à des contrats d'approvisionnement direct avec les GIE de producteurs locaux.",
23
+ callToAction: "Consommez local, vivez mieux avec Grains de Kaolack.",
24
+ mainImage: "https://via.placeholder.com/1024x1024.png?text=Grains+de+Kaolack+Cereal",
25
+ marketSources: "Source: ANSD 2024, Ministère de l'Agriculture."
26
+ }) as any;
27
+ }
28
+ if (isFish) {
29
+ return schema.parse({
30
+ title: "Délices de Kayar : L'Excellence du Poisson Transformé",
31
+ tagline: "Valoriser les produits de la mer par l'innovation et la qualité",
32
+ problem: "Le gaspillage post-capture à Kayar et l'instabilité des revenus des pêcheurs sont causés par un manque d'infrastructures de transformation moderne. Les produits traditionnels souffrent souvent de problèmes d'hygiène, limitant leur accès aux marchés urbains premium et à l'exportation.",
33
+ solution: "Délices de Kayar met en place une unité de transformation de poisson high-tech garantissant une traçabilité totale et des normes sanitaires internationales. Nous produisons du poisson séché, fumé et des conserves artisanales de haute qualité, offrant aux consommateurs dakarois une alternative saine et premium aux produits importés.",
34
+ targetAudience: "Notre cible inclut les supermarchés de Dakar, les boutiques de produits locaux haut de gamme, et les foyers de la classe moyenne soucieux de la qualité nutritionnelle et de l'origine de leur alimentation.",
35
+ businessModel: "Vente directe B2B (supermarchés) et B2C (boutique en ligne), avec une stratégie de marge élevée basée sur la marque 'Kayar Premium', assurant une juste rémunération aux pêcheurs locaux partenaires.",
36
+ callToAction: "Rejoignez la révolution de la transformation locale et goûtez à l'authenticité de Kayar.",
37
+ mainImage: "https://via.placeholder.com/1024x1024.png?text=Delices+de+Kayar+Fish"
38
+ }) as any;
39
+ }
40
+
41
+ // Default or Couture
42
+ return schema.parse({
43
+ title: "Sartoria Ndoye : L'Excellence de la Haute Couture",
44
+ tagline: "Le Prestige de Saint-Louis allié à la Précision Contemporaine",
45
+ problem: "Le marché premium sénégalais souffre d'un manque de tailleurs capables de garantir une qualité de finition internationale et un respect contractuel des délais de livraison. Cette instabilité chronique dégrade la confiance des clients et limite le potentiel de croissance du secteur de la mode de luxe.",
46
+ solution: "Sartoria Ndoye propose un atelier de haute couture qui combine le savoir-faire ancestral de Saint-Louis avec des processus industriels de précision. Nous garantissons une expérience client exclusive, avec une traçabilité totale et des finitions 'Zéro Défaut'.",
47
+ targetAudience: "Haute bourgeoisie sénégalaise, cadres dirigeants et diaspora en Europe, soit un segment de plus de 500 000 personnes à fort pouvoir d'achat.",
48
+ businessModel: "Modèle de revenus direct basé sur une tarification premium (150k - 500k FCFA) générant une marge brute confortable, complété par un service VIP et numérique.",
49
+ callToAction: "Découvrez l'élégance Ndoye et planifiez votre séance de mesures.",
50
+ mainImage: "https://via.placeholder.com/1024x1024.png?text=Sartoria+Ndoye+Premium"
51
+ }) as any;
52
  }
53
 
54
  if (schema === PitchDeckSchema) {
55
+ if (isCereal) {
56
+ return schema.parse({
57
+ title: "Grains de Kaolack : Révolutionner la Consommation de Mil",
58
+ subtitle: "Innovation, Nutrition et Souveraineté Alimentaire",
59
+ slides: [
60
+ { title: "Couverture", content: ["Grains de Kaolack", "Transformation de céréales locales pré-cuites", "Kaolack, Sénégal"], notes: "Intro." },
61
+ { title: "Le Problème", content: ["Dépendance excessive aux importations de riz au Sénégal.", "Temps de préparation trop long des céréales traditionnelles.", "Perte de valeur nutritionnelle due aux méthodes artisanales."], notes: "Pain point." },
62
+ { title: "La Solution", content: ["Unité de transformation semi-industrielle à Kaolack.", "Céréales pré-cuites prêtes en 5 minutes chrono.", "Conditionnement hermétique garantissant 12 mois de conservation."], notes: "Solution." },
63
+ { title: "Le Produit", content: ["Couscous de mil enrichi à la poudre de baobab.", "Arraw de maïs local sans additifs chimiques.", "Thiakry prêt à l'emploi pour le petit-déjeuner."], notes: "Gamme." },
64
+ { title: "Marché (TAM)", content: ["Habilitants Kaolack: 300,000 consommateurs directs.", "Marché des céréales à Dakar: 120 Mds FCFA.", "Cible: 10% du marché des produits pré-cuits."], notes: "Data ANSD.", visualType: "PIE_CHART", visualData: { labels: ["Total", "Cible", "SOM"], values: [100, 30, 10] } },
65
+ { title: "Business Model", content: ["Vente directe en boutiques de quartier (Proximité).", "Contrats de distribution avec supermarchés Auchan/Casino.", "Marge nette de 25% sur chaque sachet vendu."], notes: "B-Model." },
66
+ { title: "Traction", content: ["Phase pilote réussie avec 200 ménages à Kaolack.", "Référencement en cours dans 5 supérettes locales.", "Certification FRA (Fabrication Française) obtenue."], notes: "Validation." },
67
+ { title: "Go-to-Market", content: ["Dégustations sur les marchés hebdomadaires (Loumas).", "Partenariats avec les cantines scolaires rurales.", "Publicité radio en wolof ciblant les mères de famille."], notes: "Growth." },
68
+ { title: "Concurrence", content: ["Vs Importateurs: On valorise le produit national.", "Vs Artisans: On garantit l'hygiène et la rapidité.", "Avantage: Maîtrise totale de la source (Bassin Arachidier)."], notes: "Edge." },
69
+ { title: "Équipe", content: ["M. Touré (Directeur, 15 ans agro-industrie).", "Responsable Production (Experte en procédés locaux).", "Réseau de 20 femmes pour le tri et le nettoyage."], notes: "People." },
70
+ { title: "Finances", content: ["Chiffre d'affaires Y1 estimé à 12M FCFA.", "Rentabilité atteinte dès le 14ème mois.", "Projection Y5: Leader régional du pré-cuit."], notes: "Financials.", visualType: "BAR_CHART", visualData: { labels: ["Y1", "Y2", "Y3", "Y4", "Y5"], values: [12, 18, 28, 40, 55] } },
71
+ { title: "L'Appel (The Ask)", content: ["Besoin: 8M FCFA (Machines à emballer, Broyeurs).", "60% Capacité / 20% Marketing / 20% R&D.", "Impact: Production x3 et réduction des Ñàkk (pertes)."], notes: "The Ask." },
72
+ { title: "Contact", content: ["Kaolack, Quartier Léona - Sénégal.", "@grainsdekaolack - Qualité, Santé, Nation.", "Contact@grainsdekaolack.sn"], notes: "End." }
73
+ ]
74
+ }) as any;
75
+ }
76
+ if (isFish) {
77
+ return schema.parse({
78
+ title: "Délices de Kayar : Révolutionner la Transformation Halieutique",
79
+ subtitle: "Qualité, Tradition et Innovation au Service du Sénégal",
80
+ slides: [
81
+ { title: "Couverture", content: ["Délices de Kayar", "Transformation de produits halieutiques", "Kayar, Sénégal"], notes: "Intro." },
82
+ { title: "Le Problème", content: ["Pertes post-capture élevées à Kayar.", "Méthodes traditionnelles peu hygiéniques.", "Faible valeur ajoutée locale impacts."], notes: "Pain point." },
83
+ { title: "La Solution", content: ["Unité de transformation moderne et propre.", "Séchage et fumage contrôlés en inox.", "Packaging premium et traçabilité certifiée."], notes: "Solution." },
84
+ { title: "Marché (TAM)", content: ["Population Dakar: 4,4M consommateurs.", "Marché local: 50 Mds FCFA / an.", "Cible: 5% du marché premium dakarois."], notes: "Data Direction des Pêches.", visualType: "PIE_CHART", visualData: { labels: ["National", "Cible", "SOM"], values: [100, 20, 5] } },
85
+ { title: "Contact", content: ["Site de Kayar, Thiès - Sénégal.", "+221 77 000 00 02", "www.delicesdekayar.sn"], notes: "End." }
86
+ ]
87
+ }) as any;
88
+ }
89
+
90
+ // Default or Couture (High Density)
91
  const mockDeck = {
92
+ title: "Sartoria Ndoye : L'Excellence de la Haute Couture",
93
+ subtitle: "Un Standard Institutionnel pour l'Héritage et l'Innovation",
94
  slides: [
95
+ { title: "Couverture", content: ["Sartoria Ndoye : Maison de Haute Couture de luxe.", "Pont entre héritage et standards mondiaux.", "Exclusivité et prestige pour clientèle exigeante."], notes: "Intro." },
96
+ { title: "Le Problème", content: ["Instabilité des délais de livraison artisanaux.", "Absence de standardisation haut de gamme.", "Manque de professionnalisme en gestion VIP."], notes: "Pain point." },
97
+ { title: "La Solution", content: ["Atelier de précision indus-artisanal.", "Garantie contractuelle de ponctualité.", "Standard 'Luxe Ndoye' avec charte qualité."], notes: "Solution." },
98
+ { title: "Produits", content: ["Boubous Bazin Riche broderies complexes.", "Costumes sur mesure coupes modernes.", "Accessoires exclusifs identité visuelle."], notes: "Gamme." },
99
+ { title: "Marché", content: ["Potentiel habillement luxe: Milliards FCFA.", "Cible Dakar: 4,4M d'habitants premium.", "Objectif: 15% pénétration segment luxe."], notes: "ASND 2023.", visualType: "PIE_CHART", visualData: { labels: ["National", "Premium", "Sartoria"], values: [100, 35, 15] } },
100
+ { title: "Business Model", content: ["Prix Premium (150k - 500k FCFA).", "Ventes Showroom et Instagram VIP.", "Optimisation coûts matières nobles."], notes: "Rentabilité." },
101
+ { title: "Traction", content: ["100 clients VIP fidélisés An 1.", "Accord fournisseurs tissus Mali/Europe.", "Pré-commandes record collection Tabaski."], notes: "Preuves." },
102
+ { title: "Marketing", content: ["Campagnes Instagram immersives.", "Réseau diplomatique et cadres privés.", "Lancements exclusifs Saint-Louis/Dakar."], notes: "Growth." },
103
+ { title: "Concurrence", content: ["Vs Couture Ndar: Rigueur et délais.", "Machines numériques pour broderie rapide.", "Techniques de finition artisanales secrètes."], notes: "Unfair advantage." },
104
+ { title: "Équipe", content: ["M. Ndoye (20 ans exp. Haute Couture).", "Chef d'atelier gestion indus.", "Tailleurs formés aux standards mondiaux."], notes: "Expertise." },
105
+ { title: "Finances", content: ["Croissance CA prévue: +45% / an.", "Objectif Y3: 25M FCFA.", "Amélioration EBITDA par indus."], notes: "5 ans.", visualType: "BAR_CHART", visualData: { labels: ["Y1", "Y2", "Y3", "Y4", "Y5"], values: [8.5, 12, 18, 25, 35] } },
106
+ { title: "L'Appel (The Ask)", content: ["Besoin: 5M FCFA (Machines numériques).", "60% Équipement / 20% FR / 20% Marketing.", "Impact: Capacité +40%, Coût -15%."], notes: "Ask." },
107
+ { title: "Contact", content: ["Cœur de Saint-Louis, Sénégal.", "@sartoriandoye", "Prestige, Tradition, Innovation."], notes: "End." }
108
  ]
109
  };
110
  return schema.parse(mockDeck) as any;
111
  }
112
 
113
  if (schema === PersonalizedLessonSchema) {
114
+ return schema.parse({
115
+ lessonText: `Voici une leçon adaptée à votre secteur (${isFish ? 'Poisson' : isCereal ? 'Céréales' : 'Mode'}): Pensez à votre entreprise comme un moteur de valeur locale...`
116
+ }) as any;
117
+ }
118
+
119
+ if (schema === FeedbackSchema) {
120
+ return schema.parse({
121
+ isQualified: true,
122
+ praise: "Excellent travail ! Ta vision est claire et ambitieuse.",
123
+ rephrase: "Tu proposes donc une solution innovante pour le marché local.",
124
+ action: "Continue ainsi pour l'étape suivante !",
125
+ confidence: 95,
126
+ notes: "Réponse solide.",
127
+ missingElements: []
128
+ }) as any;
129
  }
130
 
131
  throw new Error("MockLLMProvider does not support this schema.");
 
140
  console.log(`[MOCK LLM] Generating speech for text: ${text.substring(0, 30)}...`);
141
  return Buffer.from("mock_audio_data");
142
  }
143
+
144
+ async generateImage(prompt: string): Promise<string> {
145
+ console.log(`[MOCK LLM] Generating image for prompt: ${prompt.substring(0, 30)}...`);
146
+ return "https://via.placeholder.com/1024x1024.png?text=Mock+AI+Image";
147
+ }
148
  }
apps/api/src/services/ai/openai-provider.ts CHANGED
@@ -117,5 +117,23 @@ export class OpenAIProvider implements LLMProvider {
117
  throw err;
118
  }
119
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  }
121
 
 
117
  throw err;
118
  }
119
  }
120
+
121
+ async generateImage(prompt: string): Promise<string> {
122
+ console.log('[OPENAI] Generating image with DALL-E 3...');
123
+ try {
124
+ const response = await this.openai.images.generate({
125
+ model: "dall-e-3",
126
+ prompt,
127
+ n: 1,
128
+ size: "1024x1024",
129
+ quality: "standard",
130
+ response_format: "url"
131
+ });
132
+ return response.data?.[0]?.url || '';
133
+ } catch (err: any) {
134
+ console.error('[OPENAI] Image generation failed:', err);
135
+ return '';
136
+ }
137
+ }
138
  }
139
 
apps/api/src/services/ai/search.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+
3
+ export interface SearchResult {
4
+ title: string;
5
+ snippet: string;
6
+ link: string;
7
+ }
8
+
9
+ export class SearchService {
10
+ private apiKey: string | undefined;
11
+
12
+ constructor() {
13
+ this.apiKey = process.env.SERPER_API_KEY;
14
+ }
15
+
16
+ /**
17
+ * Performs a Google Search using Serper.dev API.
18
+ */
19
+ async search(query: string): Promise<SearchResult[]> {
20
+ if (!this.apiKey) {
21
+ console.warn('[SEARCH_SERVICE] No SERPER_API_KEY found. Returning mock results.');
22
+ return [
23
+ {
24
+ title: `Données pour ${query}`,
25
+ snippet: "Le marché local est en croissance de 10-15% par an. La population cible est estimée à plusieurs milliers de personnes dans cette zone.",
26
+ link: "https://xamle.sn"
27
+ }
28
+ ];
29
+ }
30
+
31
+ try {
32
+ const response = await axios.post('https://google.serper.dev/search', {
33
+ q: query,
34
+ gl: 'sn', // Search in Senegal
35
+ hl: 'fr' // Language French
36
+ }, {
37
+ headers: {
38
+ 'X-API-KEY': this.apiKey,
39
+ 'Content-Type': 'application/json'
40
+ }
41
+ });
42
+
43
+ const results = response.data.organic || [];
44
+ return results.slice(0, 3).map((r: any) => ({
45
+ title: r.title,
46
+ snippet: r.snippet,
47
+ link: r.link
48
+ }));
49
+ } catch (err: any) {
50
+ console.error('[SEARCH_SERVICE] Search failed:', err.message);
51
+ return [];
52
+ }
53
+ }
54
+ }
55
+
56
+ export const searchService = new SearchService();
apps/api/src/services/ai/types.ts CHANGED
@@ -10,6 +10,7 @@ export interface LLMProvider {
10
  generateStructuredData<T>(prompt: string, schema: z.ZodSchema<T>): Promise<T>;
11
  transcribeAudio(audioBuffer: Buffer, filename: string, language?: string): Promise<TranscriptionResult>;
12
  generateSpeech(text: string): Promise<Buffer>;
 
13
  }
14
 
15
  // -----------------------------------------------------
@@ -23,28 +24,51 @@ export const OnePagerSchema = z.object({
23
  problem: z.string().describe("The core problem being solved"),
24
  solution: z.string().describe("How the product/service solves the problem"),
25
  targetAudience: z.string().describe("Who this is for"),
26
- businessModel: z.string().describe("How the project makes money (e.g., Subscription, B2B)"),
27
- callToAction: z.string().describe("The next step for the reader (e.g., 'Contact us', 'Try the beta')")
 
 
28
  });
29
  export type OnePagerData = z.infer<typeof OnePagerSchema>;
30
 
31
- // Schema for a single Slide
32
  export const SlideSchema = z.object({
33
  title: z.string().describe("Slide title (max 5 words)"),
34
- content: z.array(z.string()).max(5).describe("Bullet points for the slide. STRICTLY MAX 5 BULLETS. Keep them concise."),
35
- notes: z.string().describe("Speaker notes (use empty string if none)")
 
 
36
  });
37
  export type SlideData = z.infer<typeof SlideSchema>;
38
 
39
-
40
  // Schema for the V1 Pitch Deck (PPTX)
41
  export const PitchDeckSchema = z.object({
42
  title: z.string(),
43
  subtitle: z.string().describe("Subtitle or catchphrase"),
44
- slides: z.array(SlideSchema).min(5).max(12).describe("The sequence of slides for the pitch deck")
45
  });
46
  export type PitchDeckData = z.infer<typeof PitchDeckSchema>;
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  // Schema for personalized WhatsApp lesson
49
  export const PersonalizedLessonSchema = z.object({
50
  lessonText: z.string().describe("The rewritten lesson text adapted to the user's business sector.")
 
10
  generateStructuredData<T>(prompt: string, schema: z.ZodSchema<T>): Promise<T>;
11
  transcribeAudio(audioBuffer: Buffer, filename: string, language?: string): Promise<TranscriptionResult>;
12
  generateSpeech(text: string): Promise<Buffer>;
13
+ generateImage(prompt: string): Promise<string>;
14
  }
15
 
16
  // -----------------------------------------------------
 
24
  problem: z.string().describe("The core problem being solved"),
25
  solution: z.string().describe("How the product/service solves the problem"),
26
  targetAudience: z.string().describe("Who this is for"),
27
+ businessModel: z.string().describe("How the project makes money (e.g., Vente directe, Prestation)"),
28
+ callToAction: z.string().describe("The next step for the reader (e.g., 'Contact us', 'Try the beta')"),
29
+ mainImage: z.string().optional().describe("URL of the main brand image"),
30
+ marketSources: z.string().optional().describe("Sources des données de marché utilisées (ex: ANSD 2024)")
31
  });
32
  export type OnePagerData = z.infer<typeof OnePagerSchema>;
33
 
 
34
  export const SlideSchema = z.object({
35
  title: z.string().describe("Slide title (max 5 words)"),
36
+ content: z.array(z.string()).max(4).describe("Narrative storytelling blocks for the slide (15-25 words each). STRICTLY MAX 4 BLOCKS."),
37
+ notes: z.string().describe("Speaker notes (use empty string if none)"),
38
+ visualType: z.enum(["NONE", "PIE_CHART", "BAR_CHART", "IMAGE", "ICON"]).optional().describe("Type of visual to add on the right side"),
39
+ visualData: z.any().optional().describe("Data for the chart (if any) or image prompt")
40
  });
41
  export type SlideData = z.infer<typeof SlideSchema>;
42
 
 
43
  // Schema for the V1 Pitch Deck (PPTX)
44
  export const PitchDeckSchema = z.object({
45
  title: z.string(),
46
  subtitle: z.string().describe("Subtitle or catchphrase"),
47
+ slides: z.array(SlideSchema).min(10).max(15).describe("The sequence of slides for the pitch deck (Targeting 13 slides)")
48
  });
49
  export type PitchDeckData = z.infer<typeof PitchDeckSchema>;
50
 
51
+ // Schema for AI Feedback (Coach)
52
+ export const FeedbackSchema = z.object({
53
+ rephrase: z.string().describe("Synthesized version of the student's answer"),
54
+ praise: z.string().describe("Positive reinforcement"),
55
+ action: z.string().describe("Next pedagogical step or correction"),
56
+ isQualified: z.boolean().describe("Whether the answer meets the lesson's criteria"),
57
+ missingElements: z.array(z.string()).describe("List of IDs from criteria that were not met"),
58
+ confidence: z.number().min(0).max(100).describe("AI confidence in the evaluation"),
59
+ notes: z.string().describe("Internal notes for the system"),
60
+
61
+ // Strategic Enrichment Fields (Sprint 34)
62
+ competitorList: z.array(z.string()).optional().describe("List of competitors identified (Day 10)"),
63
+ financialProjections: z.object({
64
+ revenueY1: z.string().optional(),
65
+ revenueY3: z.string().optional(),
66
+ growthRate: z.string().optional()
67
+ }).optional().describe("3-year growth metrics (Day 11)"),
68
+ fundingAsk: z.string().optional().describe("The Ask: amount and purpose (Day 12)")
69
+ });
70
+ export type FeedbackData = z.infer<typeof FeedbackSchema>;
71
+
72
  // Schema for personalized WhatsApp lesson
73
  export const PersonalizedLessonSchema = z.object({
74
  lessonText: z.string().describe("The rewritten lesson text adapted to the user's business sector.")
apps/api/src/services/renderers/pdf-renderer.ts CHANGED
@@ -24,23 +24,33 @@ export class PdfOnePagerRenderer implements DocumentRenderer<OnePagerData> {
24
 
25
  header {
26
  text-align: center;
27
- margin-bottom: 40px;
28
  padding-bottom: 20px;
29
  border-bottom: 3px solid #F4A261;
30
  }
31
 
 
 
 
 
 
 
 
 
 
32
  h1 {
33
  font-family: 'Montserrat', sans-serif;
34
- color: #1C7C54;
35
- font-size: 32px;
36
  margin: 0 0 10px 0;
37
  text-transform: uppercase;
 
38
  }
39
 
40
  .tagline {
41
- color: #1B3A57;
42
- font-size: 18px;
43
- font-style: italic;
44
  margin: 0;
45
  }
46
 
@@ -82,34 +92,37 @@ export class PdfOnePagerRenderer implements DocumentRenderer<OnePagerData> {
82
  </head>
83
  <body>
84
  <header>
85
- <h1>${data.title}</h1>
86
- <p class="tagline">${data.tagline}</p>
 
87
  </header>
88
 
89
- <div class="main-content">
90
  <div class="section">
91
- <h2>The Problem</h2>
92
- <p>${data.problem}</p>
93
  </div>
94
 
95
  <div class="section">
96
- <h2>Our Solution</h2>
97
- <p>${data.solution}</p>
98
  </div>
99
 
100
  <div class="section">
101
- <h2>Target Audience</h2>
102
- <p>${data.targetAudience}</p>
103
  </div>
104
 
105
  <div class="section">
106
- <h2>Business Model</h2>
107
- <p>${data.businessModel}</p>
108
  </div>
109
- </div>
 
 
 
110
 
111
  <div class="cta">
112
- <p>${data.callToAction}</p>
113
  </div>
114
  </body>
115
  </html>
 
24
 
25
  header {
26
  text-align: center;
27
+ margin-bottom: 30px;
28
  padding-bottom: 20px;
29
  border-bottom: 3px solid #F4A261;
30
  }
31
 
32
+ .main-image {
33
+ width: 100%;
34
+ height: 250px;
35
+ object-fit: cover;
36
+ border-radius: 8px;
37
+ margin-bottom: 20px;
38
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
39
+ }
40
+
41
  h1 {
42
  font-family: 'Montserrat', sans-serif;
43
+ color: #1B3A57;
44
+ font-size: 28px;
45
  margin: 0 0 10px 0;
46
  text-transform: uppercase;
47
+ letter-spacing: 1px;
48
  }
49
 
50
  .tagline {
51
+ color: #1C7C54;
52
+ font-size: 16px;
53
+ font-weight: 600;
54
  margin: 0;
55
  }
56
 
 
92
  </head>
93
  <body>
94
  <header>
95
+ ${data.mainImage ? `<img src="${data.mainImage}" class="main-image" alt="Brand Visual">` : ''}
96
+ <h1>${data.title || '[Nom du Projet]'}</h1>
97
+ <p class="tagline">${data.tagline || '[Votre Slogan Stratégique]'}</p>
98
  </header>
99
 
 
100
  <div class="section">
101
+ <h2>Analyse du Problème</h2>
102
+ <p>${data.problem || '[Donnée à compléter]'}</p>
103
  </div>
104
 
105
  <div class="section">
106
+ <h2>Notre Solution Stratégique</h2>
107
+ <p>${data.solution || '[Donnée à compléter]'}</p>
108
  </div>
109
 
110
  <div class="section">
111
+ <h2>Cible et Opportunité</h2>
112
+ <p>${data.targetAudience || '[Donnée à compléter]'}</p>
113
  </div>
114
 
115
  <div class="section">
116
+ <h2>Modèle Économique & Croissance</h2>
117
+ <p>${data.businessModel || '[Donnée à compléter]'}</p>
118
  </div>
119
+ ${data.marketSources ? `
120
+ <div class="section" style="margin-top: 40px; border-top: 1px solid #e2e8f0; padding-top: 20px;">
121
+ <p style="font-size: 12px; color: #718096; text-align: center;"><strong>Sources & Données Réelles :</strong> ${data.marketSources}</p>
122
+ </div>` : ''}
123
 
124
  <div class="cta">
125
+ <p>${data.callToAction || '[Action Suivante]'}</p>
126
  </div>
127
  </body>
128
  </html>
apps/api/src/services/renderers/pptx-renderer.ts CHANGED
@@ -6,58 +6,100 @@ export class PptxDeckRenderer implements DocumentRenderer<PitchDeckData> {
6
  async render(data: PitchDeckData): Promise<Buffer> {
7
  const pres = new PptxGenJS();
8
 
9
- // Define Master Slide (Design System)
10
- // Primary: #1C7C54, Secondary: #1B3A57, Accent: #F4A261
11
  pres.defineSlideMaster({
12
  title: 'MASTER_SLIDE',
13
  bkgd: 'FFFFFF',
14
  objects: [
15
- { rect: { x: 0, y: 0, w: '100%', h: 0.75, fill: { color: '1B3A57' } } },
16
- { rect: { x: 0, y: 0.75, w: '100%', h: 0.05, fill: { color: 'F4A261' } } }
 
17
  ]
18
  });
19
 
20
- // Title Slide
21
  const titleSlide = pres.addSlide();
22
- titleSlide.bkgd = '1B3A57'; // Dark background
23
  titleSlide.addText(data.title.toUpperCase(), {
24
- x: 1, y: 2, w: '80%', h: 1,
25
- fontSize: 44, color: 'FFFFFF', bold: true, fontFace: 'Montserrat', align: 'center'
26
  });
27
  if (data.subtitle) {
28
  titleSlide.addText(data.subtitle, {
29
- x: 1, y: 3, w: '80%', h: 1,
30
- fontSize: 24, color: 'F4A261', fontFace: 'Inter', align: 'center'
31
  });
32
  }
 
 
 
 
33
 
34
- // Content Slides
35
- data.slides.forEach((slideData: SlideData) => {
36
  const slide = pres.addSlide({ masterName: 'MASTER_SLIDE' });
37
 
38
- // Title
39
- slide.addText(slideData.title, {
40
- x: 0.5, y: 0.15, w: '90%', h: 0.5,
41
  fontSize: 28, color: 'FFFFFF', bold: true, fontFace: 'Montserrat'
42
  });
43
 
44
- // Bullets (Max 5 enforced by Zod, but we render them nicely)
45
- const bulletOptions = slideData.content.map(text => ({ text }));
46
 
47
- slide.addText(bulletOptions, {
48
- x: 0.5, y: 1.2, w: '90%', h: '70%',
49
- fontSize: 20, color: '333333', fontFace: 'Inter',
50
- bullet: { type: 'bullet', code: '2022' }, // Green bullet color not directly supported like this in older typing
51
- valign: 'top', margin: 10, lineSpacing: 35
 
52
  });
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  // Speaker Notes
55
  if (slideData.notes) {
56
  slide.addNotes(slideData.notes);
57
  }
58
  });
59
 
60
- // Output to Buffer
61
  const buffer = await pres.write({ outputType: 'nodebuffer' }) as Buffer;
62
  return buffer;
63
  }
 
6
  async render(data: PitchDeckData): Promise<Buffer> {
7
  const pres = new PptxGenJS();
8
 
9
+ // 🎨 Premium Design System
10
+ // XAMLÉ Palette: Emerald (#1C7C54), Navy (#1B3A57), Saffron (#F4A261)
11
  pres.defineSlideMaster({
12
  title: 'MASTER_SLIDE',
13
  bkgd: 'FFFFFF',
14
  objects: [
15
+ { rect: { x: 0, y: 0, w: '100%', h: 1.0, fill: { color: '1B3A57' } } }, // Header
16
+ { rect: { x: 0, y: 1.0, w: '100%', h: 0.05, fill: { color: 'F4A261' } } }, // Accent line
17
+ { text: { text: "XAMLÉ 🇸🇳 - Pitch Deck Stratégique", options: { x: 0.5, y: 5.15, fontSize: 10, color: '1B3A57' } } }
18
  ]
19
  });
20
 
21
+ // 1. Title Slide (Slide 1)
22
  const titleSlide = pres.addSlide();
23
+ titleSlide.bkgd = '1B3A57';
24
  titleSlide.addText(data.title.toUpperCase(), {
25
+ x: 0, y: 2, w: '100%', h: 1,
26
+ fontSize: 48, color: 'FFFFFF', bold: true, fontFace: 'Montserrat', align: 'center'
27
  });
28
  if (data.subtitle) {
29
  titleSlide.addText(data.subtitle, {
30
+ x: 0, y: 3.2, w: '100%', h: 1,
31
+ fontSize: 22, color: 'F4A261', fontFace: 'Inter', align: 'center', italic: true
32
  });
33
  }
34
+ titleSlide.addText("Propulsé par XAMLÉ AI", {
35
+ x: 0, y: 4.8, w: '100%', h: 0.5,
36
+ fontSize: 14, color: 'FFFFFF', fontFace: 'Inter', align: 'center'
37
+ });
38
 
39
+ // 2. Content Slides
40
+ data.slides.forEach((slideData: SlideData, index: number) => {
41
  const slide = pres.addSlide({ masterName: 'MASTER_SLIDE' });
42
 
43
+ // Slide Title (Montserrat Bold, White on Navy)
44
+ slide.addText(slideData.title.toUpperCase(), {
45
+ x: 0.5, y: 0.25, w: '90%', h: 0.5,
46
  fontSize: 28, color: 'FFFFFF', bold: true, fontFace: 'Montserrat'
47
  });
48
 
49
+ const hasVisual = slideData.visualType && slideData.visualType !== 'NONE' && slideData.visualData;
50
+ const textWidth = hasVisual ? '50%' : '90%';
51
 
52
+ // 📝 Storytelling Text Blocks (Inter)
53
+ const textBlocks = slideData.content.map(text => ({ text: text + '\n', options: { breakLine: true } }));
54
+ slide.addText(textBlocks, {
55
+ x: 0.5, y: 1.3, w: textWidth, h: '75%',
56
+ fontSize: 15, color: '2D3748', fontFace: 'Inter',
57
+ valign: 'top', margin: 5, lineSpacing: 22
58
  });
59
 
60
+ // 📊 Graphical Integration (WOW Effect - Flat Design)
61
+ if (hasVisual) {
62
+ try {
63
+ const vizData = slideData.visualData;
64
+ if (slideData.visualType === 'PIE_CHART' && vizData.labels && vizData.values) {
65
+ const chartData = [{ name: 'Market Scale', labels: vizData.labels, values: vizData.values }];
66
+ slide.addChart(pres.ChartType.pie, chartData, {
67
+ x: 5.2, y: 1.2, w: 4.5, h: 4.0,
68
+ showLegend: true, legendPos: 'r',
69
+ showValue: true, showPercent: true,
70
+ chartColors: ['1C7C54', 'F4A261', '1B3A57'],
71
+ dataLabelColor: 'FFFFFF'
72
+ });
73
+ }
74
+ else if (slideData.visualType === 'BAR_CHART' && vizData.labels && vizData.values) {
75
+ const chartData = [{ name: 'Projections (FCFA)', labels: vizData.labels, values: vizData.values }];
76
+ slide.addChart(pres.ChartType.bar, chartData, {
77
+ x: 5.2, y: 1.2, w: 4.5, h: 4.0,
78
+ barDir: 'col',
79
+ showValue: true,
80
+ chartColors: ['1C7C54', 'F4A261', '1B3A57'],
81
+ dataLabelColor: '2D3748',
82
+ catAxisLabelColor: '2D3748',
83
+ valAxisLabelColor: '2D3748'
84
+ });
85
+ }
86
+ else if (slideData.visualType === 'IMAGE' && typeof vizData === 'string' && vizData.startsWith('http')) {
87
+ slide.addImage({ path: vizData, x: 5.2, y: 1.0, w: 4.5, h: 4.2 });
88
+ }
89
+ else if (slideData.visualType === 'IMAGE') {
90
+ slide.addText("📷 [Visual AI Placeholder]", { x: 6.5, y: 2.5, fontSize: 14, color: 'A0AEC0', fontFace: 'Inter' });
91
+ }
92
+ } catch (vErr) {
93
+ console.warn(`[RENDERER] Failed to add visual to slide ${index + 1}:`, vErr);
94
+ }
95
+ }
96
+
97
  // Speaker Notes
98
  if (slideData.notes) {
99
  slide.addNotes(slideData.notes);
100
  }
101
  });
102
 
 
103
  const buffer = await pres.write({ outputType: 'nodebuffer' }) as Buffer;
104
  return buffer;
105
  }
apps/web/src/App.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useSearchParams, useParams } from 'react-router-dom';
2
  import { useEffect, useState } from 'react';
3
  import { BookOpen, FileText, Smartphone, ArrowRight, Phone, CheckCircle, Download, AlertCircle } from 'lucide-react';
 
4
 
5
  const WA_NUMBER = import.meta.env.VITE_WHATSAPP_NUMBER || '221771234567';
6
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
@@ -297,6 +298,7 @@ function Footer() {
297
  <ul className="space-y-2 text-sm text-gray-400">
298
  <li><a href="#" className="hover:text-white transition">À propos</a></li>
299
  <li><a href="#" className="hover:text-white transition">Contact</a></li>
 
300
  </ul>
301
  </div>
302
  </div>
@@ -318,6 +320,7 @@ function App() {
318
  <Route path="/student" element={<StudentPortal />} />
319
  <Route path="/student/:phone" element={<StudentDashboard />} />
320
  <Route path="/payment/success" element={<PaymentSuccess />} />
 
321
  </Routes>
322
  </main>
323
  <Footer />
 
1
  import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useSearchParams, useParams } from 'react-router-dom';
2
  import { useEffect, useState } from 'react';
3
  import { BookOpen, FileText, Smartphone, ArrowRight, Phone, CheckCircle, Download, AlertCircle } from 'lucide-react';
4
+ import PrivacyPolicy from './PrivacyPolicy';
5
 
6
  const WA_NUMBER = import.meta.env.VITE_WHATSAPP_NUMBER || '221771234567';
7
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
 
298
  <ul className="space-y-2 text-sm text-gray-400">
299
  <li><a href="#" className="hover:text-white transition">À propos</a></li>
300
  <li><a href="#" className="hover:text-white transition">Contact</a></li>
301
+ <li><Link to="/privacy" className="hover:text-white transition">Confidentialité</Link></li>
302
  </ul>
303
  </div>
304
  </div>
 
320
  <Route path="/student" element={<StudentPortal />} />
321
  <Route path="/student/:phone" element={<StudentDashboard />} />
322
  <Route path="/payment/success" element={<PaymentSuccess />} />
323
+ <Route path="/privacy" element={<PrivacyPolicy />} />
324
  </Routes>
325
  </main>
326
  <Footer />
apps/web/src/PrivacyPolicy.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BookOpen } from 'lucide-react';
2
+ import { Link } from 'react-router-dom';
3
+
4
+ export default function PrivacyPolicy() {
5
+ return (
6
+ <div className="bg-slate-50 min-h-screen py-16 px-4">
7
+ <div className="max-w-3xl mx-auto bg-white p-8 md:p-12 rounded-3xl shadow-sm border border-slate-100">
8
+ <div className="mb-8 border-b border-slate-100 pb-8 text-center">
9
+ <div className="inline-flex bg-primary/10 p-4 rounded-full mb-6">
10
+ <BookOpen className="w-10 h-10 text-primary" />
11
+ </div>
12
+ <h1 className="text-3xl md:text-4xl font-heading font-extrabold text-secondary mb-3">Politique de Confidentialité</h1>
13
+ <p className="text-slate-500">Dernière mise à jour : 7 mars 2026</p>
14
+ </div>
15
+
16
+ <div className="prose prose-slate max-w-none">
17
+ <h2 className="text-xl font-bold text-slate-800 mt-8 mb-4">1. Qui sommes-nous ?</h2>
18
+ <p className="text-slate-600 mb-6">XAMLÉ Studio est une plateforme d'éducation entrepreneuriale accessible via WhatsApp, opérée par xamlé.studio. Contact : <a href="mailto:contact@xamle.studio" className="text-primary hover:underline">contact@xamle.studio</a></p>
19
+
20
+ <h2 className="text-xl font-bold text-slate-800 mt-8 mb-4">2. Données collectées</h2>
21
+ <p className="text-slate-600 mb-4">Lors de votre inscription et utilisation du service, nous collectons :</p>
22
+ <ul className="list-disc pl-5 text-slate-600 space-y-2 mb-6">
23
+ <li>Votre numéro de téléphone WhatsApp</li>
24
+ <li>La langue choisie (Français ou Wolof)</li>
25
+ <li>Votre secteur d'activité / projet professionnel (fourni volontairement)</li>
26
+ <li>Vos réponses aux exercices et contenus pédagogiques</li>
27
+ <li>Les métadonnées de vos messages (horodatage, type de message)</li>
28
+ </ul>
29
+
30
+ <h2 className="text-xl font-bold text-slate-800 mt-8 mb-4">3. Utilisation des données</h2>
31
+ <p className="text-slate-600 mb-4">Vos données sont utilisées pour :</p>
32
+ <ul className="list-disc pl-5 text-slate-600 space-y-2 mb-6">
33
+ <li>Vous envoyer des leçons quotidiennes personnalisées via WhatsApp</li>
34
+ <li>Personnaliser le contenu pédagogique à votre secteur d'activité</li>
35
+ <li>Générer des documents AI (One-Pager PDF, Pitch Deck) basés sur votre parcours</li>
36
+ <li>Améliorer la qualité de nos formations</li>
37
+ <li>Traiter vos paiements pour les formations premium</li>
38
+ </ul>
39
+
40
+ <h2 className="text-xl font-bold text-slate-800 mt-8 mb-4">4. Partage des données</h2>
41
+ <p className="text-slate-600 mb-4">Nous ne vendons jamais vos données. Elles peuvent être partagées uniquement avec :</p>
42
+ <ul className="list-disc pl-5 text-slate-600 space-y-2 mb-6">
43
+ <li><strong>Meta / WhatsApp</strong> — pour l'acheminement des messages</li>
44
+ <li><strong>OpenAI</strong> — pour la génération et personnalisation du contenu pédagogique</li>
45
+ <li><strong>Stripe</strong> — pour le traitement sécurisé des paiements</li>
46
+ <li><strong>Cloudflare</strong> — pour le stockage des documents générés</li>
47
+ </ul>
48
+
49
+ <h2 className="text-xl font-bold text-slate-800 mt-8 mb-4">5. Conservation des données</h2>
50
+ <p className="text-slate-600 mb-6">Vos données sont conservées pendant toute la durée de votre inscription active. Vous pouvez demander la suppression de vos données à tout moment en envoyant un e-mail à <a href="mailto:contact@xamle.studio" className="text-primary hover:underline">contact@xamle.studio</a>.</p>
51
+
52
+ <h2 className="text-xl font-bold text-slate-800 mt-8 mb-4">6. Vos droits</h2>
53
+ <p className="text-slate-600 mb-4">Conformément au RGPD et aux lois applicables, vous disposez du droit de :</p>
54
+ <ul className="list-disc pl-5 text-slate-600 space-y-2 mb-4">
55
+ <li>Accéder à vos données personnelles</li>
56
+ <li>Corriger les données inexactes</li>
57
+ <li>Demander la suppression de vos données</li>
58
+ <li>Vous opposer au traitement de vos données</li>
59
+ </ul>
60
+ <p className="text-slate-600 mb-6">Pour exercer ces droits, contactez-nous à : <a href="mailto:contact@xamle.studio" className="text-primary hover:underline">contact@xamle.studio</a></p>
61
+
62
+ <h2 className="text-xl font-bold text-slate-800 mt-8 mb-4">7. Sécurité</h2>
63
+ <p className="text-slate-600 mb-6">Vos données sont protégées par chiffrement (TLS en transit, AES au repos). L'accès aux données est strictement limité aux systèmes nécessaires au fonctionnement du service.</p>
64
+
65
+ <h2 className="text-xl font-bold text-slate-800 mt-8 mb-4">8. Modifications</h2>
66
+ <p className="text-slate-600 mb-6">Cette politique peut être mise à jour. En cas de modification majeure, vous serez informé via WhatsApp.</p>
67
+
68
+ <h2 className="text-xl font-bold text-slate-800 mt-8 mb-4">9. Contact</h2>
69
+ <p className="text-slate-600 mb-12">Pour toute question relative à cette politique : <a href="mailto:contact@xamle.studio" className="text-primary hover:underline">contact@xamle.studio</a></p>
70
+ </div>
71
+
72
+ <div className="mt-8 pt-8 border-t border-slate-100 text-center">
73
+ <Link to="/" className="text-sm text-primary font-medium hover:underline inline-flex items-center">
74
+ ← Retour à l'accueil
75
+ </Link>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ );
80
+ }
apps/whatsapp-worker/src/index.ts CHANGED
@@ -132,12 +132,23 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
132
  // Clean up undefined/null values
133
  const profileData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v != null && v !== ''));
134
 
135
- if (Object.keys(profileData).length > 0) {
136
  console.log(`[WORKER] Updating BusinessProfile for User ${userId}:`, profileData);
 
 
 
 
 
 
 
 
 
 
 
137
  await (prisma as any).businessProfile.upsert({
138
  where: { userId },
139
- update: { ...profileData, lastUpdatedFromDay: currentDay },
140
- create: { userId, ...profileData, lastUpdatedFromDay: currentDay }
141
  });
142
 
143
  // 🌟 Visuals WOW: Generate Pitch Card for Day 1 🌟
@@ -201,7 +212,11 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
201
  where: { userId_trackId: { userId, trackId } },
202
  data: {
203
  exerciseStatus: 'PENDING_REMEDIATION', // Stay in remediation until final success
204
- score: { increment: 0 }
 
 
 
 
205
  } as any
206
  });
207
  } else {
@@ -217,7 +232,11 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
217
  exerciseStatus: 'COMPLETED',
218
  score: { increment: 1 },
219
  badges: updatedBadges,
220
- behavioralScoring: updateBehavioralScore((currentProgress as any)?.behavioralScoring, (exerciseCriteria as any)?.scoring?.impact_success)
 
 
 
 
221
  } as any
222
  });
223
 
@@ -676,11 +695,16 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
676
  // 🌟 Trigger AI Document Generation 🌟
677
  console.log(`[WORKER] Triggering AI Document Generation for User ${userId}...`);
678
  try {
679
- const isWolof = user?.language === 'WOLOF';
 
 
 
 
 
680
  const userLangPrefix = isWolof ? "MBIR : " : "ACTIVITÉ : ";
681
 
682
  // Localize context to avoid English bias in LLM
683
- const userContext = `${userLangPrefix} ${user?.activity || 'Inconnue'}. Cet entrepreneur a terminé son parcours de formation XAMLÉ. Génère les documents basés sur son activité et les concepts appris.`;
684
 
685
  const AI_API_BASE_URL = getApiUrl();
686
  const apiKey = getAdminApiKey();
@@ -689,19 +713,27 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
689
  'Authorization': `Bearer ${apiKey}`
690
  };
691
 
692
- console.log(`[WORKER] Calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/onepager (${user?.language || 'FR'})...`);
693
  const opRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/onepager`, {
694
  method: 'POST',
695
  headers: authHeaders,
696
- body: JSON.stringify({ userContext, language: user?.language || 'FR' })
 
 
 
 
697
  });
698
  const pdfData = await opRes.json() as any;
699
 
700
- console.log(`[WORKER] Calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck (${user?.language || 'FR'})...`);
701
  const deckRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck`, {
702
  method: 'POST',
703
  headers: authHeaders,
704
- body: JSON.stringify({ userContext, language: user?.language || 'FR' })
 
 
 
 
705
  });
706
  const pptxData = await deckRes.json() as any;
707
 
 
132
  // Clean up undefined/null values
133
  const profileData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v != null && v !== ''));
134
 
135
+ if (Object.keys(profileData).length > 0 || feedbackData?.searchResults) {
136
  console.log(`[WORKER] Updating BusinessProfile for User ${userId}:`, profileData);
137
+
138
+ const updatePayload: any = {
139
+ ...profileData,
140
+ lastUpdatedFromDay: currentDay
141
+ };
142
+
143
+ if (feedbackData?.searchResults) {
144
+ updatePayload.marketData = feedbackData.searchResults;
145
+ console.log(`[WORKER] Market Data (Enrichment) added to profile.`);
146
+ }
147
+
148
  await (prisma as any).businessProfile.upsert({
149
  where: { userId },
150
+ update: updatePayload,
151
+ create: { userId, ...updatePayload }
152
  });
153
 
154
  // 🌟 Visuals WOW: Generate Pitch Card for Day 1 🌟
 
212
  where: { userId_trackId: { userId, trackId } },
213
  data: {
214
  exerciseStatus: 'PENDING_REMEDIATION', // Stay in remediation until final success
215
+ score: { increment: 0 },
216
+ marketData: feedbackData?.searchResults,
217
+ competitorList: (feedbackData as any)?.competitorList,
218
+ financialProjections: (feedbackData as any)?.financialProjections,
219
+ fundingAsk: (feedbackData as any)?.fundingAsk
220
  } as any
221
  });
222
  } else {
 
232
  exerciseStatus: 'COMPLETED',
233
  score: { increment: 1 },
234
  badges: updatedBadges,
235
+ behavioralScoring: updateBehavioralScore((currentProgress as any)?.behavioralScoring, (exerciseCriteria as any)?.scoring?.impact_success),
236
+ marketData: feedbackData?.searchResults,
237
+ competitorList: (feedbackData as any)?.competitorList,
238
+ financialProjections: (feedbackData as any)?.financialProjections,
239
+ fundingAsk: (feedbackData as any)?.fundingAsk
240
  } as any
241
  });
242
 
 
695
  // 🌟 Trigger AI Document Generation 🌟
696
  console.log(`[WORKER] Triggering AI Document Generation for User ${userId}...`);
697
  try {
698
+ const userWithProfile = await prisma.user.findUnique({
699
+ where: { id: userId },
700
+ include: { businessProfile: true } as any
701
+ }) as any;
702
+
703
+ const isWolof = userWithProfile?.language === 'WOLOF';
704
  const userLangPrefix = isWolof ? "MBIR : " : "ACTIVITÉ : ";
705
 
706
  // Localize context to avoid English bias in LLM
707
+ const userContext = `${userLangPrefix} ${userWithProfile?.businessProfile?.activityLabel || userWithProfile?.activity || 'Inconnue'}. Cet entrepreneur a terminé son parcours de formation XAMLÉ. Génère les documents basés sur son activité et les concepts appris.`;
708
 
709
  const AI_API_BASE_URL = getApiUrl();
710
  const apiKey = getAdminApiKey();
 
713
  'Authorization': `Bearer ${apiKey}`
714
  };
715
 
716
+ console.log(`[WORKER] Calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/onepager (${userWithProfile?.language || 'FR'})...`);
717
  const opRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/onepager`, {
718
  method: 'POST',
719
  headers: authHeaders,
720
+ body: JSON.stringify({
721
+ userContext,
722
+ language: userWithProfile?.language || 'FR',
723
+ businessProfile: userWithProfile?.businessProfile
724
+ })
725
  });
726
  const pdfData = await opRes.json() as any;
727
 
728
+ console.log(`[WORKER] Calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck (${userWithProfile?.language || 'FR'})...`);
729
  const deckRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck`, {
730
  method: 'POST',
731
  headers: authHeaders,
732
+ body: JSON.stringify({
733
+ userContext,
734
+ language: userWithProfile?.language || 'FR',
735
+ businessProfile: userWithProfile?.businessProfile
736
+ })
737
  });
738
  const pptxData = await deckRes.json() as any;
739
 
packages/database/content/tracks/T1-FR.json CHANGED
@@ -458,30 +458,35 @@
458
  },
459
  {
460
  "dayNumber": 10,
461
- "title": "Objectionrépondre sans se fâcher",
462
- "lessonText": "Un client va dire : ‘c’est cher’, ‘je vais réfléchir’, ‘je ne connais pas’.\n\nAujourd’hui : on prépare une réponse simple et polie.\n✅ écouter\n✅ rassurer\n✅ proposer une action (essai, petite quantité, preuve)",
463
  "exerciseType": "TEXT",
464
- "exercisePrompt": "Choisis 1 objection fréquente de tes clients et écris ta réponse en 2–3 phrases.",
465
  "exerciseCriteria": {
466
  "version": "1.0",
467
  "type": "TEXT",
468
- "goal": "Gestion de la relation client",
469
  "success": {
470
  "mustInclude": [
471
  {
472
- "id": "OBJECTION",
473
- "desc": "Objection identifiée",
474
  "weight": 5
475
  },
476
  {
477
- "id": "REPONSE",
478
- "desc": "Réponse constructive",
479
- "weight": 5
 
 
 
 
 
480
  }
481
  ],
482
  "threshold": {
483
- "minScore": 5,
484
- "minMustPass": 1
485
  }
486
  },
487
  "evaluation": {
@@ -490,42 +495,40 @@
490
  }
491
  },
492
  "badges": [
493
- "OBJECTIONS"
494
  ],
495
- "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes10_objections.png",
496
- "videoUrl": "https://r2.xamle.sn/videos/v7_3.mp4",
497
- "videoCaption": "📢 Feedback : Écouter pour s’améliorer."
498
  },
499
  {
500
  "dayNumber": 11,
501
- "title": "Plan 7 jourspassage à l’action",
502
- "lessonText": "On termine avec un plan simple.\n\nObjectif : 7 jours = 7 actions.\n✅ petit\n✅ réaliste\n✅ mesurable\n\nExemple : 10 messages WhatsApp, 3 visites terrain, 1 photo produit.",
503
  "exerciseType": "TEXT",
504
- "exercisePrompt": "Écris ton plan 7 jours : liste 5 actions concrètes que tu feras cette semaine pour vendre ou améliorer ton business.",
505
  "exerciseCriteria": {
506
  "version": "1.0",
507
  "type": "TEXT",
508
- "goal": "Plan d'action opérationnel",
509
  "success": {
510
  "mustInclude": [
511
  {
512
- "id": "ACTION1",
513
- "desc": "Action 1",
514
- "weight": 3
515
  },
516
  {
517
- "id": "ACTION2",
518
- "desc": "Action 2",
519
- "weight": 3
520
  },
521
  {
522
- "id": "ACTION3",
523
- "desc": "Action 3",
524
- "weight": 4
525
  }
526
  ],
527
  "threshold": {
528
- "minScore": 6,
529
  "minMustPass": 2
530
  }
531
  },
@@ -535,38 +538,41 @@
535
  }
536
  },
537
  "badges": [
538
- "ACTION"
539
  ],
540
- "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes11_plan.png",
541
- "videoUrl": "https://r2.xamle.sn/videos/v1_2.mp4",
542
- "videoCaption": "📈 Discipline : La régularité est la clé."
543
  },
544
  {
545
  "dayNumber": 12,
546
- "title": "Livrable final — Pitch + prochaine étape",
547
- "lessonText": "Bravo. Aujourd’hui, tu finalises ton pitch et ta prochaine étape.\n\n✅ On veut un pitch clair + une action immédiate (RDV, appel, message, visite).\n\nAprès ça, tu recevras ton récap (pitch deck) automatiquement.",
548
  "exerciseType": "AUDIO",
549
- "exercisePrompt": "Envoie ton pitch final en 30–45 secondes + dis ta prochaine étape (ce que tu fais demain).",
550
  "exerciseCriteria": {
551
  "version": "1.0",
552
  "type": "AUDIO",
553
- "goal": "Clôture du Niveau 1 et Transition",
554
  "success": {
555
  "mustInclude": [
556
  {
557
- "id": "PITCH",
558
- "desc": "Pitch complet",
559
- "weight": 5
560
  },
561
  {
562
- "id": "PROCHAINE_ACTION",
563
- "desc": "Action future immédiate",
564
  "weight": 5
 
 
 
 
 
565
  }
566
  ],
567
  "threshold": {
568
- "minScore": 5,
569
- "minMustPass": 1
570
  }
571
  },
572
  "evaluation": {
 
458
  },
459
  {
460
  "dayNumber": 10,
461
+ "title": "ConcurrenceTes 3 rivaux",
462
+ "lessonText": "Pour gagner, il faut savoir contre qui tu te bats.\n\nAujourd’hui : identifie tes 3 plus gros concurrents (ceux qui font la même chose que toi dans ta zone).\n✅ Qui sont-ils ?\n✅ Quel est leur prix moyen pour ton produit phare ?\n✅ Pourquoi un client irait chez eux plutôt que chez toi ?",
463
  "exerciseType": "TEXT",
464
+ "exercisePrompt": "Donne le nom (ou type) de tes 3 plus gros concurrents, leurs prix, et explique leur point fort par rapport au tien.",
465
  "exerciseCriteria": {
466
  "version": "1.0",
467
  "type": "TEXT",
468
+ "goal": "Analyse de la concurrence détaillée",
469
  "success": {
470
  "mustInclude": [
471
  {
472
+ "id": "CONCURRENTS",
473
+ "desc": "3 noms ou types de rivaux",
474
  "weight": 5
475
  },
476
  {
477
+ "id": "PRIX",
478
+ "desc": "Estimation des prix rivaux",
479
+ "weight": 3
480
+ },
481
+ {
482
+ "id": "FORCE",
483
+ "desc": "Avantage du concurrent",
484
+ "weight": 2
485
  }
486
  ],
487
  "threshold": {
488
+ "minScore": 6,
489
+ "minMustPass": 2
490
  }
491
  },
492
  "evaluation": {
 
495
  }
496
  },
497
  "badges": [
498
+ "STRATÉGIE"
499
  ],
500
+ "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes10_objections.png"
 
 
501
  },
502
  {
503
  "dayNumber": 11,
504
+ "title": "Équipe & VisionTes chiffres",
505
+ "lessonText": "Un investisseur n'achète pas juste une idée, il achète une ÉQUIPE et une VISION chiffrée.\n\nAujourd’hui : \n1) Qui t'aide au quotidien (associé, employé, famille) ?\n2) Quel est ton objectif de Chiffre d'Affaires (CA) dans 3 ans ?\n3) Combien de boutiques ou clients vises-tu en 2029 ?",
506
  "exerciseType": "TEXT",
507
+ "exercisePrompt": "Décris ton équipe (rôles) et tes projections : CA visé et nombre de clients en 2029.",
508
  "exerciseCriteria": {
509
  "version": "1.0",
510
  "type": "TEXT",
511
+ "goal": "Validation équipe et projections chiffrées",
512
  "success": {
513
  "mustInclude": [
514
  {
515
+ "id": "EQUIPE",
516
+ "desc": "Composition humaine et rôles",
517
+ "weight": 4
518
  },
519
  {
520
+ "id": "CA_CIBLE",
521
+ "desc": "Objectif financier 3 ans",
522
+ "weight": 4
523
  },
524
  {
525
+ "id": "CIBLE_VOLUME",
526
+ "desc": "Nombre de clients/boutiques",
527
+ "weight": 2
528
  }
529
  ],
530
  "threshold": {
531
+ "minScore": 7,
532
  "minMustPass": 2
533
  }
534
  },
 
538
  }
539
  },
540
  "badges": [
541
+ "VISION"
542
  ],
543
+ "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes11_plan.png"
 
 
544
  },
545
  {
546
  "dayNumber": 12,
547
+ "title": "Le Pitch Final & The Ask",
548
+ "lessonText": "C'est le grand jour ! On boucle tout.\n\nTon pitch doit maintenant inclure 'La Demande' (The Ask).\n✅ De quoi as-tu besoin PRECISÉMENT ? (Montant en FCFA, matériel spécifique).\n✅ Pourquoi cet investissement va faire exploser tes ventes ?\n\nAprès ce dernier audio, je génère ton Pitch Deck complet.",
549
  "exerciseType": "AUDIO",
550
+ "exercisePrompt": "Envoie ton pitch final (45s) incluant : ton nom, ton offre, et ton 'Besoin' précis (montant ou matériel) pour scaler.",
551
  "exerciseCriteria": {
552
  "version": "1.0",
553
  "type": "AUDIO",
554
+ "goal": "Pitch Investisseur Final (The Ask)",
555
  "success": {
556
  "mustInclude": [
557
  {
558
+ "id": "OFFRE",
559
+ "desc": "Produit/Service",
560
+ "weight": 3
561
  },
562
  {
563
+ "id": "THE_ASK",
564
+ "desc": "Montant ou matériel précis",
565
  "weight": 5
566
+ },
567
+ {
568
+ "id": "IMPACT",
569
+ "desc": "Effet de levier de l'investissement",
570
+ "weight": 2
571
  }
572
  ],
573
  "threshold": {
574
+ "minScore": 7,
575
+ "minMustPass": 2
576
  }
577
  },
578
  "evaluation": {
packages/database/content/tracks/T1-WO.json CHANGED
@@ -335,86 +335,116 @@
335
  },
336
  {
337
  "dayNumber": 10,
338
- "title": "Li ngay faye",
339
- "lessonText": "Jaay baax na, waaye gagné moo gën. Ñata ngay faye ngir uut benn produit (transport bi ci biir) ?",
340
- "exercisePrompt": "Bindal ma estimé bi ngay faye ngir benn produit :",
341
  "exerciseType": "TEXT",
 
342
  "exerciseCriteria": {
343
  "version": "1.0",
344
  "type": "TEXT",
345
- "goal": "Prise de conscience des coûts (WOLOF)",
346
  "success": {
347
  "mustInclude": [
348
  {
349
- "id": "COST",
350
- "desc": "prix walla lan lay faye",
351
  "weight": 5
 
 
 
 
 
 
 
 
 
 
352
  }
353
  ],
354
  "threshold": {
355
- "minScore": 5,
356
- "minMustPass": 1
357
  }
 
 
 
 
358
  }
359
  },
360
- "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes10_objections.png",
361
- "videoUrl": "https://r2.xamle.sn/videos/v7_3.mp4",
362
- "videoCaption": "📢 Feedback : Écouter pour s’améliorer."
 
363
  },
364
  {
365
  "dayNumber": 11,
366
- "title": "Kooluté",
367
- "lessonText": "Ci businessu Senegaal, woolu mooy lépp. Naka ngay def ngir kiliifa bi dëgërël sa wax ?",
368
- "exercisePrompt": "Lan ngay def ngir ñu gën laa woolu ?",
369
  "exerciseType": "TEXT",
 
370
  "exerciseCriteria": {
371
  "version": "1.0",
372
  "type": "TEXT",
373
- "goal": "Argument de confiance (WOLOF)",
374
  "success": {
375
  "mustInclude": [
376
  {
377
- "id": "TRUST",
378
- "desc": "garantie walla Wollo",
379
- "weight": 5
 
 
 
 
 
 
 
 
 
 
380
  }
381
  ],
382
  "threshold": {
383
- "minScore": 5,
384
- "minMustPass": 1
385
  }
 
 
 
 
386
  }
387
  },
388
- "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes11_plan.png",
389
- "videoUrl": "https://r2.xamle.sn/videos/v1_2.mp4",
390
- "videoCaption": "📈 Discipline : La régularité est la clé."
 
391
  },
392
  {
393
  "dayNumber": 12,
394
- "title": "Mini Pitch",
395
- "lessonText": "Félicitations ! Mat nga Niveau 1. Léegi, wone ko. Waxal sa pitch bi ci 30 seconde.",
396
- "exercisePrompt": "Yónnee ma audio : Maangi tudd [Nom], damay jaay [Lan] ñi [Ñan] ngir dakkal [Jafe-jafe].",
397
  "exerciseType": "AUDIO",
 
398
  "exerciseCriteria": {
399
  "version": "1.0",
400
  "type": "AUDIO",
401
- "goal": "Pitch final Niveau 1 (WOLOF)",
402
  "success": {
403
  "mustInclude": [
404
  {
405
- "id": "WHO",
406
- "desc": "ñan",
407
  "weight": 3
408
  },
409
  {
410
- "id": "WHAT",
411
- "desc": "lan",
412
- "weight": 3
413
  },
414
  {
415
- "id": "WHY",
416
- "desc": "lu tax",
417
- "weight": 4
418
  }
419
  ],
420
  "threshold": {
@@ -428,7 +458,7 @@
428
  }
429
  },
430
  "badges": [
431
- "B_MODULE_1_OK"
432
  ],
433
  "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes12_success.png"
434
  }
 
335
  },
336
  {
337
  "dayNumber": 10,
338
+ "title": "Ñi lay daxé (3 Concurrence)",
339
+ "lessonText": "Ngir gagné, faw nga xam ñan ñooy sàcc sa kiliifa.\n\nTey : ràññeel 3 nit walla 3 boutique yu lay daxé ci sa quartier (ñi jaay lii ngay jaay).\n✅ Ñan lañu ?\n✅ Ñata lañuy jaayé li nga jaayé ?\n✅ Lu tax kiliifa bi mën na dem ci ñom te bàyyi la ?",
 
340
  "exerciseType": "TEXT",
341
+ "exercisePrompt": "Bindal ma turu 3 kiliifa yu lay daxé, seen prix, te wax ma lan lañu gëné (force).",
342
  "exerciseCriteria": {
343
  "version": "1.0",
344
  "type": "TEXT",
345
+ "goal": "Saytu concurrence bu leer (WOLOF)",
346
  "success": {
347
  "mustInclude": [
348
  {
349
+ "id": "CONCURRENTS",
350
+ "desc": "tur 3 concurrents",
351
  "weight": 5
352
+ },
353
+ {
354
+ "id": "PRIX",
355
+ "desc": "prix ñi lay daxé",
356
+ "weight": 3
357
+ },
358
+ {
359
+ "id": "FORCE",
360
+ "desc": "li ñu gëné",
361
+ "weight": 2
362
  }
363
  ],
364
  "threshold": {
365
+ "minScore": 6,
366
+ "minMustPass": 2
367
  }
368
+ },
369
+ "evaluation": {
370
+ "tone": "coach_enthusiastic",
371
+ "format": "3_lines"
372
  }
373
  },
374
+ "badges": [
375
+ "STRATÉGIE"
376
+ ],
377
+ "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes10_objections.png"
378
  },
379
  {
380
  "dayNumber": 11,
381
+ "title": "Sa Doolé ak sa Bët (Vision)",
382
+ "lessonText": "Ku bëgg jàppale sa business, dax laa woolu ak sa Chiffre d'Affaires ëllëg.\n\nTey : \n1) Ñan lañu lay jàppale tey (rôles) ?\n2) Ñata nga bëgg liggéey ci xaalis (CA) ci 3 ans ?\n3) Ñata boutique walla kiliifa nga bëgg am ci 2029 ?",
 
383
  "exerciseType": "TEXT",
384
+ "exercisePrompt": "Wax ma ñan ñooy sa équipe te wax ma sa objectif xaalis (CA) ak kiliifa ci 2029.",
385
  "exerciseCriteria": {
386
  "version": "1.0",
387
  "type": "TEXT",
388
+ "goal": "Équipe ak Vision chiffrée (WOLOF)",
389
  "success": {
390
  "mustInclude": [
391
  {
392
+ "id": "EQUIPE",
393
+ "desc": "ñi lay liggéeyal",
394
+ "weight": 4
395
+ },
396
+ {
397
+ "id": "CA_VISION",
398
+ "desc": "objectif xaalis 3 ans",
399
+ "weight": 4
400
+ },
401
+ {
402
+ "id": "VOLUME",
403
+ "desc": "boutique walla kiliifa",
404
+ "weight": 2
405
  }
406
  ],
407
  "threshold": {
408
+ "minScore": 7,
409
+ "minMustPass": 2
410
  }
411
+ },
412
+ "evaluation": {
413
+ "tone": "coach_enthusiastic",
414
+ "format": "3_lines"
415
  }
416
  },
417
+ "badges": [
418
+ "VISION"
419
+ ],
420
+ "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes11_plan.png"
421
  },
422
  {
423
  "dayNumber": 12,
424
+ "title": "Pitch bi ak 'The Ask'",
425
+ "lessonText": "Félicitations ! Teey la bés bi. \n\nSa wax faw mu leer : \n✅ Lan nga soxla PRECISÉMENT ngir yokk sa business tey ? (Montant xaalis walla masin...)\n✅ Lu tax loolu lu am solo la pour sa business ?\n\nSu ma yónnee sa audio, ma defal la sa 'Pitch Deck' bu leer.",
 
426
  "exerciseType": "AUDIO",
427
+ "exercisePrompt": "Yónnee ma sa pitch final (45s) : sa tur, sa offre, te surtout : sa 'Besoin' bu leer (montant walla matériel) pour scaler.",
428
  "exerciseCriteria": {
429
  "version": "1.0",
430
  "type": "AUDIO",
431
+ "goal": "Pitch Final ak The Ask (WOLOF)",
432
  "success": {
433
  "mustInclude": [
434
  {
435
+ "id": "OFFRE",
436
+ "desc": "sa pexe",
437
  "weight": 3
438
  },
439
  {
440
+ "id": "THE_ASK",
441
+ "desc": "montant walla masin bu leer",
442
+ "weight": 5
443
  },
444
  {
445
+ "id": "IMPACT",
446
+ "desc": "njariñu investissement bi",
447
+ "weight": 2
448
  }
449
  ],
450
  "threshold": {
 
458
  }
459
  },
460
  "badges": [
461
+ "FINISHER"
462
  ],
463
  "imageUrl": "https://pub-e770286d75114b3691f9142d5e451a41.r2.dev/images/t1/bes12_success.png"
464
  }
packages/database/prisma/schema.prisma CHANGED
@@ -41,6 +41,7 @@ model BusinessProfile {
41
  mainProblem String?
42
  offerSimple String?
43
  promise String?
 
44
  lastUpdatedFromDay Int @default(0)
45
  createdAt DateTime @default(now())
46
  updatedAt DateTime @updatedAt
@@ -105,6 +106,12 @@ model UserProgress {
105
  adminTranscription String?
106
  overrideAudioUrl String?
107
  reviewedBy String?
 
 
 
 
 
 
108
  createdAt DateTime @default(now())
109
  updatedAt DateTime @updatedAt
110
 
 
41
  mainProblem String?
42
  offerSimple String?
43
  promise String?
44
+ marketData Json? // Stored Google Search results for TAM/SAM/SOM & Competition
45
  lastUpdatedFromDay Int @default(0)
46
  createdAt DateTime @default(now())
47
  updatedAt DateTime @updatedAt
 
106
  adminTranscription String?
107
  overrideAudioUrl String?
108
  reviewedBy String?
109
+
110
+ // Enriched Data for Pitch Deck (Sprint 34)
111
+ marketData Json? // Stored Google Search results (TAM/SAM/SOM)
112
+ competitorList Json? // List of rivals found or declared
113
+ financialProjections Json? // 3-year growth data
114
+ fundingAsk String? // Amount and purpose
115
  createdAt DateTime @default(now())
116
  updatedAt DateTime @updatedAt
117
 
pnpm-lock.yaml CHANGED
@@ -90,6 +90,9 @@ importers:
90
  bullmq:
91
  specifier: ^5.1.0
92
  version: 5.69.3
 
 
 
93
  dotenv:
94
  specifier: ^16.4.7
95
  version: 16.6.1
@@ -124,6 +127,9 @@ importers:
124
  '@repo/tsconfig':
125
  specifier: workspace:*
126
  version: link:../../packages/tsconfig
 
 
 
127
  '@types/dotenv':
128
  specifier: ^8.2.3
129
  version: 8.2.3
@@ -1404,6 +1410,10 @@ packages:
1404
  '@types/babel__traverse@7.28.0':
1405
  resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
1406
 
 
 
 
 
1407
  '@types/dotenv@8.2.3':
1408
  resolution: {integrity: sha512-g2FXjlDX/cYuc5CiQvyU/6kkbP1JtmGzh0obW50zD7OKeILVL0NSpPWLXVfqoAGQjom2/SLLx9zHq0KXvD6mbw==}
1409
  deprecated: This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed.
@@ -1736,6 +1746,10 @@ packages:
1736
  didyoumean@1.2.2:
1737
  resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
1738
 
 
 
 
 
1739
  dlv@1.1.3:
1740
  resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
1741
 
@@ -4224,6 +4238,10 @@ snapshots:
4224
  dependencies:
4225
  '@babel/types': 7.29.0
4226
 
 
 
 
 
4227
  '@types/dotenv@8.2.3':
4228
  dependencies:
4229
  dotenv: 16.6.1
@@ -4552,6 +4570,8 @@ snapshots:
4552
 
4553
  didyoumean@1.2.2: {}
4554
 
 
 
4555
  dlv@1.1.3: {}
4556
 
4557
  dotenv@16.6.1: {}
 
90
  bullmq:
91
  specifier: ^5.1.0
92
  version: 5.69.3
93
+ diff:
94
+ specifier: ^8.0.3
95
+ version: 8.0.3
96
  dotenv:
97
  specifier: ^16.4.7
98
  version: 16.6.1
 
127
  '@repo/tsconfig':
128
  specifier: workspace:*
129
  version: link:../../packages/tsconfig
130
+ '@types/diff':
131
+ specifier: ^8.0.0
132
+ version: 8.0.0
133
  '@types/dotenv':
134
  specifier: ^8.2.3
135
  version: 8.2.3
 
1410
  '@types/babel__traverse@7.28.0':
1411
  resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
1412
 
1413
+ '@types/diff@8.0.0':
1414
+ resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==}
1415
+ deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed.
1416
+
1417
  '@types/dotenv@8.2.3':
1418
  resolution: {integrity: sha512-g2FXjlDX/cYuc5CiQvyU/6kkbP1JtmGzh0obW50zD7OKeILVL0NSpPWLXVfqoAGQjom2/SLLx9zHq0KXvD6mbw==}
1419
  deprecated: This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed.
 
1746
  didyoumean@1.2.2:
1747
  resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
1748
 
1749
+ diff@8.0.3:
1750
+ resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
1751
+ engines: {node: '>=0.3.1'}
1752
+
1753
  dlv@1.1.3:
1754
  resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
1755
 
 
4238
  dependencies:
4239
  '@babel/types': 7.29.0
4240
 
4241
+ '@types/diff@8.0.0':
4242
+ dependencies:
4243
+ diff: 8.0.3
4244
+
4245
  '@types/dotenv@8.2.3':
4246
  dependencies:
4247
  dotenv: 16.6.1
 
4570
 
4571
  didyoumean@1.2.2: {}
4572
 
4573
+ diff@8.0.3: {}
4574
+
4575
  dlv@1.1.3: {}
4576
 
4577
  dotenv@16.6.1: {}