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
|
| 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 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
|
| 169 |
-
.
|
| 170 |
-
|
| 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;
|