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 +169 -5
- apps/api/output_log.txt +20 -0
- apps/api/package.json +2 -0
- apps/api/src/index.ts +7 -7
- apps/api/src/routes/admin.ts +136 -0
- apps/api/src/routes/ai.ts +38 -7
- apps/api/src/scripts/audit-inventory.ts +100 -0
- apps/api/src/scripts/elite-journey-simulation.ts +152 -0
- apps/api/src/services/ai/index.ts +113 -67
- apps/api/src/services/ai/mock-provider.ts +112 -26
- apps/api/src/services/ai/openai-provider.ts +18 -0
- apps/api/src/services/ai/search.ts +56 -0
- apps/api/src/services/ai/types.ts +31 -7
- apps/api/src/services/renderers/pdf-renderer.ts +32 -19
- apps/api/src/services/renderers/pptx-renderer.ts +65 -23
- apps/web/src/App.tsx +3 -0
- apps/web/src/PrivacyPolicy.tsx +80 -0
- apps/whatsapp-worker/src/index.ts +43 -11
- packages/database/content/tracks/T1-FR.json +50 -44
- packages/database/content/tracks/T1-WO.json +66 -36
- packages/database/prisma/schema.prisma +7 -0
- pnpm-lock.yaml +20 -0
apps/admin/src/pages/TrainingLab.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
import
|
| 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=
|
| 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=
|
| 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é —
|
| 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 :
|
| 135 |
|
| 136 |
<h2>1. Qui sommes-nous ?</h2>
|
| 137 |
-
<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:
|
| 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:
|
| 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:
|
| 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 |
-
|
|
|
|
| 27 |
|
| 28 |
USER INPUT:
|
| 29 |
${userContext}
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 43 |
-
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
USER INPUT:
|
| 46 |
${userContext}
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
${
|
| 108 |
-
${
|
| 109 |
-
|
| 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 |
-
|
| 130 |
-
|
| 131 |
-
ACTION : Si isQualified=true, un défi actionnable immédiat. Si isQualified=false, guide précisément sur CE QUI MANQUE.
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
Utilisation du Glossaire Officiel : Utilise exclusivement les termes validés (ex: Ñàkk pour perte, Xaalis pour argent, Denc pour épargne).
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 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 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 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
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
// but for our specific use-case, hardcoding the two expected shapes is simpler and safer.
|
| 14 |
|
| 15 |
if (schema === OnePagerSchema) {
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
if (schema === PitchDeckSchema) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
const mockDeck = {
|
| 30 |
-
title: "
|
| 31 |
-
subtitle: "
|
| 32 |
slides: [
|
| 33 |
-
{ title: "
|
| 34 |
-
{ title: "
|
| 35 |
-
{ title: "
|
| 36 |
-
{ title: "
|
| 37 |
-
{ title: "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
]
|
| 39 |
};
|
| 40 |
return schema.parse(mockDeck) as any;
|
| 41 |
}
|
| 42 |
|
| 43 |
if (schema === PersonalizedLessonSchema) {
|
| 44 |
-
|
| 45 |
-
lessonText:
|
| 46 |
-
};
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.,
|
| 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(
|
| 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(
|
| 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:
|
| 28 |
padding-bottom: 20px;
|
| 29 |
border-bottom: 3px solid #F4A261;
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
h1 {
|
| 33 |
font-family: 'Montserrat', sans-serif;
|
| 34 |
-
color: #
|
| 35 |
-
font-size:
|
| 36 |
margin: 0 0 10px 0;
|
| 37 |
text-transform: uppercase;
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
.tagline {
|
| 41 |
-
color: #
|
| 42 |
-
font-size:
|
| 43 |
-
font-
|
| 44 |
margin: 0;
|
| 45 |
}
|
| 46 |
|
|
@@ -82,34 +92,37 @@ export class PdfOnePagerRenderer implements DocumentRenderer<OnePagerData> {
|
|
| 82 |
</head>
|
| 83 |
<body>
|
| 84 |
<header>
|
| 85 |
-
<
|
| 86 |
-
<
|
|
|
|
| 87 |
</header>
|
| 88 |
|
| 89 |
-
<div class="main-content">
|
| 90 |
<div class="section">
|
| 91 |
-
<h2>
|
| 92 |
-
<p>${data.problem}</p>
|
| 93 |
</div>
|
| 94 |
|
| 95 |
<div class="section">
|
| 96 |
-
<h2>
|
| 97 |
-
<p>${data.solution}</p>
|
| 98 |
</div>
|
| 99 |
|
| 100 |
<div class="section">
|
| 101 |
-
<h2>
|
| 102 |
-
<p>${data.targetAudience}</p>
|
| 103 |
</div>
|
| 104 |
|
| 105 |
<div class="section">
|
| 106 |
-
<h2>
|
| 107 |
-
<p>${data.businessModel}</p>
|
| 108 |
</div>
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 10 |
-
//
|
| 11 |
pres.defineSlideMaster({
|
| 12 |
title: 'MASTER_SLIDE',
|
| 13 |
bkgd: 'FFFFFF',
|
| 14 |
objects: [
|
| 15 |
-
{ rect: { x: 0, y: 0, w: '100%', h:
|
| 16 |
-
{ rect: { x: 0, y:
|
|
|
|
| 17 |
]
|
| 18 |
});
|
| 19 |
|
| 20 |
-
// Title Slide
|
| 21 |
const titleSlide = pres.addSlide();
|
| 22 |
-
titleSlide.bkgd = '1B3A57';
|
| 23 |
titleSlide.addText(data.title.toUpperCase(), {
|
| 24 |
-
x:
|
| 25 |
-
fontSize:
|
| 26 |
});
|
| 27 |
if (data.subtitle) {
|
| 28 |
titleSlide.addText(data.subtitle, {
|
| 29 |
-
x:
|
| 30 |
-
fontSize:
|
| 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.
|
| 41 |
fontSize: 28, color: 'FFFFFF', bold: true, fontFace: 'Montserrat'
|
| 42 |
});
|
| 43 |
|
| 44 |
-
|
| 45 |
-
const
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
| 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:
|
| 140 |
-
create: { userId, ...
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
const userLangPrefix = isWolof ? "MBIR : " : "ACTIVITÉ : ";
|
| 681 |
|
| 682 |
// Localize context to avoid English bias in LLM
|
| 683 |
-
const userContext = `${userLangPrefix} ${
|
| 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 (${
|
| 693 |
const opRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/onepager`, {
|
| 694 |
method: 'POST',
|
| 695 |
headers: authHeaders,
|
| 696 |
-
body: JSON.stringify({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
});
|
| 698 |
const pdfData = await opRes.json() as any;
|
| 699 |
|
| 700 |
-
console.log(`[WORKER] Calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck (${
|
| 701 |
const deckRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck`, {
|
| 702 |
method: 'POST',
|
| 703 |
headers: authHeaders,
|
| 704 |
-
body: JSON.stringify({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 462 |
-
"lessonText": "
|
| 463 |
"exerciseType": "TEXT",
|
| 464 |
-
"exercisePrompt": "
|
| 465 |
"exerciseCriteria": {
|
| 466 |
"version": "1.0",
|
| 467 |
"type": "TEXT",
|
| 468 |
-
"goal": "
|
| 469 |
"success": {
|
| 470 |
"mustInclude": [
|
| 471 |
{
|
| 472 |
-
"id": "
|
| 473 |
-
"desc": "
|
| 474 |
"weight": 5
|
| 475 |
},
|
| 476 |
{
|
| 477 |
-
"id": "
|
| 478 |
-
"desc": "
|
| 479 |
-
"weight":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
}
|
| 481 |
],
|
| 482 |
"threshold": {
|
| 483 |
-
"minScore":
|
| 484 |
-
"minMustPass":
|
| 485 |
}
|
| 486 |
},
|
| 487 |
"evaluation": {
|
|
@@ -490,42 +495,40 @@
|
|
| 490 |
}
|
| 491 |
},
|
| 492 |
"badges": [
|
| 493 |
-
"
|
| 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": "
|
| 502 |
-
"lessonText": "
|
| 503 |
"exerciseType": "TEXT",
|
| 504 |
-
"exercisePrompt": "
|
| 505 |
"exerciseCriteria": {
|
| 506 |
"version": "1.0",
|
| 507 |
"type": "TEXT",
|
| 508 |
-
"goal": "
|
| 509 |
"success": {
|
| 510 |
"mustInclude": [
|
| 511 |
{
|
| 512 |
-
"id": "
|
| 513 |
-
"desc": "
|
| 514 |
-
"weight":
|
| 515 |
},
|
| 516 |
{
|
| 517 |
-
"id": "
|
| 518 |
-
"desc": "
|
| 519 |
-
"weight":
|
| 520 |
},
|
| 521 |
{
|
| 522 |
-
"id": "
|
| 523 |
-
"desc": "
|
| 524 |
-
"weight":
|
| 525 |
}
|
| 526 |
],
|
| 527 |
"threshold": {
|
| 528 |
-
"minScore":
|
| 529 |
"minMustPass": 2
|
| 530 |
}
|
| 531 |
},
|
|
@@ -535,38 +538,41 @@
|
|
| 535 |
}
|
| 536 |
},
|
| 537 |
"badges": [
|
| 538 |
-
"
|
| 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": "
|
| 547 |
-
"lessonText": "
|
| 548 |
"exerciseType": "AUDIO",
|
| 549 |
-
"exercisePrompt": "Envoie ton pitch final
|
| 550 |
"exerciseCriteria": {
|
| 551 |
"version": "1.0",
|
| 552 |
"type": "AUDIO",
|
| 553 |
-
"goal": "
|
| 554 |
"success": {
|
| 555 |
"mustInclude": [
|
| 556 |
{
|
| 557 |
-
"id": "
|
| 558 |
-
"desc": "
|
| 559 |
-
"weight":
|
| 560 |
},
|
| 561 |
{
|
| 562 |
-
"id": "
|
| 563 |
-
"desc": "
|
| 564 |
"weight": 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
}
|
| 566 |
],
|
| 567 |
"threshold": {
|
| 568 |
-
"minScore":
|
| 569 |
-
"minMustPass":
|
| 570 |
}
|
| 571 |
},
|
| 572 |
"evaluation": {
|
|
|
|
| 458 |
},
|
| 459 |
{
|
| 460 |
"dayNumber": 10,
|
| 461 |
+
"title": "Concurrence — Tes 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 & Vision — Tes 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": "
|
| 339 |
-
"lessonText": "
|
| 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": "
|
| 346 |
"success": {
|
| 347 |
"mustInclude": [
|
| 348 |
{
|
| 349 |
-
"id": "
|
| 350 |
-
"desc": "
|
| 351 |
"weight": 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
}
|
| 353 |
],
|
| 354 |
"threshold": {
|
| 355 |
-
"minScore":
|
| 356 |
-
"minMustPass":
|
| 357 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
}
|
| 359 |
},
|
| 360 |
-
"
|
| 361 |
-
|
| 362 |
-
|
|
|
|
| 363 |
},
|
| 364 |
{
|
| 365 |
"dayNumber": 11,
|
| 366 |
-
"title": "
|
| 367 |
-
"lessonText": "
|
| 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": "
|
| 374 |
"success": {
|
| 375 |
"mustInclude": [
|
| 376 |
{
|
| 377 |
-
"id": "
|
| 378 |
-
"desc": "
|
| 379 |
-
"weight":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
}
|
| 381 |
],
|
| 382 |
"threshold": {
|
| 383 |
-
"minScore":
|
| 384 |
-
"minMustPass":
|
| 385 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
}
|
| 387 |
},
|
| 388 |
-
"
|
| 389 |
-
|
| 390 |
-
|
|
|
|
| 391 |
},
|
| 392 |
{
|
| 393 |
"dayNumber": 12,
|
| 394 |
-
"title": "
|
| 395 |
-
"lessonText": "Félicitations !
|
| 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
|
| 402 |
"success": {
|
| 403 |
"mustInclude": [
|
| 404 |
{
|
| 405 |
-
"id": "
|
| 406 |
-
"desc": "
|
| 407 |
"weight": 3
|
| 408 |
},
|
| 409 |
{
|
| 410 |
-
"id": "
|
| 411 |
-
"desc": "
|
| 412 |
-
"weight":
|
| 413 |
},
|
| 414 |
{
|
| 415 |
-
"id": "
|
| 416 |
-
"desc": "
|
| 417 |
-
"weight":
|
| 418 |
}
|
| 419 |
],
|
| 420 |
"threshold": {
|
|
@@ -428,7 +458,7 @@
|
|
| 428 |
}
|
| 429 |
},
|
| 430 |
"badges": [
|
| 431 |
-
"
|
| 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: {}
|