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
|
| 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
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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Γ©
|
| 271 |
-
3. π‘ Conseil Actionnable (Pilier 3) : Donne un conseil de terrain hyper concret basΓ© lΓ aussi sur la recherche web (ex: "Conseil :
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 525 |
-
|
|
|
|
| 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 |
-
|
| 691 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|