diff --git a/backend/prisma/migrations/20260408_add_user_preferences/migration.sql b/backend/prisma/migrations/20260408_add_user_preferences/migration.sql new file mode 100644 index 0000000..accbe2c --- /dev/null +++ b/backend/prisma/migrations/20260408_add_user_preferences/migration.sql @@ -0,0 +1,41 @@ +-- Add cooking preferences to User and make stripeId optional +-- SQLite requires rewriting the table for nullability changes. + +PRAGMA foreign_keys=OFF; + +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "password" TEXT, + "name" TEXT NOT NULL, + "googleId" TEXT, + "stripeId" TEXT, + "subscription" TEXT, + "resetToken" TEXT, + "resetTokenExpiry" DATETIME, + "dietaryPreference" TEXT, + "allergies" TEXT, + "maxCookingTime" INTEGER, + "equipment" TEXT, + "cuisinePreference" TEXT, + "servingsDefault" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +INSERT INTO "new_User" ( + "id", "email", "password", "name", "googleId", "stripeId", + "subscription", "resetToken", "resetTokenExpiry", "createdAt", "updatedAt" +) SELECT + "id", "email", "password", "name", "googleId", "stripeId", + "subscription", "resetToken", "resetTokenExpiry", "createdAt", "updatedAt" +FROM "User"; + +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; + +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE UNIQUE INDEX "User_googleId_key" ON "User"("googleId"); +CREATE UNIQUE INDEX "User_stripeId_key" ON "User"("stripeId"); + +PRAGMA foreign_keys=ON; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4c6fd8e..d1d25a7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -8,18 +8,27 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique - password String? // Optionnel pour les utilisateurs Google - name String - googleId String? @unique // ID Google pour SSO - stripeId String @unique - subscription String? - resetToken String? // Token pour la réinitialisation du mot de passe - resetTokenExpiry DateTime? // Date d'expiration du token - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - recipes Recipe[] + id String @id @default(uuid()) + email String @unique + password String? // Optionnel pour les utilisateurs Google + name String + googleId String? @unique + stripeId String? @unique // Optionnel : Stripe peut être désactivé + subscription String? + resetToken String? + resetTokenExpiry DateTime? + + // Préférences culinaires injectées dans le prompt IA + dietaryPreference String? // 'vegetarian' | 'vegan' | 'pescatarian' | 'none' + allergies String? // Liste séparée par des virgules : "arachides,gluten" + maxCookingTime Int? // Temps total max (prépa + cuisson) en minutes + equipment String? // JSON array : '["four","plaque","micro-ondes"]' + cuisinePreference String? // "française", "italienne", "asiatique"... ou null + servingsDefault Int? // Nombre de portions par défaut + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + recipes Recipe[] } model Recipe { diff --git a/backend/src/ai/prompts.ts b/backend/src/ai/prompts.ts index b5f9b45..e984c72 100644 --- a/backend/src/ai/prompts.ts +++ b/backend/src/ai/prompts.ts @@ -175,14 +175,92 @@ export const RECIPE_JSON_SCHEMA = { }, } as const; +export interface UserPreferences { + dietaryPreference?: string | null; // 'vegetarian' | 'vegan' | 'pescatarian' | 'none' + allergies?: string | null; // "arachides,gluten" + maxCookingTime?: number | null; // minutes (prépa + cuisson) + equipment?: string[] | null; + cuisinePreference?: string | null; + servingsDefault?: number | null; +} + +export interface BuildPromptOptions { + transcription: string; + hint?: string; + preferences?: UserPreferences | null; +} + +const DIET_LABELS: Record = { + vegetarian: 'végétarien (aucune viande ni poisson, mais œufs/lait/fromage autorisés)', + vegan: 'végan strict (aucun produit d\'origine animale, y compris œufs, lait, miel)', + pescatarian: 'pescétarien (poisson autorisé, pas de viande)', +}; + /** * Construit le prompt utilisateur à partir des ingrédients transcrits - * et d'éventuelles préférences (à étendre dans une phase B). + * et des préférences culinaires de l'utilisateur. */ -export function buildUserPrompt(transcription: string, hint?: string): string { - const cleanTranscription = transcription.trim(); - const base = `Voici les ingrédients que j'ai sous la main : ${cleanTranscription}`; - return hint ? `${base}\n\nContrainte additionnelle : ${hint}` : base; +export function buildUserPrompt(options: BuildPromptOptions): string { + const cleanTranscription = options.transcription.trim(); + const parts: string[] = [ + `Voici les ingrédients que j'ai sous la main : ${cleanTranscription}`, + ]; + + const prefs = options.preferences; + if (prefs) { + const prefLines: string[] = []; + + if (prefs.dietaryPreference && prefs.dietaryPreference !== 'none') { + const label = DIET_LABELS[prefs.dietaryPreference] ?? prefs.dietaryPreference; + prefLines.push(`- Régime à respecter IMPÉRATIVEMENT : ${label}. Ne propose aucun ingrédient interdit par ce régime, même en substitution implicite.`); + } + + if (prefs.allergies && prefs.allergies.trim()) { + const list = prefs.allergies + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (list.length > 0) { + prefLines.push( + `- Allergies STRICTES (mettre en jeu la santé de la personne) : ${list.join(', ')}. N'utilise JAMAIS ces ingrédients ni leurs dérivés (ex: si "arachides" → pas d'huile d'arachide, pas de beurre de cacahuète).` + ); + } + } + + if (prefs.maxCookingTime && prefs.maxCookingTime > 0) { + prefLines.push( + `- Temps total disponible : ${prefs.maxCookingTime} minutes maximum (préparation + cuisson cumulées). Ajuste la complexité en conséquence.` + ); + } + + if (prefs.equipment && prefs.equipment.length > 0) { + prefLines.push( + `- Équipement disponible : ${prefs.equipment.join(', ')}. N'utilise aucune technique qui nécessiterait un équipement non listé.` + ); + } + + if (prefs.cuisinePreference) { + prefLines.push( + `- Inspiration culinaire préférée : ${prefs.cuisinePreference}. Reste dans cette famille si possible, quitte à jouer une variation créative.` + ); + } + + if (prefs.servingsDefault && prefs.servingsDefault > 0) { + prefLines.push( + `- Nombre de portions souhaité : ${prefs.servingsDefault}.` + ); + } + + if (prefLines.length > 0) { + parts.push(`\nMes contraintes :\n${prefLines.join('\n')}`); + } + } + + if (options.hint) { + parts.push(`\nContrainte additionnelle : ${options.hint}`); + } + + return parts.join('\n'); } /** diff --git a/backend/src/ai/recipe-generator.ts b/backend/src/ai/recipe-generator.ts index 1384d37..1906ec7 100644 --- a/backend/src/ai/recipe-generator.ts +++ b/backend/src/ai/recipe-generator.ts @@ -1,6 +1,11 @@ import type { OpenAI } from 'openai'; import type { FastifyBaseLogger } from 'fastify'; -import { SYSTEM_PROMPT, RECIPE_JSON_SCHEMA, buildUserPrompt } from './prompts'; +import { + SYSTEM_PROMPT, + RECIPE_JSON_SCHEMA, + buildUserPrompt, + type UserPreferences, +} from './prompts'; import { computeTextCost, type CallLog } from './cost'; export interface RecipeIngredient { @@ -34,6 +39,7 @@ export interface GenerateRecipeOptions { transcription: string; hint?: string; userId?: string; + preferences?: UserPreferences | null; } export interface GenerateRecipeResult { @@ -55,16 +61,21 @@ export async function generateRecipe( model, messages: [ { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: buildUserPrompt(options.transcription, options.hint) }, + { + role: 'user', + content: buildUserPrompt({ + transcription: options.transcription, + hint: options.hint, + preferences: options.preferences, + }), + }, ], response_format: { type: 'json_schema', json_schema: RECIPE_JSON_SCHEMA, }, - // Léger : on veut de la créativité mais sans halluciner les ingrédients temperature: 0.85, top_p: 0.95, - // Le modèle a tout ce qu'il faut pour répondre court ; cap de sécurité max_tokens: 2000, }); @@ -121,3 +132,135 @@ export async function generateRecipe( return { recipe, log }; } + +// --------------------------------------------------------------------------- +// Streaming version +// --------------------------------------------------------------------------- + +export type RecipeStreamEvent = + | { type: 'delta'; content: string } + | { type: 'title'; title: string } + | { type: 'description'; description: string } + | { type: 'complete'; recipe: StructuredRecipe; log: CallLog }; + +/** + * Streame la génération de recette token par token. + * + * Émet des événements typés au fil de la génération : + * - `delta` : chaque morceau de texte brut venu d'OpenAI (pour affichage + * ou debug ; le frontend peut l'ignorer) + * - `title` : le titre dès qu'il est extractible (permet de lancer la + * génération d'image en parallèle) + * - `description` : la description courte dès qu'elle est complète + * - `complete` : la recette parsée au complet + métadonnées de coût + */ +export async function* streamRecipe( + openai: OpenAI, + logger: FastifyBaseLogger, + options: GenerateRecipeOptions +): AsyncGenerator { + const start = Date.now(); + const model = DEFAULT_MODEL; + + const stream = await openai.chat.completions.create({ + model, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { + role: 'user', + content: buildUserPrompt({ + transcription: options.transcription, + hint: options.hint, + preferences: options.preferences, + }), + }, + ], + response_format: { + type: 'json_schema', + json_schema: RECIPE_JSON_SCHEMA, + }, + temperature: 0.85, + top_p: 0.95, + max_tokens: 2000, + stream: true, + // Demander l'usage dans la réponse streamée (important : sans ça les + // tokens.usage est null à la fin du stream) + stream_options: { include_usage: true }, + }); + + let buffer = ''; + let titleEmitted = false; + let descriptionEmitted = false; + let finalUsage: { prompt_tokens?: number; completion_tokens?: number; prompt_tokens_details?: { cached_tokens?: number } } | null = null; + + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta?.content ?? ''; + + // Le dernier chunk contient l'usage mais pas de delta + if (chunk.usage) { + finalUsage = chunk.usage; + } + + if (!delta) continue; + + buffer += delta; + yield { type: 'delta', content: delta }; + + // Détection précoce du titre via regex sur le buffer + if (!titleEmitted) { + const m = buffer.match(/"titre"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); + if (m) { + titleEmitted = true; + yield { type: 'title', title: m[1] }; + } + } + + // Détection de la description (vient typiquement après le titre) + if (titleEmitted && !descriptionEmitted) { + const m = buffer.match(/"description"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); + if (m) { + descriptionEmitted = true; + yield { type: 'description', description: m[1] }; + } + } + } + + // Parsing final de la recette complète + let recipe: StructuredRecipe; + try { + recipe = JSON.parse(buffer) as StructuredRecipe; + } catch (err) { + throw new Error( + `Parse JSON du stream impossible : ${(err as Error).message}` + ); + } + + if (!recipe.titre || !recipe.ingredients?.length || !recipe.etapes?.length) { + throw new Error('Recette générée incomplète'); + } + + const cachedTokens = finalUsage?.prompt_tokens_details?.cached_tokens ?? 0; + const cost = computeTextCost(model, { + promptTokens: finalUsage?.prompt_tokens, + cachedTokens, + completionTokens: finalUsage?.completion_tokens, + }); + + const log: CallLog = { + userId: options.userId, + operation: 'recipe_generation', + model, + durationMs: Date.now() - start, + costUsd: cost, + usage: { + promptTokens: finalUsage?.prompt_tokens, + cachedTokens, + completionTokens: finalUsage?.completion_tokens, + }, + cacheHit: cachedTokens > 0, + }; + + logger.info(log, 'openai_recipe_streamed'); + + yield { type: 'complete', recipe, log }; +} diff --git a/backend/src/plugins/ai.ts b/backend/src/plugins/ai.ts index e5bc596..1f024fb 100644 --- a/backend/src/plugins/ai.ts +++ b/backend/src/plugins/ai.ts @@ -11,6 +11,7 @@ import { downloadToTemp, } from '../ai/transcriber'; import { generateRecipe as runGenerateRecipe, type StructuredRecipe } from '../ai/recipe-generator'; +import type { UserPreferences } from '../ai/prompts'; import { generateRecipeImage as runGenerateImage } from '../ai/image-generator'; export default fp(async function aiPlugin(fastify: FastifyInstance) { @@ -52,11 +53,16 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) { // le pipeline structuré + parallélisation image/texte. fastify.decorate( 'generateRecipe', - async (ingredients: string, hint?: string): Promise => { + async ( + ingredients: string, + hint?: string, + preferences?: unknown + ): Promise => { // 1. Génération du texte (séquentiel obligatoire : on a besoin du titre) const { recipe } = await runGenerateRecipe(openai, fastify.log, { transcription: ingredients, hint, + preferences: preferences as UserPreferences | null | undefined, }); // 2. Génération de l'image en parallèle de la sérialisation diff --git a/backend/src/routes/recipes.ts b/backend/src/routes/recipes.ts index 055bbcb..7805f2d 100644 --- a/backend/src/routes/recipes.ts +++ b/backend/src/routes/recipes.ts @@ -1,7 +1,52 @@ import * as fs from 'node:fs'; -import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'; import multipart from '@fastify/multipart'; import { deleteFile } from '../utils/storage'; +import { streamRecipe, type StructuredRecipe } from '../ai/recipe-generator'; +import { generateRecipeImage } from '../ai/image-generator'; +import type { UserPreferences } from '../ai/prompts'; + +type SSESender = (event: string, data: unknown) => void; + +function setupSSE(reply: FastifyReply): SSESender { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', // désactive le buffering nginx si en prod + }); + return (event, data) => { + reply.raw.write(`event: ${event}\n`); + reply.raw.write(`data: ${JSON.stringify(data)}\n\n`); + }; +} + +function parseUserPreferences(user: { + dietaryPreference: string | null; + allergies: string | null; + maxCookingTime: number | null; + equipment: string | null; + cuisinePreference: string | null; + servingsDefault: number | null; +}): UserPreferences { + let equipmentArr: string[] | null = null; + if (user.equipment) { + try { + const parsed = JSON.parse(user.equipment); + if (Array.isArray(parsed)) equipmentArr = parsed; + } catch { + /* ignore malformed */ + } + } + return { + dietaryPreference: user.dietaryPreference, + allergies: user.allergies, + maxCookingTime: user.maxCookingTime, + equipment: equipmentArr, + cuisinePreference: user.cuisinePreference, + servingsDefault: user.servingsDefault, + }; +} const FREE_PLAN_LIMIT = 5; @@ -47,7 +92,8 @@ const recipesRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { const recipeData = await fastify.generateRecipe( transcription, - 'Crée une recette délicieuse et détaillée' + undefined, + parseUserPreferences(user) ); const ingredientsString = Array.isArray(recipeData.ingredients) @@ -196,6 +242,170 @@ const recipesRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { return reply.code(500).send({ error: 'Erreur lors de la suppression de la recette' }); } }); + + // ------------------------------------------------------------------------- + // POST /create-stream — version streaming (SSE) + // ------------------------------------------------------------------------- + // Flux d'événements émis : + // progress { step } — étape courante du pipeline + // transcription { text } — texte transcrit + // title { title } — titre extrait, image lancée en parallèle + // description { description } — description courte + // delta { content } — delta brut (optionnel, pour debug) + // recipe { recipe } — recette structurée complète + // image { url } — URL finale de l'image + // saved { id } — id de la recette persistée + // error { message } — erreur terminale + // done — fin du flux + // + // La route reste un POST multipart (audio) mais la réponse est un stream SSE. + fastify.post('/create-stream', async (request, reply) => { + const data = await request.file(); + if (!data) { + return reply.code(400).send({ error: 'Fichier audio requis' }); + } + + const [user, recipeCount] = await Promise.all([ + fastify.prisma.user.findUnique({ where: { id: request.user.id } }), + fastify.prisma.recipe.count({ where: { userId: request.user.id } }), + ]); + + if (!user) { + return reply.code(404).send({ error: 'Utilisateur non trouvé' }); + } + + const isFree = !user.subscription || user.subscription === 'free'; + if (isFree && recipeCount >= FREE_PLAN_LIMIT) { + return reply.code(403).send({ + error: `Limite de ${FREE_PLAN_LIMIT} recettes atteinte pour le plan gratuit.`, + }); + } + + const preferences = parseUserPreferences(user); + const send = setupSSE(reply); + + // Heartbeat toutes les 15s pour que les proxies ne ferment pas la connexion + const heartbeat = setInterval(() => { + reply.raw.write(`: heartbeat\n\n`); + }, 15000); + + const cleanup = () => clearInterval(heartbeat); + request.raw.on('close', cleanup); + + try { + // --- 1. Sauvegarde audio --- + send('progress', { step: 'saving_audio' }); + const audioResult = await fastify.saveAudioFile(data); + const audioUrl = audioResult.url || audioResult.localPath || null; + const audioStoragePath = audioResult.path ?? null; + + // --- 2. Transcription --- + send('progress', { step: 'transcribing' }); + const transcription = await fastify.transcribeAudio(audioResult); + send('transcription', { text: transcription }); + + // --- 3. Génération recette en streaming --- + send('progress', { step: 'generating_recipe' }); + + let imagePromise: Promise<{ url: string | null }> | null = null; + let imageStartedAt: number | null = null; + let finalRecipe: StructuredRecipe | null = null; + + const iterator = streamRecipe(fastify.openai, fastify.log, { + transcription, + preferences, + userId: user.id, + }); + + for await (const event of iterator) { + if (event.type === 'title') { + send('title', { title: event.title }); + // Lance la génération d'image en parallèle dès qu'on a le titre + if (!imagePromise) { + imageStartedAt = Date.now(); + send('progress', { step: 'generating_image' }); + imagePromise = generateRecipeImage(fastify.openai, fastify.log, { + title: event.title, + userId: user.id, + }); + } + } else if (event.type === 'description') { + send('description', { description: event.description }); + } else if (event.type === 'delta') { + // Envoyé seulement pour ceux qui veulent l'afficher en brut + // On peut commenter pour réduire la bande passante + send('delta', { content: event.content }); + } else if (event.type === 'complete') { + finalRecipe = event.recipe; + send('recipe', { recipe: event.recipe }); + } + } + + if (!finalRecipe) { + throw new Error('Recette non générée'); + } + + // --- 4. Attente image (peut être déjà prête si génération plus rapide que le stream) --- + send('progress', { step: 'finalizing' }); + const imageResult = imagePromise + ? await imagePromise + : { url: null }; + const imageUrl = imageResult.url; + if (imageStartedAt !== null) { + fastify.log.info( + { parallelImageMs: Date.now() - imageStartedAt }, + 'parallel_image_duration' + ); + } + send('image', { url: imageUrl }); + + // --- 5. Persistance --- + const ingredientsString = finalRecipe.ingredients + .map((i) => `${i.quantite} ${i.nom}${i.notes ? ` (${i.notes})` : ''}`) + .join('\n'); + const stepsString = finalRecipe.etapes + .sort((a, b) => a.numero - b.numero) + .map((e) => `${e.numero}. ${e.instruction}`) + .join('\n'); + const tipsString = [ + ...finalRecipe.conseils, + `Inspiration : ${finalRecipe.origine_inspiration}`, + finalRecipe.accord_boisson ? `Accord boisson : ${finalRecipe.accord_boisson}` : null, + ] + .filter(Boolean) + .join('\n'); + + const saved = await fastify.prisma.recipe.create({ + data: { + title: finalRecipe.titre, + ingredients: ingredientsString, + userPrompt: transcription, + generatedRecipe: JSON.stringify(finalRecipe), + imageUrl, + preparationTime: finalRecipe.temps_preparation, + cookingTime: finalRecipe.temps_cuisson, + servings: finalRecipe.portions, + difficulty: finalRecipe.difficulte, + steps: stepsString, + tips: tipsString, + audioUrl: audioStoragePath || audioUrl, + userId: user.id, + }, + }); + + send('saved', { id: saved.id }); + send('done', {}); + } catch (err) { + fastify.log.error(err, 'create-stream failed'); + send('error', { message: (err as Error).message }); + } finally { + cleanup(); + reply.raw.end(); + } + + // On a déjà géré la réponse via reply.raw + return reply; + }); }; export default recipesRoutes; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 2cac9c4..21b80c9 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -29,6 +29,15 @@ interface DeleteAccountBody { password?: string; } +interface UpdatePreferencesBody { + dietaryPreference?: string | null; + allergies?: string | null; + maxCookingTime?: number | null; + equipment?: string[] | null; + cuisinePreference?: string | null; + servingsDefault?: number | null; +} + const usersRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { const authenticateUser = async (request: FastifyRequest, reply: FastifyReply) => { try { @@ -49,6 +58,12 @@ const usersRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { name: true, subscription: true, createdAt: true, + dietaryPreference: true, + allergies: true, + maxCookingTime: true, + equipment: true, + cuisinePreference: true, + servingsDefault: true, }, }); @@ -56,7 +71,23 @@ const usersRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { return reply.code(404).send({ error: 'Utilisateur non trouvé' }); } - return { user }; + // Désérialise le JSON equipment avant de renvoyer au client + let equipmentArr: string[] = []; + if (user.equipment) { + try { + const parsed = JSON.parse(user.equipment); + if (Array.isArray(parsed)) equipmentArr = parsed; + } catch { + /* ignore */ + } + } + + return { + user: { + ...user, + equipment: equipmentArr, + }, + }; } catch (error) { fastify.log.error(error); return reply.code(500).send({ error: 'Erreur lors de la récupération du profil' }); @@ -328,6 +359,82 @@ const usersRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { } } ); + + // --- PUT /preferences --- + fastify.put<{ Body: UpdatePreferencesBody }>( + '/preferences', + { preHandler: authenticateUser }, + async (request, reply) => { + try { + const body = request.body ?? {}; + + // Validation simple : on accepte null pour "reset" + const allowedDiets = ['none', 'vegetarian', 'vegan', 'pescatarian', null]; + if (body.dietaryPreference !== undefined && !allowedDiets.includes(body.dietaryPreference)) { + return reply.code(400).send({ error: 'Régime invalide' }); + } + + if ( + body.maxCookingTime !== undefined && + body.maxCookingTime !== null && + (body.maxCookingTime < 5 || body.maxCookingTime > 600) + ) { + return reply.code(400).send({ error: 'Temps de cuisson invalide (5-600 min)' }); + } + + if ( + body.servingsDefault !== undefined && + body.servingsDefault !== null && + (body.servingsDefault < 1 || body.servingsDefault > 20) + ) { + return reply.code(400).send({ error: 'Nombre de portions invalide (1-20)' }); + } + + const updateData: Record = {}; + if (body.dietaryPreference !== undefined) updateData.dietaryPreference = body.dietaryPreference; + if (body.allergies !== undefined) updateData.allergies = body.allergies; + if (body.maxCookingTime !== undefined) updateData.maxCookingTime = body.maxCookingTime; + if (body.cuisinePreference !== undefined) updateData.cuisinePreference = body.cuisinePreference; + if (body.servingsDefault !== undefined) updateData.servingsDefault = body.servingsDefault; + if (body.equipment !== undefined) { + updateData.equipment = body.equipment ? JSON.stringify(body.equipment) : null; + } + + const updated = await fastify.prisma.user.update({ + where: { id: request.user.id }, + data: updateData, + select: { + dietaryPreference: true, + allergies: true, + maxCookingTime: true, + equipment: true, + cuisinePreference: true, + servingsDefault: true, + }, + }); + + let equipmentArr: string[] = []; + if (updated.equipment) { + try { + const parsed = JSON.parse(updated.equipment); + if (Array.isArray(parsed)) equipmentArr = parsed; + } catch { + /* ignore */ + } + } + + return { + preferences: { + ...updated, + equipment: equipmentArr, + }, + }; + } catch (error) { + fastify.log.error(error); + return reply.code(500).send({ error: 'Erreur lors de la mise à jour des préférences' }); + } + } + ); }; export default usersRoutes; diff --git a/backend/src/types/fastify.d.ts b/backend/src/types/fastify.d.ts index 51f8d8e..ea95b03 100644 --- a/backend/src/types/fastify.d.ts +++ b/backend/src/types/fastify.d.ts @@ -57,7 +57,11 @@ declare module 'fastify' { // ai plugin openai: OpenAI; transcribeAudio: (audioInput: AudioInput) => Promise; - generateRecipe: (ingredients: string, prompt?: string) => Promise; + generateRecipe: ( + ingredients: string, + prompt?: string, + preferences?: unknown + ) => Promise; saveAudioFile: (file: MultipartFile) => Promise; // google-auth plugin diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 8527019..f11f438 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -5,6 +5,21 @@ export interface User { email: string; name: string | null; subscription: string; + dietaryPreference?: string | null; + allergies?: string | null; + maxCookingTime?: number | null; + equipment?: string[]; + cuisinePreference?: string | null; + servingsDefault?: number | null; +} + +export interface UserPreferences { + dietaryPreference: string | null; + allergies: string | null; + maxCookingTime: number | null; + equipment: string[]; + cuisinePreference: string | null; + servingsDefault: number | null; } export interface AuthResponse { diff --git a/frontend/src/api/recipe.ts b/frontend/src/api/recipe.ts index b08dd43..fdbaef2 100644 --- a/frontend/src/api/recipe.ts +++ b/frontend/src/api/recipe.ts @@ -79,8 +79,120 @@ export const deleteRecipe = async (id: string): Promise => { } }; +// ----------------------------------------------------------------------------- +// Streaming (SSE via fetch + ReadableStream) +// ----------------------------------------------------------------------------- + +export type StreamEvent = + | { type: 'progress'; step: string } + | { type: 'transcription'; text: string } + | { type: 'title'; title: string } + | { type: 'description'; description: string } + | { type: 'delta'; content: string } + | { type: 'recipe'; recipe: StructuredRecipe } + | { type: 'image'; url: string | null } + | { type: 'saved'; id: string } + | { type: 'error'; message: string } + | { type: 'done' }; + +export interface StructuredRecipe { + titre: string; + description: string; + origine_inspiration: string; + ingredients: Array<{ + nom: string; + quantite: string; + notes: string | null; + complement: boolean; + }>; + etapes: Array<{ + numero: number; + instruction: string; + duree_minutes: number | null; + }>; + temps_preparation: number; + temps_cuisson: number; + portions: number; + difficulte: 'facile' | 'moyen' | 'difficile'; + conseils: string[]; + accord_boisson: string | null; +} + +const API_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; + +/** + * Lance une création de recette en streaming SSE. + * Retourne un AsyncIterable d'événements que le composant peut consommer + * avec `for await`. + */ +export async function* createRecipeStream( + audioFile: File +): AsyncGenerator { + const token = localStorage.getItem('token'); + const formData = new FormData(); + formData.append('file', audioFile); + + const response = await fetch(`${API_URL}/recipes/create-stream`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errText}`); + } + + if (!response.body) { + throw new Error('Pas de body dans la réponse'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Les événements SSE sont séparés par une ligne vide (\n\n) + let sepIndex: number; + while ((sepIndex = buffer.indexOf('\n\n')) !== -1) { + const rawEvent = buffer.slice(0, sepIndex); + buffer = buffer.slice(sepIndex + 2); + + // Parse les lignes event: et data: + let eventName = 'message'; + let dataLine = ''; + for (const line of rawEvent.split('\n')) { + if (line.startsWith('event: ')) eventName = line.slice(7).trim(); + else if (line.startsWith('data: ')) dataLine += line.slice(6); + // Ignore les heartbeats (lignes commençant par ':') + } + + if (!dataLine) continue; + + try { + const data = JSON.parse(dataLine); + yield { type: eventName, ...data } as StreamEvent; + } catch (err) { + console.warn('Event SSE non parsable:', rawEvent, err); + } + } + } + } finally { + reader.releaseLock(); + } +} + +// ----------------------------------------------------------------------------- + export const recipeService = { createRecipe, + createRecipeStream, getRecipes, getRecipeById, deleteRecipe, diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 52e3d50..d9289e1 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -1,4 +1,5 @@ import { apiService } from "./base"; +import type { User, UserPreferences } from "./auth"; const userService = { getCurrentUser: async (): Promise => { @@ -11,6 +12,14 @@ const userService = { return response.user; }, + updatePreferences: async (data: Partial): Promise => { + const response = await apiService.put<{ preferences: UserPreferences }>( + 'users/preferences', + data + ); + return response.preferences; + }, + changePassword: async (data: { currentPassword: string; newPassword: string }): Promise => { return apiService.put('users/change-password', data); }, diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index c61dd70..31f9694 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -8,7 +8,8 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Separator } from "@/components/ui/separator"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { AlertCircle, Save, User, Lock, LogOut, Trash2, Mail } from "lucide-react"; +import { AlertCircle, Save, User, Lock, LogOut, Trash2, Mail, ChefHat } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; import { recipeService, Recipe } from "@/api/recipe"; import userService from "@/api/user"; @@ -20,8 +21,25 @@ interface User { name: string; subscription?: string; createdAt: string; + dietaryPreference?: string | null; + allergies?: string | null; + maxCookingTime?: number | null; + equipment?: string[]; + cuisinePreference?: string | null; + servingsDefault?: number | null; } +const EQUIPMENT_OPTIONS = [ + { key: "plaque", label: "Plaque / gazinière" }, + { key: "four", label: "Four" }, + { key: "micro-ondes", label: "Micro-ondes" }, + { key: "mixeur", label: "Mixeur / blender" }, + { key: "robot", label: "Robot pâtissier" }, + { key: "friteuse", label: "Friteuse à air" }, + { key: "barbecue", label: "Barbecue / plancha" }, + { key: "cuiseur-vapeur", label: "Cuiseur vapeur" }, +]; + // Service utilisateur export default function Profile() { @@ -51,6 +69,23 @@ export default function Profile() { password: "" }); + // Préférences culinaires + const [prefsForm, setPrefsForm] = useState<{ + dietaryPreference: string; + allergies: string; + maxCookingTime: string; + equipment: string[]; + cuisinePreference: string; + servingsDefault: string; + }>({ + dietaryPreference: "none", + allergies: "", + maxCookingTime: "", + equipment: [], + cuisinePreference: "", + servingsDefault: "", + }); + useEffect(() => { const fetchUserData = async () => { try { @@ -62,6 +97,14 @@ export default function Profile() { setProfileForm({ name: userData.name || "" }); + setPrefsForm({ + dietaryPreference: userData.dietaryPreference || "none", + allergies: userData.allergies || "", + maxCookingTime: userData.maxCookingTime ? String(userData.maxCookingTime) : "", + equipment: userData.equipment || [], + cuisinePreference: userData.cuisinePreference || "", + servingsDefault: userData.servingsDefault ? String(userData.servingsDefault) : "", + }); // Récupérer les recettes de l'utilisateur const recipes = await recipeService.getRecipes(); @@ -116,6 +159,39 @@ export default function Profile() { } }; + const handlePrefsSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSuccess(""); + + try { + setSaving(true); + await userService.updatePreferences({ + dietaryPreference: prefsForm.dietaryPreference === "none" ? null : prefsForm.dietaryPreference, + allergies: prefsForm.allergies.trim() || null, + maxCookingTime: prefsForm.maxCookingTime ? parseInt(prefsForm.maxCookingTime, 10) : null, + equipment: prefsForm.equipment.length > 0 ? prefsForm.equipment : null, + cuisinePreference: prefsForm.cuisinePreference.trim() || null, + servingsDefault: prefsForm.servingsDefault ? parseInt(prefsForm.servingsDefault, 10) : null, + }); + setSuccess("Préférences culinaires mises à jour"); + } catch (err) { + console.error("Erreur lors de la mise à jour des préférences:", err); + setError("Impossible de mettre à jour les préférences"); + } finally { + setSaving(false); + } + }; + + const toggleEquipment = (key: string) => { + setPrefsForm((prev) => ({ + ...prev, + equipment: prev.equipment.includes(key) + ? prev.equipment.filter((e) => e !== key) + : [...prev.equipment, key], + })); + }; + const handlePasswordSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -321,18 +397,22 @@ export default function Profile() {
- + Profil + + + Cuisine + Email - Mot de passe + Sécurité @@ -374,6 +454,138 @@ export default function Profile() { + + + + Préférences culinaires + + Ces informations sont transmises au chef IA pour personnaliser chaque recette + + + +
+ {/* Régime */} +
+ + +
+ + {/* Allergies */} +
+ + + setPrefsForm({ ...prefsForm, allergies: e.target.value }) + } + /> +

+ Séparées par des virgules. Le chef les évitera impérativement. +

+
+ + {/* Temps max */} +
+
+ + + setPrefsForm({ ...prefsForm, maxCookingTime: e.target.value }) + } + /> +
+
+ + + setPrefsForm({ ...prefsForm, servingsDefault: e.target.value }) + } + /> +
+
+ + {/* Cuisine préférée */} +
+ + + setPrefsForm({ ...prefsForm, cuisinePreference: e.target.value }) + } + /> +
+ + {/* Équipement */} +
+ +
+ {EQUIPMENT_OPTIONS.map((opt) => ( +
+ toggleEquipment(opt.key)} + /> + +
+ ))} +
+

+ Le chef n'utilisera que des techniques compatibles avec ton équipement. +

+
+ + +
+
+
+
+ diff --git a/frontend/src/pages/Recipes/RecipeForm.tsx b/frontend/src/pages/Recipes/RecipeForm.tsx index 2d43202..6808d40 100644 --- a/frontend/src/pages/Recipes/RecipeForm.tsx +++ b/frontend/src/pages/Recipes/RecipeForm.tsx @@ -35,6 +35,13 @@ export default function RecipeForm() { const [recordingTime, setRecordingTime] = useState(0) const [showTips, setShowTips] = useState(false) + // Live streaming state + const [progressStep, setProgressStep] = useState("") + const [liveTranscription, setLiveTranscription] = useState("") + const [liveTitle, setLiveTitle] = useState("") + const [liveDescription, setLiveDescription] = useState("") + const [liveImageUrl, setLiveImageUrl] = useState(null) + // Update audioFile when recording is available useEffect(() => { if (currentRecording) { @@ -91,7 +98,7 @@ export default function RecipeForm() { setError("") } - // Soumettre le formulaire + // Soumettre le formulaire — utilise le streaming SSE const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -103,19 +110,63 @@ export default function RecipeForm() { setLoading(true) setError("") setRecordingStatus("processing") + setProgressStep("") + setLiveTranscription("") + setLiveTitle("") + setLiveDescription("") + setLiveImageUrl(null) try { - const recipe = await recipeService.createRecipe(audioFile) - navigate(`/recipes/${recipe.id}`) + let savedId: string | null = null + + for await (const event of recipeService.createRecipeStream(audioFile)) { + switch (event.type) { + case "progress": + setProgressStep(event.step) + break + case "transcription": + setLiveTranscription(event.text) + break + case "title": + setLiveTitle(event.title) + break + case "description": + setLiveDescription(event.description) + break + case "image": + setLiveImageUrl(event.url) + break + case "saved": + savedId = event.id + break + case "error": + throw new Error(event.message) + case "done": + break + } + } + + if (savedId) { + // Petit délai pour que l'utilisateur voie l'image finale avant de partir + setTimeout(() => navigate(`/recipes/${savedId}`), 800) + } } catch (err) { console.error("Erreur lors de la création de la recette:", err) setError(err instanceof Error ? err.message : "Une erreur est survenue lors de la création de la recette") setRecordingStatus("idle") - } finally { setLoading(false) } } + // Libellés des étapes + const stepLabels: Record = { + saving_audio: "Envoi de ton enregistrement…", + transcribing: "Je t'écoute attentivement…", + generating_recipe: "Antoine invente ta recette…", + generating_image: "Préparation de la photo du plat…", + finalizing: "Derniers préparatifs…", + } + // Format recording time const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60) @@ -199,13 +250,80 @@ export default function RecipeForm() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="flex-1 flex flex-col items-center justify-center" + className="flex-1 flex flex-col items-center justify-center px-4" > - -

Préparation en cours...

-

- Notre chef IA mijote quelque chose de délicieux avec vos ingrédients -

+ {/* Image en cours de génération ou finale */} +
+ {liveImageUrl ? ( + + ) : ( + + )} +
+ + {/* Titre live (apparaît dès que le stream le révèle) */} + + {liveTitle ? ( + + {liveTitle} + + ) : ( + + {stepLabels[progressStep] || "Préparation en cours…"} + + )} + + + {/* Description live */} + + {liveDescription && ( + + « {liveDescription} » + + )} + + + {/* Transcription live (quand pas encore de titre) */} + + {liveTranscription && !liveTitle && ( + + Ingrédients détectés : + {liveTranscription} + + )} + + + {/* Libellé d'étape quand un titre est déjà affiché */} + {liveTitle && progressStep && ( +

+ {stepLabels[progressStep] || "En cours…"} +

+ )} ) : isRecording ? (