CognxSafeTrack commited on
Commit
77c96f8
·
1 Parent(s): 2d900bc

feat: stabilize AI engine, harden Zod schemas, and implement UX guardrails

Browse files
apps/api/src/services/ai/gemini-provider.ts CHANGED
@@ -11,7 +11,7 @@ export class GeminiProvider implements LLMProvider {
11
  console.log('[GEMINI] Initializing Google AI SDK...');
12
  this.genAI = new GoogleGenerativeAI(apiKey);
13
  this.flashModel = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
14
- this.proModel = this.genAI.getGenerativeModel({ model: 'gemini-1.5-pro-latest' });
15
  }
16
 
17
  async generateStructuredData<T>(prompt: string, _schema: z.ZodSchema<T>): Promise<T> {
 
11
  console.log('[GEMINI] Initializing Google AI SDK...');
12
  this.genAI = new GoogleGenerativeAI(apiKey);
13
  this.flashModel = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
14
+ this.proModel = this.genAI.getGenerativeModel({ model: 'gemini-1.5-pro' });
15
  }
16
 
17
  async generateStructuredData<T>(prompt: string, _schema: z.ZodSchema<T>): Promise<T> {
apps/api/src/services/ai/index.ts CHANGED
@@ -165,6 +165,13 @@ class AIService {
165
  isDeepDive: boolean = false,
166
  iterationCount: number = 0
167
  ): Promise<FeedbackData & { searchResults?: any[] }> {
 
 
 
 
 
 
 
168
  const activityLabel = userActivity || businessProfile?.activityLabel || 'non précisé';
169
  const region = userRegion || businessProfile?.region || 'Sénégal';
170
  const customer = businessProfile?.mainCustomer || '';
@@ -244,6 +251,12 @@ class AIService {
244
  MISSIONS STRATÉGIQUES "SENIOR BUSINESS COACH" (CORRECTION RADICALE) :
245
  Tu es un consultant pragmatique, pas un professeur pointilleux. Rédige un feedback d'au minimum 15 lignes de haute densité.
246
 
 
 
 
 
 
 
247
  LES 3 PILIERS OBLIGATOIRES DU FEEDBACK :
248
  1. 🌟 Validation (Pilier 1) : Félicite et valide l'idée de l'utilisateur avec l'enthousiasme d'un investisseur.
249
  2. 🚀 Version Enrichie (Pilier 2) : Réécris sa phrase de manière exécutive en y intégrant OBLIGATOIREMENT des données chiffrées réelles trouvées dans ta recherche (ex: taille du marché ANSD, nombre de boutiques) et des termes stratégiques (supply chain, B2B, rétention).
@@ -255,6 +268,7 @@ class AIService {
255
  - Tu es là pour ENRICHIR sa vision, pas pour lui faire passer un interrogatoire. Ne pose AUCUNE question bloquante à la fin.
256
 
257
  INVITATION DEEP-DIVE OBLIGATOIRE :
 
258
  Termine EXACTEMENT ton pilier 3 par cette phrase selon la langue :
259
  (FR): "Si tu veux affiner ce point avec une donnée de ton propre terrain, tape 1️⃣ APPROFONDIR, sinon tape 2️⃣ SUITE."
260
  (WO): "Su nga bëggee yokk leneen ci li nga xam, bindal 1️⃣ APPROFONDIR, wala nga bind 2️⃣ SUITE."
 
165
  isDeepDive: boolean = false,
166
  iterationCount: number = 0
167
  ): Promise<FeedbackData & { searchResults?: any[] }> {
168
+ // 🚀 Question Detection Logic (Lead AI Engineer Requirement)
169
+ const questionKeywords = ['?', 'avis', 'penses', 'conseil', 'aider', 'comment', 'pourquoi', 'idée', 'prix', 'standard'];
170
+ const lowerInput = userInput.toLowerCase();
171
+ const hasQuestion = questionKeywords.some(kw => lowerInput.includes(kw));
172
+
173
+ console.log(`[AI_INTERACTION] User asked a question: ${hasQuestion}`);
174
+
175
  const activityLabel = userActivity || businessProfile?.activityLabel || 'non précisé';
176
  const region = userRegion || businessProfile?.region || 'Sénégal';
177
  const customer = businessProfile?.mainCustomer || '';
 
251
  MISSIONS STRATÉGIQUES "SENIOR BUSINESS COACH" (CORRECTION RADICALE) :
252
  Tu es un consultant pragmatique, pas un professeur pointilleux. Rédige un feedback d'au minimum 15 lignes de haute densité.
253
 
254
+ ${hasQuestion ? `
255
+ 🚨 DOUBLE INTENTION DÉTECTÉE : L'utilisateur a posé une question ou sollicité un avis stratégique.
256
+ - TU DOIS répondre spécifiquement à sa question dans la section "🚀 Version Enrichée".
257
+ - Compare son idée ou sa question (ex: prix, cible, stratégie) avec les standards du marché trouvés via tes recherches.
258
+ - Sois tranché et expert.` : ''}
259
+
260
  LES 3 PILIERS OBLIGATOIRES DU FEEDBACK :
261
  1. 🌟 Validation (Pilier 1) : Félicite et valide l'idée de l'utilisateur avec l'enthousiasme d'un investisseur.
262
  2. 🚀 Version Enrichie (Pilier 2) : Réécris sa phrase de manière exécutive en y intégrant OBLIGATOIREMENT des données chiffrées réelles trouvées dans ta recherche (ex: taille du marché ANSD, nombre de boutiques) et des termes stratégiques (supply chain, B2B, rétention).
 
268
  - Tu es là pour ENRICHIR sa vision, pas pour lui faire passer un interrogatoire. Ne pose AUCUNE question bloquante à la fin.
269
 
270
  INVITATION DEEP-DIVE OBLIGATOIRE :
271
+ ${hasQuestion ? `L'utilisateur ayant posé une question, tu DOIS systématiquement proposer l'option d'approfondissement pour explorer des alternatives stratégiques.` : ''}
272
  Termine EXACTEMENT ton pilier 3 par cette phrase selon la langue :
273
  (FR): "Si tu veux affiner ce point avec une donnée de ton propre terrain, tape 1️⃣ APPROFONDIR, sinon tape 2️⃣ SUITE."
274
  (WO): "Su nga bëggee yokk leneen ci li nga xam, bindal 1️⃣ APPROFONDIR, wala nga bind 2️⃣ SUITE."
apps/api/src/services/ai/types.ts CHANGED
@@ -26,9 +26,9 @@ export const OnePagerSchema = z.object({
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
- aiSource: z.string().optional().describe("The AI provider used (GEMINI, OPENAI, MOCK)")
32
  });
33
  export type OnePagerData = z.infer<typeof OnePagerSchema>;
34
 
@@ -36,8 +36,8 @@ 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"]).optional().describe("Type of visual to add on the right side"),
40
- visualData: z.any().optional().describe("Data for the chart (if any) or image prompt")
41
  });
42
  export type SlideData = z.infer<typeof SlideSchema>;
43
 
@@ -55,20 +55,20 @@ export const FeedbackSchema = z.object({
55
  enrichedVersion: z.string().describe("Réécriture de la phrase y intégrant des données chiffrées réelles et stratégiques issues de la recherche (Pilier 2)"),
56
  actionableAdvice: z.string().describe("Conseil terrain concret, actionnable et documenté pour cette étape (Pilier 3)"),
57
  isQualified: z.boolean().describe("Whether the answer meets the lesson's criteria"),
58
- isForcedClosure: z.boolean().optional().describe("True if the user has reached the 3-loop limit and the AI must force-close the deep dive"),
59
  missingElements: z.array(z.string()).describe("List of IDs from criteria that were not met"),
60
  confidence: z.number().min(0).max(100).describe("AI confidence in the evaluation"),
61
  notes: z.string().describe("Internal notes for the system"),
62
 
63
  // Strategic Enrichment Fields (Sprint 34)
64
- competitorList: z.array(z.string()).optional().describe("List of competitors identified (Day 10)"),
65
  financialProjections: z.object({
66
- revenueY1: z.string().optional(),
67
- revenueY3: z.string().optional(),
68
- growthRate: z.string().optional()
69
- }).optional().describe("3-year growth metrics (Day 11)"),
70
- fundingAsk: z.string().optional().describe("The Ask: amount and purpose (Day 12)"),
71
- aiSource: z.string().optional().describe("The AI provider used (GEMINI, OPENAI, MOCK)")
72
  });
73
  export type FeedbackData = z.infer<typeof FeedbackSchema>;
74
 
 
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().nullable().optional().describe("URL of the main brand image"),
30
+ marketSources: z.string().nullable().optional().describe("Sources des données de marché utilisées (ex: ANSD 2024)"),
31
+ aiSource: z.string().nullable().optional().describe("The AI provider used (GEMINI, OPENAI, MOCK)")
32
  });
33
  export type OnePagerData = z.infer<typeof OnePagerSchema>;
34
 
 
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>;
43
 
 
55
  enrichedVersion: z.string().describe("Réécriture de la phrase y intégrant des données chiffrées réelles et stratégiques issues de la recherche (Pilier 2)"),
56
  actionableAdvice: z.string().describe("Conseil terrain concret, actionnable et documenté pour cette étape (Pilier 3)"),
57
  isQualified: z.boolean().describe("Whether the answer meets the lesson's criteria"),
58
+ isForcedClosure: z.boolean().nullable().optional().describe("True if the user has reached the 3-loop limit and the AI must force-close the deep dive"),
59
  missingElements: z.array(z.string()).describe("List of IDs from criteria that were not met"),
60
  confidence: z.number().min(0).max(100).describe("AI confidence in the evaluation"),
61
  notes: z.string().describe("Internal notes for the system"),
62
 
63
  // Strategic Enrichment Fields (Sprint 34)
64
+ competitorList: z.array(z.string()).nullable().optional().describe("List of competitors identified (Day 10)"),
65
  financialProjections: z.object({
66
+ revenueY1: z.string().nullable().optional(),
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
  });
73
  export type FeedbackData = z.infer<typeof FeedbackSchema>;
74
 
apps/api/src/services/whatsapp.ts CHANGED
@@ -62,7 +62,7 @@ export class WhatsAppService {
62
  let user = await prisma.user.findUnique({ where: { phone } });
63
 
64
  if (!user) {
65
- const isInscription = this.isFuzzyMatch(normalizedText, 'INSCRIPTION') || normalizedText.includes('INSCRI');
66
 
67
  if (isInscription) {
68
  console.log(`${traceId} New user registration triggered for ${phone}`);
@@ -149,6 +149,18 @@ export class WhatsAppService {
149
  return;
150
  }
151
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  if (this.isFuzzyMatch(normalizedText, 'SEED')) {
153
  // Reply immediately so the webhook doesn't time out
154
  console.log(`[SEED] Triggered by user ${user.id}`);
@@ -422,6 +434,14 @@ export class WhatsAppService {
422
  }
423
 
424
  // Fallback to Exercise Response if nothing else matched
 
 
 
 
 
 
 
 
425
  const pendingProgress = await prisma.userProgress.findFirst({
426
  where: { userId: user.id, exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION', 'PENDING_DEEPDIVE'] }, trackId: activeEnrollment.trackId },
427
  });
 
62
  let user = await prisma.user.findUnique({ where: { phone } });
63
 
64
  if (!user) {
65
+ const isInscription = this.isFuzzyMatch(normalizedText, 'INSCRIPTION') || normalizedText.includes('INSCRI') || normalizedText.includes('INSCRI');
66
 
67
  if (isInscription) {
68
  console.log(`${traceId} New user registration triggered for ${phone}`);
 
149
  return;
150
  }
151
 
152
+ // 🚨 Guardrail "Contenu Vide" / Gibberish (UX Engineer Requirement)
153
+ const wordCount = (text || '').trim().split(/\s+/).length;
154
+ const systemCommands = ['1', '2', 'SUITE', 'APPROFONDIR', 'INSCRIPTION', 'SEED'];
155
+ const isSystemCommand = systemCommands.some(cmd => this.isFuzzyMatch(normalizedText, cmd)) || normalizedText.includes('INSCRI');
156
+
157
+ if (wordCount < 3 && !isSystemCommand) {
158
+ await scheduleMessage(user.id, user.language === 'WOLOF'
159
+ ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir (mbebetu 3 baat). Waxtaanal ak man !"
160
+ : "Je n'ai pas bien compris ton activité. Peux-tu me réexpliquer en quelques mots ce que tu fais ? (Minimum 3 mots)");
161
+ return;
162
+ }
163
+
164
  if (this.isFuzzyMatch(normalizedText, 'SEED')) {
165
  // Reply immediately so the webhook doesn't time out
166
  console.log(`[SEED] Triggered by user ${user.id}`);
 
434
  }
435
 
436
  // Fallback to Exercise Response if nothing else matched
437
+ // 🚨 COACHING GUARDRAIL: AI Coach only activated if profile (sector + language) is complete
438
+ if (!user.activity) {
439
+ await scheduleMessage(user.id, user.language === 'WOLOF'
440
+ ? "Danga wara tànn sa mbiru liggeey balaa ñuy tàmbali coaching bi."
441
+ : "Tu dois d'abord définir ton activité avant que le coach AI ne puisse t'aider.");
442
+ return;
443
+ }
444
+
445
  const pendingProgress = await prisma.userProgress.findFirst({
446
  where: { userId: user.id, exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION', 'PENDING_DEEPDIVE'] }, trackId: activeEnrollment.trackId },
447
  });
apps/api/tsconfig.json CHANGED
@@ -11,6 +11,9 @@
11
  "lib": [
12
  "ES2020"
13
  ],
 
 
 
14
  "esModuleInterop": true,
15
  "allowSyntheticDefaultImports": true
16
  },
 
11
  "lib": [
12
  "ES2020"
13
  ],
14
+ "types": [
15
+ "node"
16
+ ],
17
  "esModuleInterop": true,
18
  "allowSyntheticDefaultImports": true
19
  },
apps/whatsapp-worker/src/index.ts CHANGED
@@ -160,15 +160,24 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
160
  if (extractRes.ok) {
161
  const { data } = await extractRes.json() as any;
162
  // Clean up undefined/null values
163
- const profileData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v != null && v !== ''));
164
-
165
- if (Object.keys(profileData).length > 0 || feedbackData?.searchResults) {
166
- console.log(`[WORKER] Updating BusinessProfile for User ${userId}:`, profileData);
 
 
 
 
 
 
 
167
 
168
- const updatePayload: any = {
169
- ...profileData,
170
- lastUpdatedFromDay: currentDay
171
- };
 
 
172
 
173
  if (feedbackData?.searchResults) {
174
  updatePayload.marketData = feedbackData.searchResults;
 
160
  if (extractRes.ok) {
161
  const { data } = await extractRes.json() as any;
162
  // Clean up undefined/null values
163
+ const profileData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v != null && v !== ''));
164
+
165
+ // 🚨 Sector Locking (Lead AI Engineer Requirement)
166
+ const lockedSectors = ["Organisation d'événements / PWA"];
167
+ if (lockedSectors.includes(user.activity || "")) {
168
+ if (profileData.activityLabel || profileData.activityType) {
169
+ console.log(`[WORKER] Sector Locked for User ${userId}. Blocking activity update.`);
170
+ delete profileData.activityLabel;
171
+ delete profileData.activityType;
172
+ }
173
+ }
174
 
175
+ if (Object.keys(profileData).length > 0 || feedbackData?.searchResults) {
176
+ console.log(`[WORKER] Updating BusinessProfile for User ${userId}:`, profileData);
177
+ const updatePayload: any = {
178
+ ...profileData,
179
+ lastUpdatedFromDay: currentDay
180
+ };
181
 
182
  if (feedbackData?.searchResults) {
183
  updatePayload.marketData = feedbackData.searchResults;