edtech / apps /api /src /routes /ai.ts
CognxSafeTrack
feat: Implement Non-Blocking Card UX and Prompt Hook Hook
635e9f4
import { FastifyInstance } from 'fastify';
import { aiService } from '../services/ai';
import { PdfOnePagerRenderer } from '../services/renderers/pdf-renderer';
import { PptxDeckRenderer } from '../services/renderers/pptx-renderer';
import { uploadFile } from '../services/storage';
import { convertToMp3IfNeeded } from '../services/ai/ffmpeg';
import { z } from 'zod';
export async function aiRoutes(fastify: FastifyInstance) {
const pdfRenderer = new PdfOnePagerRenderer();
const pptxRenderer = new PptxDeckRenderer();
// 1. Generate One-Pager (PDF)
fastify.post('/onepager', async (request) => {
const bodySchema = z.object({
userContext: z.string(),
language: z.string().optional().default('FR'),
businessProfile: z.any().optional()
});
const { userContext, language, businessProfile } = bodySchema.parse(request.body);
console.log(`Generating One-Pager (${language}) for context:`, userContext.substring(0, 50));
// Step 1: LLM generates structured JSON
const onePagerData = await aiService.generateOnePagerData(userContext, language, businessProfile);
// Step 1.5: Generate Brand Image if needed
if (onePagerData.mainImage && !onePagerData.mainImage.startsWith('http')) {
console.log(`[AI_ROUTE] Generating brand image for One-Pager: ${onePagerData.title}`);
try {
const imageUrl = await aiService.generateImage(onePagerData.mainImage);
if (imageUrl) {
onePagerData.mainImage = imageUrl;
}
} catch (imgErr) {
console.error(`[AI_ROUTE] Image generation failed for One-Pager:`, imgErr);
}
}
// Step 2: Renderer creates PDF Buffer
const pdfBuffer = await pdfRenderer.render(onePagerData);
// Step 3: Upload to Storage
const downloadUrl = await uploadFile(pdfBuffer, `onepager-${Date.now()}.pdf`, 'application/pdf');
return { success: true, url: downloadUrl, data: onePagerData, aiSource: onePagerData.aiSource };
});
// 2. Generate Pitch Deck (PPTX)
fastify.post('/deck', async (request) => {
const bodySchema = z.object({
userContext: z.string(),
language: z.string().optional().default('FR'),
businessProfile: z.any().optional()
});
const { userContext, language, businessProfile } = bodySchema.parse(request.body);
console.log(`Generating Pitch Deck (${language}) for context:`, userContext.substring(0, 50));
// Step 1: LLM generates structured JSON (slides)
const deckData = await aiService.generatePitchDeckData(userContext, language, businessProfile);
// Step 1.5: Generate AI Images for specific slides if requested
for (const slide of deckData.slides) {
if (slide.visualType === 'IMAGE' && slide.visualData && typeof slide.visualData === 'string' && !slide.visualData.startsWith('http')) {
console.log(`[AI_ROUTE] Generating image for slide: ${slide.title}`);
try {
const imageUrl = await aiService.generateImage(slide.visualData);
if (imageUrl) {
slide.visualData = imageUrl;
}
} catch (imgErr) {
console.error(`[AI_ROUTE] Image generation failed for slide ${slide.title}:`, imgErr);
}
}
}
// Step 2: Renderer creates PPTX Buffer
const pptxBuffer = await pptxRenderer.render(deckData);
// Step 3: Upload to Storage
const downloadUrl = await uploadFile(pptxBuffer, `deck-${Date.now()}.pptx`, 'application/vnd.openxmlformats-officedocument.presentationml.presentation');
return { success: true, url: downloadUrl, data: deckData, aiSource: deckData.aiSource };
});
// 3. Personalize Lesson Content (Dynamic Rewriting)
fastify.post('/personalize-lesson', async (request) => {
const bodySchema = z.object({
lessonText: z.string(),
userActivity: z.string(),
userLanguage: z.string().optional().default('FR'),
businessProfile: z.any().optional(),
previousResponses: z.array(z.object({
day: z.number(),
response: z.string()
})).optional()
});
const { lessonText, userActivity, userLanguage, businessProfile, previousResponses } = bodySchema.parse(request.body);
console.log(`[AI] Personalizing lesson for activity: ${userActivity} (Lang: ${userLanguage}) with ${previousResponses?.length || 0} prev responses.`);
const { lessonText: personalizedText, aiSource } = await aiService.generatePersonalizedLesson(lessonText, userActivity, userLanguage, businessProfile, previousResponses);
return { success: true, text: personalizedText, aiSource };
});
// 4. Generate TTS Audio for a lesson
fastify.post('/tts', async (request, reply) => {
const bodySchema = z.object({ text: z.string() });
const { text } = bodySchema.parse(request.body);
console.log(`Generating TTS audio...`);
try {
const audioBuffer = await aiService.generateSpeech(text);
const downloadUrl = await uploadFile(audioBuffer, `lesson-audio-${Date.now()}.mp3`, 'audio/mpeg');
return { success: true, url: downloadUrl };
} catch (err: unknown) {
if ((err as any)?.name === 'QuotaExceededError') {
return reply.code(429).send({ error: 'quota_exceeded' });
}
throw err;
}
});
// 5. Transcribe audio (called by Railway worker after downloading media from Meta)
// Accepts: { audioBase64: string, filename: string }
// Returns: { text: string }
fastify.post('/transcribe', async (request, reply) => {
const bodySchema = z.object({
audioBase64: z.string(),
filename: z.string().default('message.ogg'),
language: z.string().optional()
});
const { audioBase64, filename, language } = bodySchema.parse(request.body);
const buffer = Buffer.from(audioBase64, 'base64');
console.log(`[AI] 🚀 DEPLOY V4 - Transcribing: ${filename} (size: ${buffer.length})`);
try {
const { buffer: audioToTranscribe, format } = await convertToMp3IfNeeded(buffer, filename);
console.log(`[AI] Calling transcribeAudio for format: ${format} (Lang: ${language || 'none'})`);
const { text, confidence } = await aiService.transcribeAudio(audioToTranscribe, `message.${format}`, language);
// 🌟 STT Hardening: Basic quality check 🌟
// Include common punctuation: , . ! ? ' -
const isSuspect = text.length < 3 || /[^a-zA-Z0-9\sàâäéèêëîïôöùûüçÀÂÄÉÈÊËÎÏÔÖÙÛÜÇ,.!?'\-]/.test(text.slice(0, 10));
return { success: true, text, confidence, isSuspect };
} catch (err: unknown) {
console.error(`[AI] ❌ Transcription error:`, err);
if ((err as any)?.name === 'QuotaExceededError') {
return reply.code(429).send({ error: 'quota_exceeded', retryAfterMs: (err as any).retryAfterMs });
}
// Ensure error message is bubbled up for debugging
return reply.code(500).send({ error: 'transcription_failed', message: (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)), stack: process.env.NODE_ENV === 'development' ? (err as Error).stack : undefined });
}
});
// 6. Store audio on R2 (called by Railway worker for audio archiving)
// Accepts: { audioBase64: string, mimeType: string, phone: string }
// Returns: { success: true, url: string }
fastify.post('/store-audio', async (request) => {
const bodySchema = z.object({
audioBase64: z.string(),
mimeType: z.string().default('audio/ogg'),
phone: z.string()
});
const { audioBase64, mimeType, phone } = bodySchema.parse(request.body);
let ext = 'ogg';
let folder = 'audio';
if (mimeType.includes('image/')) {
ext = mimeType.split('/')[1] || 'jpeg';
folder = 'images';
} else if (mimeType.includes('mp4')) {
ext = 'mp4';
} else if (mimeType.includes('mpeg')) {
ext = 'mp3';
}
const filename = `${folder}/${phone}-${Date.now()}.${ext}`;
const buffer = Buffer.from(audioBase64, 'base64');
try {
// Ensure the /tmp folders exist because uploadFile falls back to local storage
const { mkdir } = require('fs/promises');
await mkdir(`/tmp/${folder}`, { recursive: true }).catch(() => { });
const url = await uploadFile(buffer, filename, mimeType);
console.log(`[AI] ✅ Media stored: ${url}`);
return { success: true, url };
} catch (err: unknown) {
console.error('[AI] store-audio failed:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)));
return { success: false, error: (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)) };
}
});
// 7. Generate Exercise Feedback (Async Worker)
fastify.post('/generate-feedback', async (request, reply) => {
const bodySchema = z.object({
answers: z.string(),
lessonText: z.string(),
exercisePrompt: z.string(),
userLanguage: z.string().optional().default('FR'),
businessProfile: z.any().nullish(),
exerciseCriteria: z.any().nullish(),
// Expert coaching context
userActivity: z.string().nullish(),
userRegion: z.string().nullish(),
dayNumber: z.number().nullish(),
previousResponses: z.array(z.object({
day: z.number(),
response: z.string()
})).optional(),
totalDays: z.number().optional().default(1),
isDeepDive: z.boolean().optional().default(false),
iterationCount: z.number().optional().default(0),
imageUrl: z.string().optional(),
isButtonChoice: z.boolean().optional().default(false)
});
const {
answers, lessonText, exercisePrompt, userLanguage, businessProfile, exerciseCriteria,
userActivity, userRegion, dayNumber, previousResponses, isDeepDive, iterationCount, imageUrl,
isButtonChoice
} = bodySchema.parse(request.body);
console.log(`[AI] Generating feedback for user... (Lang: ${userLanguage}, Button: ${isButtonChoice}, DeepDive: ${isDeepDive})`);
try {
const feedback = await aiService.generateFeedback(
answers, exercisePrompt, lessonText, userLanguage, businessProfile ?? undefined, exerciseCriteria ?? undefined,
userActivity ?? undefined, userRegion ?? undefined, dayNumber ?? undefined, previousResponses ?? undefined,
isDeepDive, iterationCount, imageUrl ?? undefined, isButtonChoice
);
// 🌟 Standard Feedback UX: 3 lines 🌟
// 1. Encouragement/Validation (VALIDATION)
// 2. Diagnostic (ENRICHED VERSION)
// 3. Action / Help (ACTIONABLE ADVICE)
const formattedFeedback = `✨ *Coach XAMLÉ* :\n\n` +
`🌟 ${feedback.validation}\n\n` +
`🚀 ${feedback.enrichedVersion}\n\n` +
`💡 ${feedback.actionableAdvice}`;
return {
success: true,
text: formattedFeedback,
isQualified: feedback.isQualified,
missingElements: feedback.missingElements || [],
confidence: feedback.confidence,
notes: feedback.notes,
searchResults: feedback.searchResults,
aiSource: feedback.aiSource
};
} catch (err: unknown) {
if ((err as any)?.name === 'QuotaExceededError') {
return reply.code(429).send({ error: 'quota_exceeded' });
}
throw err;
}
});
// 8. Extract Business Profile Data
fastify.post('/extract-profile', async (request) => {
const bodySchema = z.object({
userInput: z.string(),
dayNumber: z.number(),
userLanguage: z.string().optional().default('FR')
});
const { userInput, dayNumber, userLanguage } = bodySchema.parse(request.body);
console.log(`[AI] Extracting business profile for Day ${dayNumber}`);
const profileData = await aiService.extractBusinessProfile(userInput, dayNumber, userLanguage);
return { success: true, data: profileData, aiSource: profileData.aiSource };
});
}