CognxSafeTrack commited on
Commit
b073e6a
Β·
1 Parent(s): e6d84cb

feat(ai): implement multimodal vision and dynamic data for business context

Browse files
apps/api/src/routes/ai.ts CHANGED
@@ -207,19 +207,24 @@ export async function aiRoutes(fastify: FastifyInstance) {
207
  previousResponses: z.array(z.object({
208
  day: z.number(),
209
  response: z.string()
210
- })).optional()
 
 
 
 
211
  });
212
  const {
213
  answers, lessonText, exercisePrompt, userLanguage, businessProfile, exerciseCriteria,
214
- userActivity, userRegion, dayNumber, previousResponses
215
  } = bodySchema.parse(request.body);
216
 
217
- console.log(`[AI] Generating expert feedback for answers (Lang: ${userLanguage}, Day: ${dayNumber || '?'}).`);
218
 
219
  try {
220
  const feedback = await aiService.generateFeedback(
221
  answers, exercisePrompt, lessonText, userLanguage, businessProfile ?? undefined, exerciseCriteria ?? undefined,
222
- userActivity ?? undefined, userRegion ?? undefined, dayNumber ?? undefined, previousResponses ?? undefined
 
223
  );
224
 
225
  // 🌟 Standard Feedback UX: 3 lines 🌟
 
207
  previousResponses: z.array(z.object({
208
  day: z.number(),
209
  response: z.string()
210
+ })).optional(),
211
+ totalDays: z.number().optional().default(1),
212
+ isDeepDive: z.boolean().optional().default(false),
213
+ iterationCount: z.number().optional().default(0),
214
+ imageUrl: z.string().optional()
215
  });
216
  const {
217
  answers, lessonText, exercisePrompt, userLanguage, businessProfile, exerciseCriteria,
218
+ userActivity, userRegion, dayNumber, previousResponses, isDeepDive, iterationCount, imageUrl
219
  } = bodySchema.parse(request.body);
220
 
221
+ console.log(`[AI] Generating feedback for user... (Lang: ${userLanguage}, DeepDive: ${isDeepDive}, Iteration: ${iterationCount}, Image: ${!!imageUrl})`);
222
 
223
  try {
224
  const feedback = await aiService.generateFeedback(
225
  answers, exercisePrompt, lessonText, userLanguage, businessProfile ?? undefined, exerciseCriteria ?? undefined,
226
+ userActivity ?? undefined, userRegion ?? undefined, dayNumber ?? undefined, previousResponses ?? undefined,
227
+ isDeepDive, iterationCount, imageUrl ?? undefined
228
  );
229
 
230
  // 🌟 Standard Feedback UX: 3 lines 🌟
apps/api/src/services/ai/gemini-provider.ts CHANGED
@@ -14,18 +14,40 @@ export class GeminiProvider implements LLMProvider {
14
  this.proModel = this.genAI.getGenerativeModel({ model: 'gemini-1.5-pro' });
15
  }
16
 
17
- async generateStructuredData<T>(prompt: string, _schema: z.ZodSchema<T>, temperature?: number): Promise<T> {
18
  // Use Flash for standard feedback/chat (fast)
19
  // Use Pro for complex docs (OnePager/PitchDeck) - detected by prompt length or keyword
20
  const isComplex = prompt.includes('PITCH_DECK') || prompt.includes('ONE_PAGER') || prompt.length > 2000;
21
  const model = isComplex ? this.proModel : this.flashModel;
22
  const modelName = isComplex ? 'gemini-1.5-pro' : 'gemini-1.5-flash';
23
 
24
- console.log(`[GEMINI] Generating structured data with ${modelName}...`);
25
 
26
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const result = await model.generateContent({
28
- contents: [{ role: 'user', parts: [{ text: prompt }] }],
29
  generationConfig: {
30
  responseMimeType: 'application/json',
31
  temperature: temperature ?? 0.2, // Default to 0.2
 
14
  this.proModel = this.genAI.getGenerativeModel({ model: 'gemini-1.5-pro' });
15
  }
16
 
17
+ async generateStructuredData<T>(prompt: string, _schema: z.ZodSchema<T>, temperature?: number, imageUrl?: string): Promise<T> {
18
  // Use Flash for standard feedback/chat (fast)
19
  // Use Pro for complex docs (OnePager/PitchDeck) - detected by prompt length or keyword
20
  const isComplex = prompt.includes('PITCH_DECK') || prompt.includes('ONE_PAGER') || prompt.length > 2000;
21
  const model = isComplex ? this.proModel : this.flashModel;
22
  const modelName = isComplex ? 'gemini-1.5-pro' : 'gemini-1.5-flash';
23
 
24
+ console.log(`[GEMINI] Generating structured data with ${modelName}... (Vision: ${!!imageUrl})`);
25
 
26
  try {
27
+ const parts: any[] = [{ text: prompt }];
28
+
29
+ if (imageUrl) {
30
+ try {
31
+ console.log(`[GEMINI] Fetching image from: ${imageUrl}`);
32
+ const response = await fetch(imageUrl);
33
+ const buffer = await response.arrayBuffer();
34
+ const base64 = Buffer.from(buffer).toString('base64');
35
+ const mimeType = response.headers.get('content-type') || 'image/png';
36
+
37
+ parts.push({
38
+ inlineData: {
39
+ data: base64,
40
+ mimeType
41
+ }
42
+ });
43
+ } catch (imgErr) {
44
+ console.error('[GEMINI] Failed to fetch image for vision:', imgErr);
45
+ // Fallback to text-only if image fetch fails rather than crashing
46
+ }
47
+ }
48
+
49
  const result = await model.generateContent({
50
+ contents: [{ role: 'user', parts }],
51
  generationConfig: {
52
  responseMimeType: 'application/json',
53
  temperature: temperature ?? 0.2, // Default to 0.2
apps/api/src/services/ai/index.ts CHANGED
@@ -45,18 +45,19 @@ class AIService {
45
  private async callWithFailover<T>(
46
  prompt: string,
47
  schema: z.ZodSchema<T>,
48
- temperature?: number
 
49
  ): Promise<{ data: T, source: string }> {
50
  try {
51
- const data = await this.primaryProvider.generateStructuredData(prompt, schema, temperature);
52
  const source = (this.primaryProvider instanceof GeminiProvider) ? 'GEMINI' :
53
  (this.primaryProvider instanceof OpenAIProvider) ? 'OPENAI' : 'MOCK';
54
- console.log(`[AI_INFO] ${source} used successfully.`);
55
  return { data, source };
56
  } catch (err) {
57
  if (this.fallbackProvider) {
58
  console.warn('[AI_WARNING] Primary provider failed, falling back to OpenAI...', (err as Error).message);
59
- const data = await this.fallbackProvider.generateStructuredData(prompt, schema, temperature);
60
  console.log('[AI_INFO] OPENAI used as fallback.');
61
  return { data, source: 'OPENAI' };
62
  }
@@ -81,7 +82,7 @@ class AIService {
81
  ${marketDataInjected}
82
 
83
  STRICTES CONTRAINTES DE QUALITΓ‰ "PREMIUM V4" :
84
- - 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).
85
  - 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).
86
  - SOURCES (marketSources) : Si tu utilises les donnΓ©es de marchΓ© injectΓ©es, cite explicitement la source (ex: "Source: ANSD 2024").
87
  - ANALYSE vs DESCRIPTION : Ne te contente pas d'Γ©numΓ©rer. Explique l'impact business et le positionnement luxe.
@@ -164,7 +165,8 @@ class AIService {
164
  dayNumber?: number,
165
  previousResponses?: Array<{ day: number; response: string }>,
166
  isDeepDive: boolean = false,
167
- iterationCount: number = 0
 
168
  ): Promise<FeedbackData & { searchResults?: any[] }> {
169
  // πŸš€ Question Detection Logic (Lead AI Engineer Requirement)
170
  const questionKeywords = ['?', 'avis', 'penses', 'conseil', 'aider', 'comment', 'pourquoi', 'idΓ©e', 'prix', 'standard'];
@@ -205,13 +207,21 @@ class AIService {
205
 
206
  // Remove hallucinatory generic fallback words
207
  const cleanActivity = activityLabel.replace(/non prΓ©cisΓ©|e-commerce/i, '').trim() || 'Entrepreneuriat';
208
- const query = `${cleanActivity} ${region} SΓ©nΓ©gal marchΓ© chiffres statistiques data`;
 
 
 
 
 
 
 
 
209
  try {
210
  const results = await searchService.search(query);
211
  if (results && results.length > 0) {
212
  searchResults = results;
213
  searchContext = `\n🌐 DONNΓ‰ES DE MARCHΓ‰ RΓ‰ELLES (Google Search) :\n${results.map(r => `- ${r.title}: ${r.snippet}`).join('\n')}\n`;
214
- console.log(`[AI_SERVICE] βœ… Search enrichment added (${results.length} results).`);
215
  }
216
  } catch (err) {
217
  console.error('[AI_SERVICE] Search enrichment failed:', err);
@@ -267,8 +277,15 @@ class AIService {
267
 
268
  LES 3 PILIERS OBLIGATOIRES DU FEEDBACK :
269
  1. 🌟 Validation (Pilier 1) : FΓ©licite et valide l'idΓ©e de l'utilisateur avec l'enthousiasme d'un investisseur.
270
- 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).
271
- 3. πŸ’‘ Conseil Actionnable (Pilier 3) : Donne un conseil de terrain hyper concret basΓ© lΓ  aussi sur la recherche web (ex: "Conseil : Le crΓ©neau 6h-8h est critique dans les boutiques de Dakar...").
 
 
 
 
 
 
 
272
 
273
  ⚠️ INTERDICTION ABSOLUE (Anti-Remediation Loop) :
274
  - Tu NE DOIS PLUS JAMAIS demander Γ  l'utilisateur de s'expliquer davantage (ex: "Quel est l'Γ’ge exact ?", "Combien gagnes-tu ?").
@@ -321,7 +338,7 @@ class AIService {
321
  `ALERTE MAXIMALE : Tu as l'INTERDICTION FORMELLE d'utiliser du Wolof. Reste dans un FranΓ§ais institutionnel et pragmatique.`}
322
  `;
323
 
324
- const { data, source } = await this.callWithFailover(prompt, FeedbackSchema, 0.7);
325
  return {
326
  ...data,
327
  searchResults,
 
45
  private async callWithFailover<T>(
46
  prompt: string,
47
  schema: z.ZodSchema<T>,
48
+ temperature?: number,
49
+ imageUrl?: string
50
  ): Promise<{ data: T, source: string }> {
51
  try {
52
+ const data = await this.primaryProvider.generateStructuredData(prompt, schema, temperature, imageUrl);
53
  const source = (this.primaryProvider instanceof GeminiProvider) ? 'GEMINI' :
54
  (this.primaryProvider instanceof OpenAIProvider) ? 'OPENAI' : 'MOCK';
55
+ console.log(`[AI_INFO] ${source} used successfully. (Vision: ${!!imageUrl})`);
56
  return { data, source };
57
  } catch (err) {
58
  if (this.fallbackProvider) {
59
  console.warn('[AI_WARNING] Primary provider failed, falling back to OpenAI...', (err as Error).message);
60
+ const data = await this.fallbackProvider.generateStructuredData(prompt, schema, temperature, imageUrl);
61
  console.log('[AI_INFO] OPENAI used as fallback.');
62
  return { data, source: 'OPENAI' };
63
  }
 
82
  ${marketDataInjected}
83
 
84
  STRICTES CONTRAINTES DE QUALITΓ‰ "PREMIUM V4" :
85
+ - 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: enjeux de distribution pour ${businessProfile?.activityLabel || 'ce secteur'}, dΓ©lais de livraison locaux).
86
  - 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).
87
  - SOURCES (marketSources) : Si tu utilises les donnΓ©es de marchΓ© injectΓ©es, cite explicitement la source (ex: "Source: ANSD 2024").
88
  - ANALYSE vs DESCRIPTION : Ne te contente pas d'Γ©numΓ©rer. Explique l'impact business et le positionnement luxe.
 
165
  dayNumber?: number,
166
  previousResponses?: Array<{ day: number; response: string }>,
167
  isDeepDive: boolean = false,
168
+ iterationCount: number = 0,
169
+ imageUrl?: string
170
  ): Promise<FeedbackData & { searchResults?: any[] }> {
171
  // πŸš€ Question Detection Logic (Lead AI Engineer Requirement)
172
  const questionKeywords = ['?', 'avis', 'penses', 'conseil', 'aider', 'comment', 'pourquoi', 'idΓ©e', 'prix', 'standard'];
 
207
 
208
  // Remove hallucinatory generic fallback words
209
  const cleanActivity = activityLabel.replace(/non prΓ©cisΓ©|e-commerce/i, '').trim() || 'Entrepreneuriat';
210
+ let query = `${cleanActivity} ${region} SΓ©nΓ©gal marchΓ© chiffres statistiques data`;
211
+
212
+ // πŸš€ Diversification des recherches (Lead AI Architect Requirement)
213
+ if (dayNumber === 10) {
214
+ query = `startups concurrents ${cleanActivity} ${region} SΓ©nΓ©gal solutions paiement UEMOA`;
215
+ } else if (dayNumber === 11) {
216
+ query = `benchmarks marges rentabilitΓ© ${cleanActivity} Afrique de l'Ouest tech business model`;
217
+ }
218
+
219
  try {
220
  const results = await searchService.search(query);
221
  if (results && results.length > 0) {
222
  searchResults = results;
223
  searchContext = `\n🌐 DONNΓ‰ES DE MARCHΓ‰ RΓ‰ELLES (Google Search) :\n${results.map(r => `- ${r.title}: ${r.snippet}`).join('\n')}\n`;
224
+ console.log(`[AI_SERVICE] βœ… Search enrichment added for Day ${dayNumber} (Query: ${query}).`);
225
  }
226
  } catch (err) {
227
  console.error('[AI_SERVICE] Search enrichment failed:', err);
 
277
 
278
  LES 3 PILIERS OBLIGATOIRES DU FEEDBACK :
279
  1. 🌟 Validation (Pilier 1) : FΓ©licite et valide l'idΓ©e de l'utilisateur avec l'enthousiasme d'un investisseur.
280
+ 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Γ© selon la recherche, nombre de clients potentiels) et des termes stratΓ©giques (supply chain, B2B, rΓ©tention).
281
+ 3. πŸ’‘ Conseil Actionnable (Pilier 3) : Donne un conseil de terrain hyper concret basΓ© lΓ  aussi sur la recherche web (ex: "Conseil : Dans le secteur de ${activityLabel} Γ  ${region}, il est crucial de...").
282
+
283
+ ${imageUrl ? `
284
+ πŸ“Έ ANALYSE VISUELLE (MULTIMODAL) :
285
+ - L'utilisateur a envoyΓ© une image comme preuve ou illustration.
286
+ - TU DOIS analyser visuellement cette image et intΓ©grer ton constat dans le feedback.
287
+ - Si c'est un trophΓ©e (ex: Blue Ocean Awards), un diplΓ΄me ou un logo, souligne sa valeur pour la crΓ©dibilitΓ© et la 'Slide Confiance'.
288
+ - Si l'image contient des chiffres ou des contrats, extrais-les pour confirmer les données financières.` : ''}
289
 
290
  ⚠️ INTERDICTION ABSOLUE (Anti-Remediation Loop) :
291
  - Tu NE DOIS PLUS JAMAIS demander Γ  l'utilisateur de s'expliquer davantage (ex: "Quel est l'Γ’ge exact ?", "Combien gagnes-tu ?").
 
338
  `ALERTE MAXIMALE : Tu as l'INTERDICTION FORMELLE d'utiliser du Wolof. Reste dans un FranΓ§ais institutionnel et pragmatique.`}
339
  `;
340
 
341
+ const { data, source } = await this.callWithFailover(prompt, FeedbackSchema, 0.7, imageUrl);
342
  return {
343
  ...data,
344
  searchResults,
apps/api/src/services/ai/openai-provider.ts CHANGED
@@ -26,7 +26,7 @@ export class OpenAIProvider implements LLMProvider {
26
  });
27
  }
28
 
29
- async generateStructuredData<T>(prompt: string, schema: z.ZodSchema<T>, temperature?: number): Promise<T> {
30
  console.log('[OPENAI] Generating structured data...');
31
 
32
  const timeout = new Promise<never>((_, reject) =>
 
26
  });
27
  }
28
 
29
+ async generateStructuredData<T>(prompt: string, schema: z.ZodSchema<T>, temperature?: number, _imageUrl?: string): Promise<T> {
30
  console.log('[OPENAI] Generating structured data...');
31
 
32
  const timeout = new Promise<never>((_, reject) =>
apps/api/src/services/ai/types.ts CHANGED
@@ -7,7 +7,7 @@ export interface TranscriptionResult {
7
 
8
  // Base interface for all LLM Providers
9
  export interface LLMProvider {
10
- generateStructuredData<T>(prompt: string, schema: z.ZodSchema<T>, temperature?: number): Promise<T>;
11
  transcribeAudio(audioBuffer: Buffer, filename: string, language?: string): Promise<TranscriptionResult>;
12
  generateSpeech(text: string): Promise<Buffer>;
13
  generateImage(prompt: string): Promise<string>;
 
7
 
8
  // Base interface for all LLM Providers
9
  export interface LLMProvider {
10
+ generateStructuredData<T>(prompt: string, schema: z.ZodSchema<T>, temperature?: number, imageUrl?: string): Promise<T>;
11
  transcribeAudio(audioBuffer: Buffer, filename: string, language?: string): Promise<TranscriptionResult>;
12
  generateSpeech(text: string): Promise<Buffer>;
13
  generateImage(prompt: string): Promise<string>;
apps/api/src/services/whatsapp.ts CHANGED
@@ -53,10 +53,10 @@ export class WhatsAppService {
53
  return similarity >= threshold;
54
  }
55
 
56
- static async handleIncomingMessage(phone: string, text: string, audioUrl?: string) {
57
- const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
58
  const normalizedText = this.normalizeCommand(text);
59
- console.log(`${traceId} Received: ${normalizedText} (Audio: ${audioUrl || 'N/A'})`);
60
 
61
  // 1. Find or Create User
62
  let user = await prisma.user.findUnique({ where: { phone } });
@@ -92,7 +92,7 @@ export class WhatsAppService {
92
  await prisma.message.create({
93
  data: {
94
  content: text,
95
- mediaUrl: audioUrl,
96
  direction: 'INBOUND',
97
  userId: user.id
98
  }
@@ -503,7 +503,7 @@ export class WhatsAppService {
503
  userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDay.id,
504
  exercisePrompt: trackDay.exercisePrompt || '', lessonText: trackDay.lessonText || '',
505
  exerciseCriteria: trackDay.exerciseCriteria, pendingProgressId: pendingProgress.id,
506
- enrollmentId: activeEnrollment.id, currentDay: activeEnrollment.currentDay,
507
  totalDays: activeEnrollment.track.duration, language: user.language,
508
  // NEW EXPERT CONTEXT
509
  userActivity: user.activity,
@@ -511,7 +511,8 @@ export class WhatsAppService {
511
  previousResponses,
512
  // DEEP DIVE PARAMETERS
513
  isDeepDive: isDeepDiveAction,
514
- iterationCount: currentIterationCount
 
515
  }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
516
  return;
517
  }
@@ -569,7 +570,8 @@ export class WhatsAppService {
569
  // NEW EXPERT CONTEXT
570
  userActivity: user.activity,
571
  userRegion: user.city,
572
- previousResponses
 
573
  });
574
  return;
575
  }
 
53
  return similarity >= threshold;
54
  }
55
 
56
+ static async handleIncomingMessage(phone: string, text: string, audioUrl?: string, imageUrl?: string) {
57
+ const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
58
  const normalizedText = this.normalizeCommand(text);
59
+ console.log(`${traceId} Received: ${normalizedText} (Audio: ${audioUrl || 'N/A'}, Image: ${imageUrl || 'N/A'})`);
60
 
61
  // 1. Find or Create User
62
  let user = await prisma.user.findUnique({ where: { phone } });
 
92
  await prisma.message.create({
93
  data: {
94
  content: text,
95
+ mediaUrl: audioUrl || imageUrl,
96
  direction: 'INBOUND',
97
  userId: user.id
98
  }
 
503
  userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDay.id,
504
  exercisePrompt: trackDay.exercisePrompt || '', lessonText: trackDay.lessonText || '',
505
  exerciseCriteria: trackDay.exerciseCriteria, pendingProgressId: pendingProgress.id,
506
+ dayNumber: activeEnrollment.currentDay,
507
  totalDays: activeEnrollment.track.duration, language: user.language,
508
  // NEW EXPERT CONTEXT
509
  userActivity: user.activity,
 
511
  previousResponses,
512
  // DEEP DIVE PARAMETERS
513
  isDeepDive: isDeepDiveAction,
514
+ iterationCount: currentIterationCount,
515
+ imageUrl: imageUrl // Pass the image URL to the AI Coach
516
  }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
517
  return;
518
  }
 
570
  // NEW EXPERT CONTEXT
571
  userActivity: user.activity,
572
  userRegion: user.city,
573
+ previousResponses,
574
+ imageUrl: imageUrl // Pass the image URL to the AI Coach (fallback case)
575
  });
576
  return;
577
  }
apps/whatsapp-worker/src/index.ts CHANGED
@@ -48,7 +48,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
48
  await sendTextMessage(phone, text);
49
  }
50
  else if (job.name === 'generate-feedback') {
51
- const { userId, text, trackId, exercisePrompt, lessonText, exerciseCriteria, currentDay, totalDays, language, userActivity, userRegion, previousResponses, isDeepDive, iterationCount } = job.data;
52
  const user = await prisma.user.findUnique({
53
  where: { id: userId },
54
  include: { businessProfile: true } as any
@@ -86,7 +86,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
86
  previousResponses,
87
  // DEEP DIVE CONTEXT
88
  isDeepDive: isDeepDive || false,
89
- iterationCount: iterationCount || 0
 
90
  })
91
  });
92
 
@@ -521,8 +522,9 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
521
  }
522
  }
523
 
524
- // Transcribe with Whisper via API
525
- console.log(`${traceId} Transcribe start calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/transcribe`);
 
526
  const transcribeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/transcribe`, {
527
  method: 'POST',
528
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
@@ -669,7 +671,6 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
669
  }
670
  console.log(`${traceId} API handle-message success.`);
671
  }
672
-
673
  } else if (transcribeRes.status === 429) {
674
  // OpenAI quota exceeded β€” send fallback and do NOT requeue
675
  console.warn(`[WORKER] 429 Error during transcription`);
@@ -686,11 +687,41 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
686
  console.error(`[WORKER] /v1/ai/transcribe failed with HTTP ${transcribeRes.status}: ${errText}`);
687
  throw new Error(`Transcription failed HTTP ${transcribeRes.status}`); // throw so BullMQ retries
688
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
 
690
- } catch (err: unknown) {
691
- console.error(`[WORKER] handle-inbound-audio failed:`, err);
 
 
 
 
 
 
 
 
 
 
692
  }
 
 
693
  }
 
694
  else if (job.name === 'send-image') {
695
  const { to, imageUrl, caption } = job.data;
696
  try {
 
48
  await sendTextMessage(phone, text);
49
  }
50
  else if (job.name === 'generate-feedback') {
51
+ const { userId, text, trackId, exercisePrompt, lessonText, exerciseCriteria, currentDay, totalDays, language, userActivity, userRegion, previousResponses, isDeepDive, iterationCount, imageUrl } = job.data;
52
  const user = await prisma.user.findUnique({
53
  where: { id: userId },
54
  include: { businessProfile: true } as any
 
86
  previousResponses,
87
  // DEEP DIVE CONTEXT
88
  isDeepDive: isDeepDive || false,
89
+ iterationCount: iterationCount || 0,
90
+ imageUrl: imageUrl
91
  })
92
  });
93
 
 
522
  }
523
  }
524
 
525
+ // ─── Routing: Transcribe if Audio, Forward if Image ─────────
526
+ if (mimeType.startsWith('audio/')) {
527
+ console.log(`${traceId} Transcribe start calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/transcribe`);
528
  const transcribeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/transcribe`, {
529
  method: 'POST',
530
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
 
671
  }
672
  console.log(`${traceId} API handle-message success.`);
673
  }
 
674
  } else if (transcribeRes.status === 429) {
675
  // OpenAI quota exceeded β€” send fallback and do NOT requeue
676
  console.warn(`[WORKER] 429 Error during transcription`);
 
687
  console.error(`[WORKER] /v1/ai/transcribe failed with HTTP ${transcribeRes.status}: ${errText}`);
688
  throw new Error(`Transcription failed HTTP ${transcribeRes.status}`); // throw so BullMQ retries
689
  }
690
+ } else if (mimeType.startsWith('image/')) {
691
+ // πŸ“Έ VISION FLOW (Lead AI Architect Requirement)
692
+ console.log(`${traceId} Image detected. Forwarding to API handle-message...`);
693
+
694
+ // ─── Port Hardening: Handle HF Space 7860 vs Local 8080 ─────────
695
+ let finalApiUrl = AI_API_BASE_URL.replace(/\/$/, "");
696
+ if (finalApiUrl.includes('localhost:8080')) {
697
+ try {
698
+ const pingRes = await fetch('http://localhost:8080/health').catch(() => null);
699
+ if (!pingRes || !pingRes.ok) {
700
+ console.log(`${traceId} Local port 8080 not responding, trying 7860 (HF Default)...`);
701
+ finalApiUrl = finalApiUrl.replace('8080', '7860');
702
+ }
703
+ } catch (e) {
704
+ finalApiUrl = finalApiUrl.replace('8080', '7860');
705
+ }
706
+ }
707
 
708
+ const handleRes = await fetch(`${finalApiUrl}/v1/internal/handle-message`, {
709
+ method: 'POST',
710
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
711
+ body: JSON.stringify({ phone, text: 'Image reΓ§ue', imageUrl: audioUrl }) // text: 'Image reΓ§ue' to pass gibberish guardrail
712
+ });
713
+
714
+ if (!handleRes.ok) {
715
+ const errText = await handleRes.text();
716
+ console.error(`${traceId} API handle-message (IMAGE) failed: ${errText}`);
717
+ throw new Error(`API handle-message failed: ${handleRes.status}`);
718
+ }
719
+ console.log(`${traceId} API handle-message (IMAGE) success.`);
720
  }
721
+ } catch (err: unknown) {
722
+ console.error(`[WORKER] download-media failed:`, err);
723
  }
724
+ }
725
  else if (job.name === 'send-image') {
726
  const { to, imageUrl, caption } = job.data;
727
  try {