CognxSafeTrack commited on
Commit
7b4936e
·
1 Parent(s): 6da0dd6

feat(ai): team building visual experience and persistence

Browse files
apps/api/src/routes/ai.ts CHANGED
@@ -172,18 +172,26 @@ export async function aiRoutes(fastify: FastifyInstance) {
172
 
173
  const { audioBase64, mimeType, phone } = bodySchema.parse(request.body);
174
 
175
- const ext = mimeType.includes('mp4') ? 'mp4' : mimeType.includes('mpeg') ? 'mp3' : 'ogg';
176
- const filename = `audio/${phone}-${Date.now()}.${ext}`;
 
 
 
 
 
 
 
 
 
177
  const buffer = Buffer.from(audioBase64, 'base64');
178
 
179
  try {
180
- // Ensure the /tmp/audio directory exists because uploadFile falls back to local storage
181
- // This prevents ENOENT errors if the container restarted
182
  const { mkdir } = require('fs/promises');
183
- await mkdir('/tmp/audio', { recursive: true }).catch(() => { });
184
 
185
  const url = await uploadFile(buffer, filename, mimeType);
186
- console.log(`[AI] ✅ Audio stored: ${url}`);
187
  return { success: true, url };
188
  } catch (err: unknown) {
189
  console.error('[AI] store-audio failed:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)));
 
172
 
173
  const { audioBase64, mimeType, phone } = bodySchema.parse(request.body);
174
 
175
+ let ext = 'ogg';
176
+ let folder = 'audio';
177
+ if (mimeType.includes('image/')) {
178
+ ext = mimeType.split('/')[1] || 'jpeg';
179
+ folder = 'images';
180
+ } else if (mimeType.includes('mp4')) {
181
+ ext = 'mp4';
182
+ } else if (mimeType.includes('mpeg')) {
183
+ ext = 'mp3';
184
+ }
185
+ const filename = `${folder}/${phone}-${Date.now()}.${ext}`;
186
  const buffer = Buffer.from(audioBase64, 'base64');
187
 
188
  try {
189
+ // Ensure the /tmp folders exist because uploadFile falls back to local storage
 
190
  const { mkdir } = require('fs/promises');
191
+ await mkdir(`/tmp/${folder}`, { recursive: true }).catch(() => { });
192
 
193
  const url = await uploadFile(buffer, filename, mimeType);
194
+ console.log(`[AI] ✅ Media stored: ${url}`);
195
  return { success: true, url };
196
  } catch (err: unknown) {
197
  console.error('[AI] store-audio failed:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)));
apps/api/src/services/ai/index.ts CHANGED
@@ -103,6 +103,10 @@ class AIService {
103
  ? `\n🌐 DONNÉES DE MARCHÉ (RECHERCHE WEB) :\n${JSON.stringify(businessProfile.marketData, null, 2)}\n`
104
  : '';
105
 
 
 
 
 
106
  const prompt = `
107
  Tu es un expert en Pitch Decks internationaux (VC-ready).
108
  Génère un deck de 13 slides STRICTEMENT basé sur la structure suivante pour ce business :
@@ -128,6 +132,7 @@ class AIService {
128
  USER INPUT:
129
  ${userContext}
130
  ${marketDataInjected}
 
131
 
132
  STRICTES CONTRAINTES DE QUALITÉ "GENSPARK-STANDARD" :
133
  - 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.
@@ -135,6 +140,7 @@ class AIService {
135
  - 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).
136
  - 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').
137
  - Slide 5 (Marché) : visualType = 'PIE_CHART', visualData = { labels: ["TAM (Total)", "SAM (Cible)", "SOM (Capturable)"], values: [1000000, 500000, 50000] }. 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.
 
138
  - Slide 11 (Finances) : visualType = 'BAR_CHART', visualData = { labels: ["Année 1", "Année 2", "Année 3", "Année 4", "Année 5"], values: [100, 200, 400, 800, 1600] }. Explique la LOGIQUE de croissance financière de manière narrative.
139
  - STRICTEMENT 3 à 4 blocs de texte par slide. Aucun bloc de moins de 15 mots.
140
  - DÉTANCHÉITÉ LINGUISTIQUE :
@@ -247,6 +253,26 @@ class AIService {
247
  IMPORTANT : Interdiction de poser une question à la fin. Exige seulement à l'utilisateur de taper "SUITE" pour clôturer cette étape.
248
  MET isForcedClosure À TRUE DANS TA RÉPONSE JSON.
249
  `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  } else {
251
  actionPrompt = `
252
  🔄 ANALYSE ITÉRATIVE "DEEP DIVE" (Tour ${iterationCount}/3) 🔄
 
103
  ? `\n🌐 DONNÉES DE MARCHÉ (RECHERCHE WEB) :\n${JSON.stringify(businessProfile.marketData, null, 2)}\n`
104
  : '';
105
 
106
+ const teamDataInjected = businessProfile?.teamMembers && Array.isArray(businessProfile.teamMembers) && businessProfile.teamMembers.length > 0
107
+ ? `\n👥 MEMBRES DE L'ÉQUIPE (PHOTOS/BDD) :\n${JSON.stringify(businessProfile.teamMembers, null, 2)}\n`
108
+ : '';
109
+
110
  const prompt = `
111
  Tu es un expert en Pitch Decks internationaux (VC-ready).
112
  Génère un deck de 13 slides STRICTEMENT basé sur la structure suivante pour ce business :
 
132
  USER INPUT:
133
  ${userContext}
134
  ${marketDataInjected}
135
+ ${teamDataInjected}
136
 
137
  STRICTES CONTRAINTES DE QUALITÉ "GENSPARK-STANDARD" :
138
  - 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.
 
140
  - 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).
141
  - 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').
142
  - Slide 5 (Marché) : visualType = 'PIE_CHART', visualData = { labels: ["TAM (Total)", "SAM (Cible)", "SOM (Capturable)"], values: [1000000, 500000, 50000] }. 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.
143
+ - Slide Équipe (10) : Si des données d'équipe existent, définis IMPERATIVEMENT visualType = 'TEAM' et place le tableau EXACT JSON fourni dans MEMBRES DE L'ÉQUIPE dans visualData.
144
  - Slide 11 (Finances) : visualType = 'BAR_CHART', visualData = { labels: ["Année 1", "Année 2", "Année 3", "Année 4", "Année 5"], values: [100, 200, 400, 800, 1600] }. Explique la LOGIQUE de croissance financière de manière narrative.
145
  - STRICTEMENT 3 à 4 blocs de texte par slide. Aucun bloc de moins de 15 mots.
146
  - DÉTANCHÉITÉ LINGUISTIQUE :
 
253
  IMPORTANT : Interdiction de poser une question à la fin. Exige seulement à l'utilisateur de taper "SUITE" pour clôturer cette étape.
254
  MET isForcedClosure À TRUE DANS TA RÉPONSE JSON.
255
  `;
256
+ } else if (dayNumber === 11) {
257
+ actionPrompt = `
258
+ 🔄 TEAM BUILDING "DEEP DIVE" (Tour ${iterationCount}/3) 🔄
259
+ L'utilisateur a choisi d'approfondir l'équipe (Jour 11).
260
+
261
+ ${iterationCount === 1 && !imageUrl ? `Dis STRICTEMENT : "Super ! Envoie-moi la photo de ton premier membre d'équipe avec son nom et son rôle." (Ne dis rien de plus).` : ''}
262
+
263
+ ${imageUrl ? `
264
+ 📸 ANALYSE VISUELLE (MULTIMODAL) :
265
+ - L'utilisateur a envoyé une photo pour son équipe.
266
+ - VÉRIFICATION OBLIGATOIRE : Tu dois d'abord valider que la photo envoyée ressemble bien à une personne (portrait humain) ou un logo d'équipe, et non à un objet aléatoire. Si ce n'est pas le cas, excuse-toi poliment et demande une vraie photo de la personne.
267
+ - Si la photo est valide, EXTRAIS le nom de la personne et son rôle à partir de son texte ("${userInput}").
268
+ - Mets la photoUrl (qui est "${imageUrl}") et les infos dans ta structure JSON \`teamMembers\`.
269
+ - Dis-lui EXACTEMENT : "Bien reçu la photo de [Nom], je l'ajoute en tant que [Rôle] sur ta Slide Équipe." (Suivi de : "En as-tu un autre ou tapes-tu 2️⃣ SUITE pour passer aux chiffres ?")
270
+ ` : (!imageUrl && iterationCount > 1) ? `
271
+ - L'utilisateur vient de répondre sans photo supplémentaire ou pour passer à la suite. Clôture en douceur ou prends ses remarques sur l'équipe en compte.
272
+ ` : ''}
273
+
274
+ ATTENTION : Ne pose AUCUNE question qui relève d'une leçon suivante (Reste sur l'Équipe).
275
+ `;
276
  } else {
277
  actionPrompt = `
278
  🔄 ANALYSE ITÉRATIVE "DEEP DIVE" (Tour ${iterationCount}/3) 🔄
apps/api/src/services/ai/types.ts CHANGED
@@ -36,7 +36,7 @@ export const SlideSchema = z.object({
36
  title: z.string().describe("Slide title (max 5 words)"),
37
  content: z.array(z.string()).max(4).describe("Narrative storytelling blocks for the slide (15-25 words each). STRICTLY MAX 4 BLOCKS."),
38
  notes: z.string().describe("Speaker notes (use empty string if none)"),
39
- visualType: z.enum(["NONE", "PIE_CHART", "BAR_CHART", "IMAGE", "ICON"]).nullable().optional().describe("Type of visual to add on the right side"),
40
  visualData: z.any().nullable().optional().describe("Data for the chart (if any) or image prompt")
41
  });
42
  export type SlideData = z.infer<typeof SlideSchema>;
@@ -67,6 +67,12 @@ export const FeedbackSchema = z.object({
67
  revenueY3: z.string().nullable().optional(),
68
  growthRate: z.string().nullable().optional()
69
  }).nullable().optional().describe("3-year growth metrics (Day 11)"),
 
 
 
 
 
 
70
  fundingAsk: z.string().nullable().optional().describe("The Ask: amount and purpose (Day 12)"),
71
  aiSource: z.string().nullable().optional().describe("The AI provider used (GEMINI, OPENAI, MOCK)")
72
  });
 
36
  title: z.string().describe("Slide title (max 5 words)"),
37
  content: z.array(z.string()).max(4).describe("Narrative storytelling blocks for the slide (15-25 words each). STRICTLY MAX 4 BLOCKS."),
38
  notes: z.string().describe("Speaker notes (use empty string if none)"),
39
+ visualType: z.enum(["NONE", "PIE_CHART", "BAR_CHART", "IMAGE", "ICON", "TEAM"]).nullable().optional().describe("Type of visual to add on the right side"),
40
  visualData: z.any().nullable().optional().describe("Data for the chart (if any) or image prompt")
41
  });
42
  export type SlideData = z.infer<typeof SlideSchema>;
 
67
  revenueY3: z.string().nullable().optional(),
68
  growthRate: z.string().nullable().optional()
69
  }).nullable().optional().describe("3-year growth metrics (Day 11)"),
70
+ teamMembers: z.array(z.object({
71
+ name: z.string(),
72
+ role: z.string(),
73
+ bio: z.string().optional(),
74
+ photoUrl: z.string().optional()
75
+ })).nullable().optional().describe("List of newly identified team members (Day 11) with their roles and photos"),
76
  fundingAsk: z.string().nullable().optional().describe("The Ask: amount and purpose (Day 12)"),
77
  aiSource: z.string().nullable().optional().describe("The AI provider used (GEMINI, OPENAI, MOCK)")
78
  });
apps/api/src/services/renderers/pptx-renderer.ts CHANGED
@@ -89,6 +89,32 @@ export class PptxDeckRenderer implements DocumentRenderer<PitchDeckData> {
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
  }
 
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
+ else if (slideData.visualType === 'TEAM' && Array.isArray(vizData)) {
93
+ const maxMembers = Math.min(vizData.length, 4);
94
+ for (let i = 0; i < maxMembers; i++) {
95
+ const member = vizData[i];
96
+ const row = Math.floor(i / 2);
97
+ const col = i % 2;
98
+ const xP = 5.2 + (col * 2.3);
99
+ const yP = 1.0 + (row * 2.2);
100
+
101
+ if (member.photoUrl && typeof member.photoUrl === 'string' && member.photoUrl.startsWith('http')) {
102
+ slide.addImage({ path: member.photoUrl, x: xP, y: yP, w: 1.8, h: 1.8, sizing: { type: 'cover', w: 1.8, h: 1.8 } });
103
+ } else {
104
+ // Default icon or placeholder shape
105
+ slide.addShape(pres.ShapeType.ellipse, { x: xP + 0.15, y: yP + 0.15, w: 1.5, h: 1.5, fill: { color: 'E2E8F0' } });
106
+ }
107
+
108
+ slide.addText(member.name?.toUpperCase() || "MEMBRE", {
109
+ x: xP, y: yP + 1.85, w: 1.8, h: 0.3,
110
+ fontSize: 12, color: '1B3A57', bold: true, align: 'center', fontFace: 'Montserrat'
111
+ });
112
+ slide.addText(member.role || "Rôle", {
113
+ x: xP, y: yP + 2.15, w: 1.8, h: 0.2,
114
+ fontSize: 10, color: '1C7C54', align: 'center', fontFace: 'Inter'
115
+ });
116
+ }
117
+ }
118
  } catch (vErr) {
119
  console.warn(`[RENDERER] Failed to add visual to slide ${index + 1}:`, vErr);
120
  }
apps/whatsapp-worker/src/index.ts CHANGED
@@ -257,12 +257,17 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
257
  });
258
 
259
  // 🚨 Store Strategy Data in BusinessProfile
260
- if (feedbackData?.searchResults || (feedbackData as any)?.competitorList || (feedbackData as any)?.financialProjections || (feedbackData as any)?.fundingAsk) {
261
  const updatePayload: any = { lastUpdatedFromDay: currentDay };
262
  if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
263
  if ((feedbackData as any)?.competitorList) updatePayload.competitorList = (feedbackData as any).competitorList;
264
  if ((feedbackData as any)?.financialProjections) updatePayload.financialProjections = (feedbackData as any).financialProjections;
265
  if ((feedbackData as any)?.fundingAsk) updatePayload.fundingAsk = (feedbackData as any).fundingAsk;
 
 
 
 
 
266
 
267
  await (prisma as any).businessProfile.upsert({
268
  where: { userId },
@@ -292,12 +297,17 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
292
  });
293
 
294
  // 🚨 Store Strategy Data in BusinessProfile
295
- if (feedbackData?.searchResults || (feedbackData as any)?.competitorList || (feedbackData as any)?.financialProjections || (feedbackData as any)?.fundingAsk) {
296
  const updatePayload: any = { lastUpdatedFromDay: currentDay };
297
  if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
298
  if ((feedbackData as any)?.competitorList) updatePayload.competitorList = (feedbackData as any).competitorList;
299
  if ((feedbackData as any)?.financialProjections) updatePayload.financialProjections = (feedbackData as any).financialProjections;
300
  if ((feedbackData as any)?.fundingAsk) updatePayload.fundingAsk = (feedbackData as any).fundingAsk;
 
 
 
 
 
301
 
302
  await (prisma as any).businessProfile.upsert({
303
  where: { userId },
 
257
  });
258
 
259
  // 🚨 Store Strategy Data in BusinessProfile
260
+ if (feedbackData?.searchResults || (feedbackData as any)?.competitorList || (feedbackData as any)?.financialProjections || (feedbackData as any)?.fundingAsk || (feedbackData as any)?.teamMembers) {
261
  const updatePayload: any = { lastUpdatedFromDay: currentDay };
262
  if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
263
  if ((feedbackData as any)?.competitorList) updatePayload.competitorList = (feedbackData as any).competitorList;
264
  if ((feedbackData as any)?.financialProjections) updatePayload.financialProjections = (feedbackData as any).financialProjections;
265
  if ((feedbackData as any)?.fundingAsk) updatePayload.fundingAsk = (feedbackData as any).fundingAsk;
266
+ if ((feedbackData as any)?.teamMembers && Array.isArray((feedbackData as any).teamMembers)) {
267
+ const existingProfile = await (prisma as any).businessProfile.findUnique({ where: { userId } });
268
+ const existingTeam = Array.isArray(existingProfile?.teamMembers) ? existingProfile.teamMembers : [];
269
+ updatePayload.teamMembers = [...existingTeam, ...(feedbackData as any).teamMembers];
270
+ }
271
 
272
  await (prisma as any).businessProfile.upsert({
273
  where: { userId },
 
297
  });
298
 
299
  // 🚨 Store Strategy Data in BusinessProfile
300
+ if (feedbackData?.searchResults || (feedbackData as any)?.competitorList || (feedbackData as any)?.financialProjections || (feedbackData as any)?.fundingAsk || (feedbackData as any)?.teamMembers) {
301
  const updatePayload: any = { lastUpdatedFromDay: currentDay };
302
  if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
303
  if ((feedbackData as any)?.competitorList) updatePayload.competitorList = (feedbackData as any).competitorList;
304
  if ((feedbackData as any)?.financialProjections) updatePayload.financialProjections = (feedbackData as any).financialProjections;
305
  if ((feedbackData as any)?.fundingAsk) updatePayload.fundingAsk = (feedbackData as any).fundingAsk;
306
+ if ((feedbackData as any)?.teamMembers && Array.isArray((feedbackData as any).teamMembers)) {
307
+ const existingProfile = await (prisma as any).businessProfile.findUnique({ where: { userId } });
308
+ const existingTeam = Array.isArray(existingProfile?.teamMembers) ? existingProfile.teamMembers : [];
309
+ updatePayload.teamMembers = [...existingTeam, ...(feedbackData as any).teamMembers];
310
+ }
311
 
312
  await (prisma as any).businessProfile.upsert({
313
  where: { userId },
packages/database/prisma/schema.prisma CHANGED
@@ -44,6 +44,7 @@ model BusinessProfile {
44
  marketData Json? // Stored Google Search results for TAM/SAM/SOM & Competition
45
  competitorList Json? // List of rivals found or declared
46
  financialProjections Json? // 3-year growth data
 
47
  fundingAsk String? // Amount and purpose
48
  lastUpdatedFromDay Int @default(0)
49
  createdAt DateTime @default(now())
 
44
  marketData Json? // Stored Google Search results for TAM/SAM/SOM & Competition
45
  competitorList Json? // List of rivals found or declared
46
  financialProjections Json? // 3-year growth data
47
+ teamMembers Json? // Array of { name, role, bio, photoUrl }
48
  fundingAsk String? // Amount and purpose
49
  lastUpdatedFromDay Int @default(0)
50
  createdAt DateTime @default(now())