| import { logger } from '../../logger'; |
| import { z } from 'zod'; |
| import { |
| OnePagerData, OnePagerSchema, |
| PitchDeckData, PitchDeckSchema, |
| PersonalizedLessonSchema, |
| FeedbackSchema, FeedbackData |
| } from './types'; |
| import { MockLLMProvider } from './mock-provider'; |
| import { OpenAIProvider } from './openai-provider'; |
| import { searchService } from './search'; |
| import { GeminiProvider } from './gemini-provider'; |
| import { PromptLoader, PersonalityConfig } from '@repo/prompts'; |
| import { getOrganizationId } from '@repo/database'; |
| import { prisma } from '../prisma'; |
| import { redis } from '../queue'; |
| import { ProviderRegistry, ProviderCapability } from './ProviderRegistry'; |
|
|
| class AIService { |
| private registry: ProviderRegistry; |
|
|
| constructor() { |
| this.registry = new ProviderRegistry(); |
| this.initializeProviders(); |
| } |
|
|
| private initializeProviders() { |
| const geminiApiKey = process.env.GOOGLE_AI_API_KEY; |
| const openAiApiKey = process.env.OPENAI_API_KEY; |
|
|
| if (geminiApiKey) { |
| this.registry.register('GEMINI', new GeminiProvider(geminiApiKey), 100, [ |
| ProviderCapability.TEXT, |
| ProviderCapability.VISION |
| ]); |
| } |
|
|
| if (openAiApiKey) { |
| const openai = new OpenAIProvider(openAiApiKey); |
| this.registry.register('OPENAI', openai, 50, [ |
| ProviderCapability.TEXT, |
| ProviderCapability.VISION, |
| ProviderCapability.AUDIO_TRANSCRIPTION, |
| ProviderCapability.SPEECH_GENERATION, |
| ProviderCapability.IMAGE_GENERATION |
| ]); |
| } |
|
|
| this.registry.register('MOCK', new MockLLMProvider(), 0, Object.values(ProviderCapability) as ProviderCapability[]); |
| } |
|
|
| private async getTenantPersonality(): Promise<Partial<PersonalityConfig>> { |
| const organizationId = getOrganizationId(); |
| if (!organizationId) return {}; |
|
|
| const cacheKey = `org:config:${organizationId}`; |
| try { |
| const cached = await redis.get(cacheKey); |
| if (cached) return JSON.parse(cached); |
| } catch (err) { |
| logger.error('[AI_SERVICE] Redis error:', err); |
| } |
|
|
| try { |
| const org = await prisma.organization.findUnique({ |
| where: { id: organizationId }, |
| select: { personalityConfig: true } |
| }); |
| const personality = (org?.personalityConfig as any) || {}; |
| await redis.set(cacheKey, JSON.stringify(personality), 'EX', 3600); |
| return personality; |
| } catch (err) { |
| logger.error('[AI_SERVICE] Failed to fetch tenant personality:', err); |
| return {}; |
| } |
| } |
|
|
| private async callWithFailover<T>( |
| prompt: string, |
| schema: z.ZodSchema<T>, |
| temperature?: number, |
| imageUrl?: string |
| ): Promise<{ data: T, source: string }> { |
| const capability = imageUrl ? ProviderCapability.VISION : ProviderCapability.TEXT; |
| const providers = this.registry.getProvidersFor(capability); |
|
|
| for (const provider of providers) { |
| try { |
| const data = await provider.instance.generateStructuredData(prompt, schema, temperature, imageUrl); |
| logger.info(`[AI_INFO] ${provider.name} used successfully. (Capability: ${capability})`); |
| return { data, source: provider.name }; |
| } catch (err) { |
| logger.warn(`[AI_WARNING] ${provider.name} failed: ${(err as Error).message}. Trying next provider...`); |
| } |
| } |
|
|
| throw new Error(`[AI_ERROR] All providers for ${capability} failed.`); |
| } |
|
|
| async generateOnePagerData(userContext: string, language: string = 'FR', businessProfile?: any): Promise<OnePagerData> { |
| const personality = await this.getTenantPersonality(); |
| const prompt = PromptLoader.compile('one-pager', { |
| activityLabel: businessProfile?.activityLabel || 'non précisé', |
| userContext, |
| marketDataInjected: businessProfile?.marketData ? `\n🌐 DONNÉES DE MARCHÉ :\n${JSON.stringify(businessProfile.marketData)}\n` : '', |
| languageLabel: language === 'WOLOF' ? 'WOLOF standardisé' : 'Français institutionnel', |
| languageInstruction: language === 'WOLOF' ? 'WOLOF (ñ, ë, é) suivi du FR' : 'French' |
| }, personality); |
|
|
| const { data, source } = await this.callWithFailover(prompt, OnePagerSchema); |
| return { ...data, aiSource: source }; |
| } |
|
|
| async generatePitchDeckData(userContext: string, language: string = 'FR', businessProfile?: any): Promise<PitchDeckData & { aiSource?: string }> { |
| const personality = await this.getTenantPersonality(); |
| const prompt = PromptLoader.compile('pitch-deck', { |
| activityLabel: businessProfile?.activityLabel || 'Entrepreneuriat', |
| locationCity: businessProfile?.locationCity || 'Sénégal', |
| userContext, |
| marketDataInjected: businessProfile?.marketData ? `\n🌐 MARCHÉ :\n${JSON.stringify(businessProfile.marketData)}\n` : '', |
| teamDataInjected: businessProfile?.teamMembers?.length > 0 ? `\n👥 ÉQUIPE :\n${JSON.stringify(businessProfile.teamMembers)}\n` : '', |
| languageLabel: language === 'WOLOF' ? 'WOLOF' : 'FRENCH' |
| }, personality); |
|
|
| const { data, source } = await this.callWithFailover(prompt, PitchDeckSchema); |
| return { ...data, aiSource: source }; |
| } |
|
|
| async generateFeedback( |
| userInput: string, |
| expectedExercise: string, |
| lessonContent: string, |
| userLanguage: string = 'FR', |
| businessProfile?: any, |
| exerciseCriteria?: any, |
| userActivity?: string, |
| userRegion?: string, |
| dayNumber?: number, |
| previousResponses?: Array<{ day: number; response: string }>, |
| isDeepDive: boolean = false, |
| iterationCount: number = 0, |
| imageUrl?: string |
| ): Promise<FeedbackData & { searchResults?: any[] }> { |
| const hasQuestion = ['?', 'avis', 'penses', 'conseil', 'aider'].some(kw => userInput.toLowerCase().includes(kw)); |
| const activityLabel = userActivity || businessProfile?.activityLabel || 'non précisé'; |
| |
| let searchContext = ''; |
| let searchResults: any[] | undefined; |
|
|
| if (dayNumber !== 7) { |
| try { |
| const results = await searchService.search(`${activityLabel} Sénégal marché`); |
| if (results?.length > 0) { |
| searchResults = results; |
| searchContext = `\n🌐 MARCHÉ :\n${results.map(r => `- ${r.title}: ${r.snippet}`).join('\n')}\n`; |
| } |
| } catch (err) {} |
| } |
|
|
| const personality = await this.getTenantPersonality(); |
| let actionPrompt = ''; |
| if (isDeepDive) { |
| actionPrompt = iterationCount >= 3 |
| ? PromptLoader.compile('action-feedback-deepdive-limit', {}, personality) |
| : PromptLoader.compile('action-feedback-deepdive', { iterationCount, activityLabel }, personality); |
| } else { |
| actionPrompt = PromptLoader.compile('action-feedback-standard', { |
| dayNumber: dayNumber || 0, |
| expectedExercise, |
| previousResponsesContext: previousResponses?.map(r => `J${r.day}: ${r.response}`).join('\n') || '', |
| questionDetectionBlock: hasQuestion ? '🚨 QUESTION DÉTECTÉE...' : '', |
| visionMultimodalBlock: imageUrl ? '📸 ANALYSE VISUELLE...' : '' |
| }, personality); |
| } |
|
|
| const prompt = PromptLoader.compile('feedback-base', { |
| dayNumber: dayNumber || 0, |
| activityLabel, |
| region: userRegion || businessProfile?.region || 'Sénégal', |
| businessContext: `🏪 Activité: ${activityLabel}`, |
| prevContext: previousResponses?.map(r => `[J${r.day}]: "${r.response}"`).join('\n') || '', |
| criteriaContext: JSON.stringify(exerciseCriteria || {}), |
| searchContext, |
| lessonContentContent: lessonContent.substring(0, 500), |
| expectedExercise, |
| userInput, |
| actionPrompt, |
| userLanguage |
| }, personality); |
|
|
| const { data, source } = await this.callWithFailover(prompt, FeedbackSchema, 0.7, imageUrl); |
| return { ...data, searchResults, aiSource: source }; |
| } |
|
|
| async generatePersonalizedLesson( |
| lessonText: string, |
| userActivity: string, |
| userLanguage: string = 'FR', |
| previousResponses?: Array<{ day: number; response: string }> |
| ): Promise<{ lessonText: string, aiSource: string }> { |
| const personality = await this.getTenantPersonality(); |
| const prompt = PromptLoader.compile('personalized-lesson', { |
| businessContext: `🏪 Activité: ${userActivity}`, |
| prevContext: previousResponses?.map(r => `J${r.day}: "${r.response.substring(0, 100)}"`).join('\n') || '', |
| activityLabel: userActivity, |
| lessonText, |
| languageLabel: userLanguage === 'WOLOF' ? 'WOLOF (ñ, ë, é)' : 'Français' |
| }, personality); |
|
|
| const { data, source } = await this.callWithFailover(prompt, PersonalizedLessonSchema); |
| return { lessonText: data.lessonText, aiSource: source }; |
| } |
|
|
| async transcribeAudio(audioBuffer: Buffer, filename: string, language?: string): Promise<{ text: string, confidence: number }> { |
| const provider = this.registry.getPrimary(ProviderCapability.AUDIO_TRANSCRIPTION); |
| if (!provider) throw new Error('[AI_ERROR] No provider for audio transcription.'); |
| return provider.instance.transcribeAudio(audioBuffer, filename, language); |
| } |
|
|
| async extractBusinessProfile(userInput: string, dayNumber: number, userLanguage: string = 'FR'): Promise<any> { |
| const personality = await this.getTenantPersonality(); |
| const prompt = PromptLoader.compile('business-profile-extraction', { |
| userInput, dayNumber, languageLabel: userLanguage === 'WOLOF' ? 'WOLOF' : 'Français' |
| }, personality); |
|
|
| const schema = z.object({ |
| activityLabel: z.string().nullable(), |
| activityType: z.string().nullable(), |
| mainCustomer: z.string().nullable(), |
| mainProblem: z.string().nullable(), |
| offerSimple: z.string().nullable(), |
| promise: z.string().nullable() |
| }); |
|
|
| const { data, source } = await this.callWithFailover(prompt, schema); |
| return { ...data, aiSource: source }; |
| } |
|
|
| async generateSpeech(text: string): Promise<Buffer> { |
| const provider = this.registry.getPrimary(ProviderCapability.SPEECH_GENERATION); |
| if (!provider) throw new Error('[AI_ERROR] No provider for speech generation.'); |
| return provider.instance.generateSpeech(text); |
| } |
|
|
| async generateImage(prompt: string): Promise<string> { |
| const provider = this.registry.getPrimary(ProviderCapability.IMAGE_GENERATION); |
| if (!provider) throw new Error('[AI_ERROR] No provider for image generation.'); |
| return provider.instance.generateImage(prompt); |
| } |
| } |
|
|
| export const aiService = new AIService(); |
|
|