edtech / apps /api /src /services /ai /index.ts
CognxSafeTrack
feat: B2B SaaS Multi-tenant architecture & Tech Debt Resolution
e289c5c
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();