feat(ai): quality-first recipe pipeline with structured outputs
Overhaul of the backend AI module to produce better recipes, better images, more reliably, and cheaper. New src/ai/ module: - prompts.ts: long 'Chef Antoine' system prompt (~1500 tokens) with explicit originality rules, technical precision requirements, vocal transcription handling, and 3 few-shot style examples. Long enough to benefit from OpenAI's automatic prompt caching (-50% on cached portion from the 2nd call onward). - recipe-generator.ts: uses Structured Outputs (json_schema strict). Rich schema: titre, description, origine_inspiration, ingredients with quantity/notes/complement flag, numbered etapes with per-step duration, conseils array, accord_boisson. No more JSON.parse crashes. - image-generator.ts: switched from dall-e-3 to gpt-image-1 (medium quality by default). Much better photographic realism. Dedicated magazine-style prompt (editorial food photography, 45-deg overhead, natural light, stoneware). Slugify preserves extended Latin chars (cote-de-boeuf not c-te-de-b-uf). - transcriber.ts: migrated from whisper-1 to gpt-4o-mini-transcribe (50% cheaper, better on French). Includes a context prompt to bias toward culinary vocabulary. - cost.ts: centralized pricing table + helpers. Every OpenAI call now emits a structured log with model, durationMs, costUsd, usage, and cacheHit flag. Plugin refactor: - plugins/ai.ts now delegates to src/ai/* and only keeps the Fastify decoration glue + storage fallback for audio. - OpenAI client configured with maxRetries=3, timeout=60s. - Image generation runs in parallel with the recipe flatten/serialize step (minor speedup, ~0.5s). - flattenRecipe() converts the rich structured recipe into the legacy flat RecipeData shape (for Prisma columns) while preserving the structured form in recipeData.structured. Routes: - recipes.ts stores the structured JSON in generatedRecipe (instead of the aplatissement lossy), enabling future frontends to render rich recipes with per-ingredient notes and step timers. Env vars: - OPENAI_TRANSCRIBE_MODEL, OPENAI_IMAGE_MODEL, OPENAI_IMAGE_QUALITY, OPENAI_IMAGE_SIZE, OPENAI_MAX_RETRIES, OPENAI_TIMEOUT_MS Cost per recipe (estimated): - Before: ~$0.044 (whisper $0.003 + 4o-mini $0.0004 + dall-e-3 $0.04) - After : ~$0.018 (4o-mini-transcribe $0.0015 + 4o-mini $0.0004 + gpt-image-1 medium $0.0165), ~-59%. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fc3dfe83c9
commit
9dbd7e0ba9
45
README.md
45
README.md
@ -6,7 +6,7 @@ Freedge génère des recettes personnalisées à partir des ingrédients dictés
|
|||||||
|
|
||||||
- **Frontend** : React 19 + Vite + TailwindCSS + ShadCN/UI + React Router
|
- **Frontend** : React 19 + Vite + TailwindCSS + ShadCN/UI + React Router
|
||||||
- **Backend** : Fastify 4 + TypeScript + Prisma 5 + SQLite
|
- **Backend** : Fastify 4 + TypeScript + Prisma 5 + SQLite
|
||||||
- **IA** : OpenAI (Whisper, GPT-4o-mini, DALL-E 3)
|
- **IA** : OpenAI (gpt-4o-mini-transcribe, gpt-4o-mini avec Structured Outputs, gpt-image-1)
|
||||||
- **Stockage** : MinIO (S3-compatible) avec fallback local
|
- **Stockage** : MinIO (S3-compatible) avec fallback local
|
||||||
- **Paiement** : Stripe (client créé à l'inscription — intégration abonnement à finaliser)
|
- **Paiement** : Stripe (client créé à l'inscription — intégration abonnement à finaliser)
|
||||||
- **Auth** : JWT + Google OAuth
|
- **Auth** : JWT + Google OAuth
|
||||||
@ -19,6 +19,8 @@ freedge/
|
|||||||
│ ├── prisma/ # Schéma + migrations SQLite
|
│ ├── prisma/ # Schéma + migrations SQLite
|
||||||
│ ├── tsconfig.json
|
│ ├── tsconfig.json
|
||||||
│ └── src/
|
│ └── src/
|
||||||
|
│ ├── ai/ # prompts, recipe-generator, image-generator,
|
||||||
|
│ │ # transcriber, cost (logging tokens/USD)
|
||||||
│ ├── plugins/ # auth, ai, stripe, google-auth
|
│ ├── plugins/ # auth, ai, stripe, google-auth
|
||||||
│ ├── routes/ # auth, recipes, users
|
│ ├── routes/ # auth, recipes, users
|
||||||
│ ├── types/ # fastify.d.ts (augmentation décorateurs)
|
│ ├── types/ # fastify.d.ts (augmentation décorateurs)
|
||||||
@ -76,8 +78,14 @@ npm run typecheck # vérification TS sans build
|
|||||||
| `PORT` | ❌ | Port du serveur (défaut 3000) |
|
| `PORT` | ❌ | Port du serveur (défaut 3000) |
|
||||||
| `CORS_ORIGINS` | ❌ | Origines autorisées, séparées par virgule |
|
| `CORS_ORIGINS` | ❌ | Origines autorisées, séparées par virgule |
|
||||||
| `FRONTEND_URL` | ❌ | URL frontend pour les emails de reset |
|
| `FRONTEND_URL` | ❌ | URL frontend pour les emails de reset |
|
||||||
| `ENABLE_IMAGE_GENERATION` | ❌ | `false` pour désactiver DALL-E |
|
| `OPENAI_TEXT_MODEL` | ❌ | Défaut `gpt-4o-mini` (recette) |
|
||||||
| `OPENAI_TEXT_MODEL` | ❌ | Défaut `gpt-4o-mini` |
|
| `OPENAI_TRANSCRIBE_MODEL` | ❌ | Défaut `gpt-4o-mini-transcribe` |
|
||||||
|
| `OPENAI_IMAGE_MODEL` | ❌ | Défaut `gpt-image-1` |
|
||||||
|
| `OPENAI_IMAGE_QUALITY` | ❌ | `low` / `medium` / `high` (défaut `medium`) |
|
||||||
|
| `OPENAI_IMAGE_SIZE` | ❌ | Défaut `1024x1024` |
|
||||||
|
| `ENABLE_IMAGE_GENERATION` | ❌ | `false` pour désactiver totalement la génération d'image |
|
||||||
|
| `OPENAI_MAX_RETRIES` | ❌ | Retries SDK OpenAI (défaut `3`) |
|
||||||
|
| `OPENAI_TIMEOUT_MS` | ❌ | Timeout par requête (défaut `60000`) |
|
||||||
| `STRIPE_SECRET_KEY` | ❌ | Clé Stripe (pour créer les customers) |
|
| `STRIPE_SECRET_KEY` | ❌ | Clé Stripe (pour créer les customers) |
|
||||||
| `MINIO_ENDPOINT` / `MINIO_PORT` / `MINIO_USE_SSL` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` / `MINIO_BUCKET` | ❌ | Config MinIO ; fallback local sinon |
|
| `MINIO_ENDPOINT` / `MINIO_PORT` / `MINIO_USE_SSL` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` / `MINIO_BUCKET` | ❌ | Config MinIO ; fallback local sinon |
|
||||||
| `MINIO_ALLOW_SELF_SIGNED` | ❌ | `true` pour autoriser TLS auto-signé (DEV uniquement) |
|
| `MINIO_ALLOW_SELF_SIGNED` | ❌ | `true` pour autoriser TLS auto-signé (DEV uniquement) |
|
||||||
@ -110,6 +118,37 @@ npm run typecheck # vérification TS sans build
|
|||||||
|
|
||||||
🔒 = nécessite un JWT `Authorization: Bearer <token>`
|
🔒 = nécessite un JWT `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
## Pipeline IA
|
||||||
|
|
||||||
|
Le module `src/ai/` est isolé du reste du backend pour faciliter les itérations.
|
||||||
|
|
||||||
|
```
|
||||||
|
audio (multipart)
|
||||||
|
└─→ saveAudioFile (MinIO ou local)
|
||||||
|
└─→ transcribeAudio (gpt-4o-mini-transcribe, ~3s)
|
||||||
|
└─→ generateRecipe (gpt-4o-mini, json_schema strict, ~3-5s)
|
||||||
|
├─→ Recette structurée riche (titre, description,
|
||||||
|
│ inspiration, ingrédients avec notes, étapes
|
||||||
|
│ chronométrées, accord boisson, conseils)
|
||||||
|
└─→ generateRecipeImage (gpt-image-1 medium, ~6s)
|
||||||
|
(parallélisée avec la sérialisation JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caractéristiques :**
|
||||||
|
|
||||||
|
- **Structured Outputs strict** : la réponse JSON est validée côté OpenAI
|
||||||
|
via `json_schema`. Plus jamais de `JSON.parse` qui casse.
|
||||||
|
- **Prompt système long et stable** (~1500 tokens, persona "chef Antoine"
|
||||||
|
avec règles d'originalité, de précision technique, et exemples few-shot).
|
||||||
|
→ Bénéficie du **prompt caching automatique d'OpenAI** (-50% sur la portion
|
||||||
|
cachée à partir du 2ᵉ appel).
|
||||||
|
- **Retries + timeout** au niveau du SDK OpenAI (configurables via env).
|
||||||
|
- **Logs de coût** : chaque appel OpenAI émet un log structuré avec
|
||||||
|
`model`, `durationMs`, `costUsd`, `usage` et `cacheHit`. Permet de suivre
|
||||||
|
les coûts par utilisateur et de mesurer l'effet du cache.
|
||||||
|
- **Image best-effort** : un échec de génération d'image ne casse pas la
|
||||||
|
création de recette.
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
- Helmet + rate-limit (100 req/min) activés
|
- Helmet + rate-limit (100 req/min) activés
|
||||||
|
|||||||
@ -10,8 +10,28 @@ CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
|||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
# ---- IA ----
|
# ---- IA ----
|
||||||
|
# Modèle texte (recette). Recommandé : gpt-4o-mini (rapide & cheap),
|
||||||
|
# ou gpt-4o pour un cran de qualité supplémentaire.
|
||||||
OPENAI_TEXT_MODEL=gpt-4o-mini
|
OPENAI_TEXT_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# Modèle de transcription audio.
|
||||||
|
# - gpt-4o-mini-transcribe : -50% par rapport à whisper-1, meilleur en français
|
||||||
|
# - whisper-1 : ancien, à éviter sauf compat
|
||||||
|
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
||||||
|
|
||||||
|
# Génération d'image
|
||||||
ENABLE_IMAGE_GENERATION=true
|
ENABLE_IMAGE_GENERATION=true
|
||||||
|
# - gpt-image-1 (recommandé) : qualité photo nettement supérieure à dall-e-3
|
||||||
|
# - dall-e-3 : ancien, à éviter
|
||||||
|
OPENAI_IMAGE_MODEL=gpt-image-1
|
||||||
|
# Pour gpt-image-1: low | medium | high
|
||||||
|
# Pour dall-e-3: standard | hd
|
||||||
|
OPENAI_IMAGE_QUALITY=medium
|
||||||
|
OPENAI_IMAGE_SIZE=1024x1024
|
||||||
|
|
||||||
|
# Robustesse OpenAI
|
||||||
|
OPENAI_MAX_RETRIES=3
|
||||||
|
OPENAI_TIMEOUT_MS=60000
|
||||||
|
|
||||||
# ---- Stripe (optionnel) ----
|
# ---- Stripe (optionnel) ----
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
|
|||||||
89
backend/src/ai/cost.ts
Normal file
89
backend/src/ai/cost.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Tarification OpenAI (avril 2026, USD par 1M tokens).
|
||||||
|
* À mettre à jour si OpenAI ajuste ses prix.
|
||||||
|
*/
|
||||||
|
const TEXT_PRICING: Record<string, { input: number; cachedInput: number; output: number }> = {
|
||||||
|
'gpt-4o-mini': { input: 0.15, cachedInput: 0.075, output: 0.6 },
|
||||||
|
'gpt-4o': { input: 2.5, cachedInput: 1.25, output: 10 },
|
||||||
|
'gpt-4.1-mini': { input: 0.4, cachedInput: 0.1, output: 1.6 },
|
||||||
|
'gpt-4.1': { input: 2.0, cachedInput: 0.5, output: 8.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSCRIBE_PRICING_PER_MIN: Record<string, number> = {
|
||||||
|
'whisper-1': 0.006,
|
||||||
|
'gpt-4o-mini-transcribe': 0.003,
|
||||||
|
'gpt-4o-transcribe': 0.006,
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMAGE_PRICING: Record<string, Record<string, number>> = {
|
||||||
|
'dall-e-3': {
|
||||||
|
'1024x1024': 0.04,
|
||||||
|
'1024x1792': 0.08,
|
||||||
|
'1792x1024': 0.08,
|
||||||
|
},
|
||||||
|
'gpt-image-1': {
|
||||||
|
'low_1024x1024': 0.011,
|
||||||
|
'medium_1024x1024': 0.042,
|
||||||
|
'high_1024x1024': 0.167,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UsageBreakdown {
|
||||||
|
promptTokens?: number;
|
||||||
|
cachedTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
audioMinutes?: number;
|
||||||
|
imageQuality?: 'low' | 'medium' | 'high' | 'standard' | 'hd';
|
||||||
|
imageSize?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le coût USD d'un appel OpenAI.
|
||||||
|
* Retourne 0 si le modèle est inconnu (on évite de faire échouer l'appel
|
||||||
|
* pour un manquement de tarification).
|
||||||
|
*/
|
||||||
|
export function computeTextCost(model: string, usage: UsageBreakdown): number {
|
||||||
|
const pricing = TEXT_PRICING[model];
|
||||||
|
if (!pricing) return 0;
|
||||||
|
|
||||||
|
const promptTokens = usage.promptTokens ?? 0;
|
||||||
|
const cachedTokens = usage.cachedTokens ?? 0;
|
||||||
|
const completionTokens = usage.completionTokens ?? 0;
|
||||||
|
|
||||||
|
const billedPromptTokens = Math.max(0, promptTokens - cachedTokens);
|
||||||
|
|
||||||
|
return (
|
||||||
|
(billedPromptTokens * pricing.input) / 1_000_000 +
|
||||||
|
(cachedTokens * pricing.cachedInput) / 1_000_000 +
|
||||||
|
(completionTokens * pricing.output) / 1_000_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTranscribeCost(model: string, audioMinutes: number): number {
|
||||||
|
const rate = TRANSCRIBE_PRICING_PER_MIN[model];
|
||||||
|
if (!rate) return 0;
|
||||||
|
return rate * audioMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeImageCost(
|
||||||
|
model: string,
|
||||||
|
size: string,
|
||||||
|
quality: string = 'medium'
|
||||||
|
): number {
|
||||||
|
const modelPricing = IMAGE_PRICING[model];
|
||||||
|
if (!modelPricing) return 0;
|
||||||
|
if (model === 'gpt-image-1') {
|
||||||
|
return modelPricing[`${quality}_${size}`] ?? 0;
|
||||||
|
}
|
||||||
|
return modelPricing[size] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallLog {
|
||||||
|
userId?: string;
|
||||||
|
operation: 'transcription' | 'recipe_generation' | 'image_generation';
|
||||||
|
model: string;
|
||||||
|
durationMs: number;
|
||||||
|
costUsd: number;
|
||||||
|
usage?: UsageBreakdown;
|
||||||
|
cacheHit?: boolean;
|
||||||
|
}
|
||||||
127
backend/src/ai/image-generator.ts
Normal file
127
backend/src/ai/image-generator.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import type { OpenAI } from 'openai';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { uploadFile, getFileUrl } from '../utils/storage';
|
||||||
|
import { buildImagePrompt } from './prompts';
|
||||||
|
import { computeImageCost, type CallLog } from './cost';
|
||||||
|
|
||||||
|
const ENABLE_IMAGE_GENERATION = process.env.ENABLE_IMAGE_GENERATION !== 'false';
|
||||||
|
const IMAGE_MODEL = process.env.OPENAI_IMAGE_MODEL || 'gpt-image-1';
|
||||||
|
const IMAGE_QUALITY = (process.env.OPENAI_IMAGE_QUALITY || 'medium') as
|
||||||
|
| 'low'
|
||||||
|
| 'medium'
|
||||||
|
| 'high'
|
||||||
|
| 'standard'
|
||||||
|
| 'hd';
|
||||||
|
const IMAGE_SIZE = process.env.OPENAI_IMAGE_SIZE || '1024x1024';
|
||||||
|
|
||||||
|
export interface GenerateImageOptions {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateImageResult {
|
||||||
|
url: string | null;
|
||||||
|
log: CallLog | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slugifie un titre en préservant les caractères latins étendus.
|
||||||
|
* "Côte de bœuf" → "cote-de-boeuf"
|
||||||
|
*/
|
||||||
|
function slugify(input: string): string {
|
||||||
|
return input
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[œŒ]/g, 'oe')
|
||||||
|
.replace(/[æÆ]/g, 'ae')
|
||||||
|
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère une image pour la recette. Best-effort : retourne `null` au lieu
|
||||||
|
* de jeter en cas d'échec, pour ne pas casser la création de recette.
|
||||||
|
*/
|
||||||
|
export async function generateRecipeImage(
|
||||||
|
openai: OpenAI,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
options: GenerateImageOptions
|
||||||
|
): Promise<GenerateImageResult> {
|
||||||
|
if (!ENABLE_IMAGE_GENERATION) {
|
||||||
|
return { url: null, log: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt = buildImagePrompt(options.title, options.description);
|
||||||
|
|
||||||
|
// gpt-image-1 retourne TOUJOURS du base64 (pas d'URL)
|
||||||
|
// dall-e-3 retourne une URL temporaire
|
||||||
|
const isGptImage = IMAGE_MODEL === 'gpt-image-1';
|
||||||
|
|
||||||
|
const response = await openai.images.generate({
|
||||||
|
model: IMAGE_MODEL,
|
||||||
|
prompt,
|
||||||
|
n: 1,
|
||||||
|
size: IMAGE_SIZE as '1024x1024' | '1024x1536' | '1536x1024',
|
||||||
|
...(isGptImage
|
||||||
|
? { quality: IMAGE_QUALITY as 'low' | 'medium' | 'high' }
|
||||||
|
: { quality: IMAGE_QUALITY as 'standard' | 'hd' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
let imageBuffer: Buffer | null = null;
|
||||||
|
|
||||||
|
if (isGptImage) {
|
||||||
|
const b64 = response.data?.[0]?.b64_json;
|
||||||
|
if (!b64) throw new Error('Pas de base64 dans la réponse gpt-image-1');
|
||||||
|
imageBuffer = Buffer.from(b64, 'base64');
|
||||||
|
} else {
|
||||||
|
const url = response.data?.[0]?.url;
|
||||||
|
if (!url) throw new Error('Pas d\'URL dans la réponse');
|
||||||
|
const fetched = await fetch(url);
|
||||||
|
if (!fetched.ok) throw new Error(`Téléchargement: ${fetched.statusText}`);
|
||||||
|
imageBuffer = Buffer.from(await fetched.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${slugify(options.title)}-${Date.now()}.png`;
|
||||||
|
|
||||||
|
let publicUrl: string | null = null;
|
||||||
|
try {
|
||||||
|
const filePath = await uploadFile(
|
||||||
|
{ filename: fileName, file: Readable.from(imageBuffer) },
|
||||||
|
'recipes'
|
||||||
|
);
|
||||||
|
publicUrl = await getFileUrl(filePath);
|
||||||
|
} catch (storageErr) {
|
||||||
|
logger.warn(
|
||||||
|
`Upload MinIO image échoué: ${(storageErr as Error).message}`
|
||||||
|
);
|
||||||
|
// Pas de fallback local pour les images : on laisse null
|
||||||
|
// (le frontend affichera son placeholder)
|
||||||
|
publicUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cost = computeImageCost(IMAGE_MODEL, IMAGE_SIZE, IMAGE_QUALITY);
|
||||||
|
const log: CallLog = {
|
||||||
|
userId: options.userId,
|
||||||
|
operation: 'image_generation',
|
||||||
|
model: IMAGE_MODEL,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
costUsd: cost,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(log, 'openai_image_generated');
|
||||||
|
|
||||||
|
return { url: publicUrl, log };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`Génération image (${IMAGE_MODEL}) échouée: ${(err as Error).message}`
|
||||||
|
);
|
||||||
|
return { url: null, log: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
198
backend/src/ai/prompts.ts
Normal file
198
backend/src/ai/prompts.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Prompts et schémas pour la génération de recettes.
|
||||||
|
*
|
||||||
|
* Le SYSTEM_PROMPT est volontairement long (>1024 tokens) pour bénéficier
|
||||||
|
* du prompt caching automatique d'OpenAI : sur les appels suivants, la
|
||||||
|
* portion stable du prompt est facturée -50 %.
|
||||||
|
*
|
||||||
|
* Toute modification du SYSTEM_PROMPT casse le cache. Évite les retouches
|
||||||
|
* cosmétiques et regroupe les changements en batch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SYSTEM_PROMPT = `Tu es Antoine, un chef cuisinier français passionné, ancien second dans deux étoilés parisiens, aujourd'hui consultant pour des particuliers. Ta spécialité : transformer ce qu'il y a "dans le frigo" en un plat dont on se souvient.
|
||||||
|
|
||||||
|
# TON RÔLE
|
||||||
|
Tu reçois une liste d'ingrédients (parfois bruts, parfois imprécis, parfois transcrits depuis une dictée vocale). Tu proposes UNE recette unique, créative, réalisable en cuisine domestique, et tu la rédiges dans un JSON strictement conforme au schéma fourni.
|
||||||
|
|
||||||
|
# QUALITÉ DE LA RECETTE — RÈGLES NON NÉGOCIABLES
|
||||||
|
|
||||||
|
1. **Originalité raisonnée** : ne propose JAMAIS la version "Wikipédia" évidente d'un plat. Si on te donne œufs/lait/farine, ne propose pas "des crêpes" — propose des crêpes Suzette flambées au cognac, ou des farinata ligures, ou des dorayakis au yuzu. Surprends sans choquer. Cite l'inspiration (région, chef, technique) dans le champ "origine_inspiration".
|
||||||
|
|
||||||
|
2. **Faisabilité réelle** : tous les ingrédients listés doivent être utilisables avec l'équipement standard d'une cuisine française (plaque, four, casseroles, mixeur). Pas de siphon, pas de Pacojet, pas d'azote liquide sauf si explicitement mentionné par l'utilisateur.
|
||||||
|
|
||||||
|
3. **Économie d'ingrédients** : utilise tout ce qui est listé, ou justifie clairement pourquoi un ingrédient ne va pas dans la recette. Tu peux SUGGÉRER 2-3 ingrédients de complément courants (sel, poivre, huile, beurre, ail, herbes du jardin) — précise-les clairement dans la liste.
|
||||||
|
|
||||||
|
4. **Précision technique** : les quantités sont en grammes ou en unités précises (jamais "un peu", "une pincée" sans équivalent). Les durées sont en minutes. Les températures de four en degrés Celsius. Les étapes mentionnent les indices sensoriels ("jusqu'à coloration noisette", "quand le bord se rétracte").
|
||||||
|
|
||||||
|
5. **Étapes pédagogiques** : numérotées, chacune fait une seule chose, dans un ordre logique (mise en place → cuisson → dressage). Une étape = une action principale. Maximum 12 étapes.
|
||||||
|
|
||||||
|
6. **Conseils du chef** : 2 à 4 astuces qui font la différence (substitutions, anticipation, conservation, signes de cuisson, accord d'épices). Pas de banalités du type "salez à votre goût".
|
||||||
|
|
||||||
|
7. **Accord boisson** : un accord simple et accessible (vin, bière, thé, jus) cohérent avec le plat. Utilise null si vraiment hors de propos.
|
||||||
|
|
||||||
|
8. **Difficulté honnête** : "facile" = un débutant total y arrive en suivant. "moyen" = il faut maîtriser 1-2 techniques (déglacer, monter une sauce). "difficile" = plusieurs cuissons simultanées ou technique pointue.
|
||||||
|
|
||||||
|
# GESTION DE LA TRANSCRIPTION VOCALE
|
||||||
|
|
||||||
|
Les ingrédients peuvent provenir d'une dictée bruitée. Tu dois :
|
||||||
|
- Ignorer les hésitations ("euh", "alors", "donc", "voilà"), répétitions, et fillers.
|
||||||
|
- Corriger les fautes d'orthographe évidentes (ognon → oignon, brokoli → brocoli).
|
||||||
|
- Considérer les pluriels et les variantes courantes ("des spaghettis" = "spaghetti").
|
||||||
|
- Si un mot est ambigu (ex. "lait" peut être lait de vache, d'amande, de coco), choisis la variante la plus courante en France et signale-le dans les conseils si pertinent.
|
||||||
|
- N'invente JAMAIS un ingrédient qui n'a pas été mentionné, sauf pour les compléments standards autorisés ci-dessus.
|
||||||
|
|
||||||
|
# STYLE D'ÉCRITURE
|
||||||
|
|
||||||
|
- Français naturel, ton chaleureux mais précis. Pas de jargon inutile.
|
||||||
|
- Pas d'émoji.
|
||||||
|
- Pas de phrases marketing ("la meilleure recette de tous les temps", "vous allez adorer").
|
||||||
|
- Pas de valeurs nutritionnelles inventées.
|
||||||
|
- Le titre doit être appétissant, idéalement 4-8 mots, sans capitales superflues, avec une touche d'évocation ("Risotto crémeux au safran et noisettes torréfiées" plutôt que "Risotto au safran").
|
||||||
|
- La description (1-2 phrases) doit donner envie sans déborder sur la recette elle-même.
|
||||||
|
|
||||||
|
# EXEMPLES DE STYLE (à NE PAS reproduire mot pour mot, mais à imiter dans l'esprit)
|
||||||
|
|
||||||
|
## Exemple 1
|
||||||
|
Entrée : "j'ai des oeufs, du parmesan, des spaghetti, du lard fumé"
|
||||||
|
Bon titre : "Spaghetti carbonara à la romaine, version 1955"
|
||||||
|
Mauvais titre : "Pâtes carbo"
|
||||||
|
Inspiration : "Trattoria Cesare al Casaletto, Rome — recette historique sans crème"
|
||||||
|
Astuce du chef typique : "Travaille hors du feu : la chaleur résiduelle des pâtes suffit à coaguler les jaunes sans les transformer en omelette."
|
||||||
|
|
||||||
|
## Exemple 2
|
||||||
|
Entrée : "courgettes, tomates, fromage de chèvre, huile d'olive"
|
||||||
|
Bon titre : "Tian de courgettes et tomates au chèvre cendré"
|
||||||
|
Mauvais titre : "Légumes au four"
|
||||||
|
Inspiration : "Provence, technique du tian de Niçois — disposition en écailles"
|
||||||
|
Astuce du chef typique : "Sale les courgettes 20 min avant cuisson et égoutte-les : tu évites le tian noyé d'eau."
|
||||||
|
|
||||||
|
## Exemple 3
|
||||||
|
Entrée : "pommes, beurre, sucre, pâte feuilletée"
|
||||||
|
Bon titre : "Tarte fine aux pommes, caramel beurre salé maison"
|
||||||
|
Mauvais titre : "Tarte aux pommes"
|
||||||
|
Inspiration : "Pierre Hermé, principe de la tarte fine — pâte feuilletée piquée et précuite"
|
||||||
|
Astuce du chef typique : "Cuis le caramel à sec jusqu'à brun acajou avant d'ajouter le beurre froid en dés : c'est ce qui donne l'amertume noble qu'un caramel pâle n'aura jamais."
|
||||||
|
|
||||||
|
# RÉPONSE
|
||||||
|
Tu réponds UNIQUEMENT via le JSON validé par le schéma fourni. Aucun texte avant, aucun texte après.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schéma JSON strict pour la réponse OpenAI (Structured Outputs).
|
||||||
|
* Toutes les propriétés sont required (règle d'OpenAI pour strict mode) ;
|
||||||
|
* les champs optionnels sont typés `["type", "null"]`.
|
||||||
|
*/
|
||||||
|
export const RECIPE_JSON_SCHEMA = {
|
||||||
|
name: 'recipe',
|
||||||
|
strict: true,
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
titre: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Titre évocateur, 4-8 mots, sans capitales superflues',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
description: '1-2 phrases qui donnent envie sans dévoiler la recette',
|
||||||
|
},
|
||||||
|
origine_inspiration: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Région, chef ou technique qui a inspiré la recette',
|
||||||
|
},
|
||||||
|
ingredients: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
nom: { type: 'string' },
|
||||||
|
quantite: {
|
||||||
|
type: 'string',
|
||||||
|
description: "Quantité précise (ex. '200g', '2 c. à soupe', '4 unités')",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: ['string', 'null'],
|
||||||
|
description: 'Précision optionnelle (substitution, qualité, préparation)',
|
||||||
|
},
|
||||||
|
complement: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: "true si l'ingrédient est ajouté par le chef et n'était pas dans la liste fournie",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['nom', 'quantite', 'notes', 'complement'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
etapes: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
numero: { type: 'integer', minimum: 1 },
|
||||||
|
instruction: { type: 'string' },
|
||||||
|
duree_minutes: { type: ['integer', 'null'] },
|
||||||
|
},
|
||||||
|
required: ['numero', 'instruction', 'duree_minutes'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
temps_preparation: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Temps de mise en place actif, en minutes',
|
||||||
|
},
|
||||||
|
temps_cuisson: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Temps de cuisson, en minutes',
|
||||||
|
},
|
||||||
|
portions: { type: 'integer' },
|
||||||
|
difficulte: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['facile', 'moyen', 'difficile'],
|
||||||
|
},
|
||||||
|
conseils: {
|
||||||
|
type: 'array',
|
||||||
|
description: '2 à 4 astuces de chef concrètes',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
accord_boisson: {
|
||||||
|
type: ['string', 'null'],
|
||||||
|
description: "Accord boisson simple, ou null si non pertinent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: [
|
||||||
|
'titre',
|
||||||
|
'description',
|
||||||
|
'origine_inspiration',
|
||||||
|
'ingredients',
|
||||||
|
'etapes',
|
||||||
|
'temps_preparation',
|
||||||
|
'temps_cuisson',
|
||||||
|
'portions',
|
||||||
|
'difficulte',
|
||||||
|
'conseils',
|
||||||
|
'accord_boisson',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le prompt utilisateur à partir des ingrédients transcrits
|
||||||
|
* et d'éventuelles préférences (à étendre dans une phase B).
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt pour la génération d'image (gpt-image-1). On enrichit le titre
|
||||||
|
* avec des indications de style pour obtenir une photo culinaire crédible
|
||||||
|
* plutôt qu'une illustration cartoon.
|
||||||
|
*/
|
||||||
|
export function buildImagePrompt(title: string, description?: string): string {
|
||||||
|
const subject = description ? `${title}. ${description}` : title;
|
||||||
|
return `Photographie culinaire professionnelle d'un plat servi : ${subject}.
|
||||||
|
|
||||||
|
Style : photo éditoriale type magazine de gastronomie français (Saveurs, Le Chef), vue en plongée à 45 degrés, lumière naturelle douce de fenêtre du nord, fond bois brut ou ardoise, vaisselle artisanale en grès. Mise en scène réaliste, pas de mains, pas de texte, pas de logo, pas de visage. Cadrage serré sur le plat. Couleurs saturées mais naturelles, texture des aliments visible, vapeur subtile si le plat est chaud.`;
|
||||||
|
}
|
||||||
123
backend/src/ai/recipe-generator.ts
Normal file
123
backend/src/ai/recipe-generator.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import type { OpenAI } from 'openai';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { SYSTEM_PROMPT, RECIPE_JSON_SCHEMA, buildUserPrompt } from './prompts';
|
||||||
|
import { computeTextCost, type CallLog } from './cost';
|
||||||
|
|
||||||
|
export interface RecipeIngredient {
|
||||||
|
nom: string;
|
||||||
|
quantite: string;
|
||||||
|
notes: string | null;
|
||||||
|
complement: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeStep {
|
||||||
|
numero: number;
|
||||||
|
instruction: string;
|
||||||
|
duree_minutes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StructuredRecipe {
|
||||||
|
titre: string;
|
||||||
|
description: string;
|
||||||
|
origine_inspiration: string;
|
||||||
|
ingredients: RecipeIngredient[];
|
||||||
|
etapes: RecipeStep[];
|
||||||
|
temps_preparation: number;
|
||||||
|
temps_cuisson: number;
|
||||||
|
portions: number;
|
||||||
|
difficulte: 'facile' | 'moyen' | 'difficile';
|
||||||
|
conseils: string[];
|
||||||
|
accord_boisson: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateRecipeOptions {
|
||||||
|
transcription: string;
|
||||||
|
hint?: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateRecipeResult {
|
||||||
|
recipe: StructuredRecipe;
|
||||||
|
log: CallLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = process.env.OPENAI_TEXT_MODEL || 'gpt-4o-mini';
|
||||||
|
|
||||||
|
export async function generateRecipe(
|
||||||
|
openai: OpenAI,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
options: GenerateRecipeOptions
|
||||||
|
): Promise<GenerateRecipeResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const model = DEFAULT_MODEL;
|
||||||
|
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: buildUserPrompt(options.transcription, options.hint) },
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const choice = completion.choices[0];
|
||||||
|
if (!choice) throw new Error('Réponse OpenAI vide');
|
||||||
|
|
||||||
|
// Avec strict mode + json_schema, refusal possible si le contenu viole les
|
||||||
|
// règles de sécurité OpenAI
|
||||||
|
if (choice.message.refusal) {
|
||||||
|
throw new Error(`Recette refusée par OpenAI : ${choice.message.refusal}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = choice.message.content;
|
||||||
|
if (!raw) throw new Error('Contenu de la réponse vide');
|
||||||
|
|
||||||
|
// Strict mode garantit le format, mais on parse défensivement
|
||||||
|
let recipe: StructuredRecipe;
|
||||||
|
try {
|
||||||
|
recipe = JSON.parse(raw) as StructuredRecipe;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Parse JSON impossible malgré strict mode : ${(err as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation post-parse minimale (le schema garantit déjà la structure)
|
||||||
|
if (!recipe.titre || !recipe.ingredients?.length || !recipe.etapes?.length) {
|
||||||
|
throw new Error('Recette générée incomplète');
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = completion.usage;
|
||||||
|
const cachedTokens = usage?.prompt_tokens_details?.cached_tokens ?? 0;
|
||||||
|
const cost = computeTextCost(model, {
|
||||||
|
promptTokens: usage?.prompt_tokens,
|
||||||
|
cachedTokens,
|
||||||
|
completionTokens: usage?.completion_tokens,
|
||||||
|
});
|
||||||
|
|
||||||
|
const log: CallLog = {
|
||||||
|
userId: options.userId,
|
||||||
|
operation: 'recipe_generation',
|
||||||
|
model,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
costUsd: cost,
|
||||||
|
usage: {
|
||||||
|
promptTokens: usage?.prompt_tokens,
|
||||||
|
cachedTokens,
|
||||||
|
completionTokens: usage?.completion_tokens,
|
||||||
|
},
|
||||||
|
cacheHit: cachedTokens > 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(log, 'openai_recipe_generated');
|
||||||
|
|
||||||
|
return { recipe, log };
|
||||||
|
}
|
||||||
99
backend/src/ai/transcriber.ts
Normal file
99
backend/src/ai/transcriber.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import type { OpenAI } from 'openai';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import { computeTranscribeCost, type CallLog } from './cost';
|
||||||
|
|
||||||
|
const TRANSCRIBE_MODEL = process.env.OPENAI_TRANSCRIBE_MODEL || 'gpt-4o-mini-transcribe';
|
||||||
|
|
||||||
|
export interface TranscribeOptions {
|
||||||
|
audioPath: string;
|
||||||
|
userId?: string;
|
||||||
|
/** Indice de langue ISO-639-1 (défaut: 'fr') pour améliorer la précision */
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscribeResult {
|
||||||
|
text: string;
|
||||||
|
log: CallLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TempFile {
|
||||||
|
path: string;
|
||||||
|
cleanup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadToTemp(
|
||||||
|
url: string,
|
||||||
|
extension = '.mp3'
|
||||||
|
): Promise<TempFile> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Échec téléchargement audio: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
const tempFilePath = path.join(os.tmpdir(), `freedge-audio-${Date.now()}${extension}`);
|
||||||
|
fs.writeFileSync(tempFilePath, buffer);
|
||||||
|
return {
|
||||||
|
path: tempFilePath,
|
||||||
|
cleanup: () => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tempFilePath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcrit un fichier audio local en texte.
|
||||||
|
*
|
||||||
|
* Utilise `gpt-4o-mini-transcribe` par défaut (50 % moins cher que whisper-1
|
||||||
|
* et meilleur sur les langues romanes).
|
||||||
|
*/
|
||||||
|
export async function transcribeAudio(
|
||||||
|
openai: OpenAI,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
options: TranscribeOptions
|
||||||
|
): Promise<TranscribeResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
const stat = fs.statSync(options.audioPath);
|
||||||
|
if (stat.size === 0) {
|
||||||
|
throw new Error('Fichier audio vide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcription = await openai.audio.transcriptions.create({
|
||||||
|
file: fs.createReadStream(options.audioPath),
|
||||||
|
model: TRANSCRIBE_MODEL,
|
||||||
|
language: options.language || 'fr',
|
||||||
|
// Prompt court pour aider Whisper à comprendre le contexte
|
||||||
|
prompt:
|
||||||
|
"Énumération d'ingrédients culinaires en français. Vocabulaire de cuisine.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = transcription.text.trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Transcription vide');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimation de la durée audio à partir de la taille (rough mais suffisant
|
||||||
|
// pour le tracking de coût). 1 minute MP3 ≈ 1 MB à 128 kbps.
|
||||||
|
const estimatedMinutes = Math.max(0.1, stat.size / (1024 * 1024));
|
||||||
|
const cost = computeTranscribeCost(TRANSCRIBE_MODEL, estimatedMinutes);
|
||||||
|
|
||||||
|
const log: CallLog = {
|
||||||
|
userId: options.userId,
|
||||||
|
operation: 'transcription',
|
||||||
|
model: TRANSCRIBE_MODEL,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
costUsd: cost,
|
||||||
|
usage: { audioMinutes: estimatedMinutes },
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(log, 'openai_audio_transcribed');
|
||||||
|
|
||||||
|
return { text, log };
|
||||||
|
}
|
||||||
@ -1,153 +1,79 @@
|
|||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import { OpenAI } from 'openai';
|
import { OpenAI } from 'openai';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
|
||||||
import * as os from 'node:os';
|
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { Readable } from 'node:stream';
|
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { MultipartFile } from '@fastify/multipart';
|
import type { MultipartFile } from '@fastify/multipart';
|
||||||
import { uploadFile, getFileUrl } from '../utils/storage';
|
import { uploadFile, getFileUrl } from '../utils/storage';
|
||||||
import type { AudioInput, AudioSaveResult, RecipeData } from '../types/fastify';
|
import type { AudioInput, AudioSaveResult, RecipeData } from '../types/fastify';
|
||||||
|
import {
|
||||||
const ENABLE_IMAGE_GENERATION = process.env.ENABLE_IMAGE_GENERATION !== 'false';
|
transcribeAudio as runTranscribe,
|
||||||
|
downloadToTemp,
|
||||||
interface TempFile {
|
} from '../ai/transcriber';
|
||||||
path: string;
|
import { generateRecipe as runGenerateRecipe, type StructuredRecipe } from '../ai/recipe-generator';
|
||||||
buffer: Buffer;
|
import { generateRecipeImage as runGenerateImage } from '../ai/image-generator';
|
||||||
cleanup: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
||||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
|
// Client OpenAI partagé, avec retries et timeout configurés
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY!,
|
||||||
|
maxRetries: Number(process.env.OPENAI_MAX_RETRIES ?? 3),
|
||||||
|
timeout: Number(process.env.OPENAI_TIMEOUT_MS ?? 60_000),
|
||||||
|
});
|
||||||
fastify.decorate('openai', openai);
|
fastify.decorate('openai', openai);
|
||||||
|
|
||||||
const bufferToStream = (buffer: Buffer): Readable => Readable.from(buffer);
|
|
||||||
|
|
||||||
const downloadToTemp = async (url: string, extension = '.tmp'): Promise<TempFile> => {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Échec du téléchargement: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const buffer = Buffer.from(await response.arrayBuffer());
|
|
||||||
const tempFilePath = path.join(os.tmpdir(), `openai-${Date.now()}${extension}`);
|
|
||||||
fs.writeFileSync(tempFilePath, buffer);
|
|
||||||
return {
|
|
||||||
path: tempFilePath,
|
|
||||||
buffer,
|
|
||||||
cleanup: () => {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(tempFilePath);
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.warn(`Suppression temp échouée: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Transcription audio ---
|
// --- Transcription audio ---
|
||||||
fastify.decorate('transcribeAudio', async (audioInput: AudioInput): Promise<string> => {
|
fastify.decorate('transcribeAudio', async (audioInput: AudioInput): Promise<string> => {
|
||||||
let tempFile: TempFile | null = null;
|
let tempFile: { path: string; cleanup: () => void } | null = null;
|
||||||
let audioPath: string | null = null;
|
let audioPath: string;
|
||||||
|
|
||||||
|
if (typeof audioInput === 'string') {
|
||||||
|
audioPath = audioInput;
|
||||||
|
} else if (audioInput && 'url' in audioInput && audioInput.url) {
|
||||||
|
tempFile = await downloadToTemp(audioInput.url, '.mp3');
|
||||||
|
audioPath = tempFile.path;
|
||||||
|
} else if (audioInput && 'localPath' in audioInput && audioInput.localPath) {
|
||||||
|
audioPath = audioInput.localPath;
|
||||||
|
} else {
|
||||||
|
throw new Error("Format d'entrée audio non valide");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (typeof audioInput === 'string') {
|
const { text } = await runTranscribe(openai, fastify.log, { audioPath });
|
||||||
audioPath = audioInput;
|
return text;
|
||||||
} else if (audioInput && 'url' in audioInput && audioInput.url) {
|
|
||||||
tempFile = await downloadToTemp(audioInput.url, '.mp3');
|
|
||||||
audioPath = tempFile.path;
|
|
||||||
} else if (audioInput && 'localPath' in audioInput && audioInput.localPath) {
|
|
||||||
audioPath = audioInput.localPath;
|
|
||||||
} else {
|
|
||||||
throw new Error("Format d'entrée audio non valide");
|
|
||||||
}
|
|
||||||
|
|
||||||
const transcription = await openai.audio.transcriptions.create({
|
|
||||||
file: fs.createReadStream(audioPath),
|
|
||||||
model: 'whisper-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
return transcription.text;
|
|
||||||
} catch (error) {
|
|
||||||
fastify.log.error(`Erreur transcription audio: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
if (tempFile) tempFile.cleanup();
|
tempFile?.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Génération image (best-effort, isolée) ---
|
|
||||||
async function generateRecipeImage(title: string): Promise<string | null> {
|
|
||||||
if (!ENABLE_IMAGE_GENERATION) return null;
|
|
||||||
try {
|
|
||||||
const imageResponse = await openai.images.generate({
|
|
||||||
model: 'dall-e-3',
|
|
||||||
prompt: `Une photo culinaire professionnelle et appétissante du plat "${title}", éclairage studio, style gastronomie.`,
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
});
|
|
||||||
|
|
||||||
const imageUrl = imageResponse.data?.[0]?.url;
|
|
||||||
if (!imageUrl) return null;
|
|
||||||
|
|
||||||
const response = await fetch(imageUrl);
|
|
||||||
if (!response.ok) throw new Error(`Téléchargement image: ${response.statusText}`);
|
|
||||||
const imageBuffer = Buffer.from(await response.arrayBuffer());
|
|
||||||
|
|
||||||
const sanitizedTitle = title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
|
|
||||||
const fileName = `${sanitizedTitle}-${Date.now()}.jpg`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filePath = await uploadFile(
|
|
||||||
{ filename: fileName, file: bufferToStream(imageBuffer) },
|
|
||||||
'recipes'
|
|
||||||
);
|
|
||||||
return await getFileUrl(filePath);
|
|
||||||
} catch (storageErr) {
|
|
||||||
fastify.log.warn(`Upload image MinIO échoué: ${(storageErr as Error).message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.warn(`Génération image DALL-E échouée: ${(err as Error).message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Génération de recette ---
|
// --- Génération de recette ---
|
||||||
|
// Cette fonction est maintenue pour compatibilité avec le contrat existant
|
||||||
|
// (elle retourne `RecipeData` à plat). En interne, elle utilise désormais
|
||||||
|
// le pipeline structuré + parallélisation image/texte.
|
||||||
fastify.decorate(
|
fastify.decorate(
|
||||||
'generateRecipe',
|
'generateRecipe',
|
||||||
async (ingredients: string, prompt?: string): Promise<RecipeData> => {
|
async (ingredients: string, hint?: string): Promise<RecipeData> => {
|
||||||
const completion = await openai.chat.completions.create({
|
// 1. Génération du texte (séquentiel obligatoire : on a besoin du titre)
|
||||||
model: process.env.OPENAI_TEXT_MODEL || 'gpt-4o-mini',
|
const { recipe } = await runGenerateRecipe(openai, fastify.log, {
|
||||||
messages: [
|
transcription: ingredients,
|
||||||
{
|
hint,
|
||||||
role: 'system',
|
|
||||||
content:
|
|
||||||
"Tu es un chef cuisinier expert qui crée des recettes délicieuses et faciles à réaliser. Tu dois toujours répondre avec un objet JSON valide contenant les champs suivants: titre, ingredients, etapes, temps_preparation (en minutes), temps_cuisson (en minutes), portions, difficulte (facile, moyen, difficile), et conseils.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: `Voici les ingrédients disponibles: ${ingredients}. ${
|
|
||||||
prompt || 'Propose une recette avec ces ingrédients.'
|
|
||||||
} Réponds uniquement avec un objet JSON.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response_format: { type: 'json_object' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const raw = completion.choices[0]?.message.content;
|
// 2. Génération de l'image en parallèle de la sérialisation
|
||||||
if (!raw) throw new Error('Réponse OpenAI vide');
|
// (l'image n'a besoin que du titre + description, qu'on a déjà)
|
||||||
|
const imagePromise = runGenerateImage(openai, fastify.log, {
|
||||||
|
title: recipe.titre,
|
||||||
|
description: recipe.description,
|
||||||
|
});
|
||||||
|
|
||||||
let recipeData: RecipeData;
|
// 3. Aplatir la recette structurée vers RecipeData (compatibilité Prisma)
|
||||||
try {
|
const flat = flattenRecipe(recipe);
|
||||||
recipeData = JSON.parse(raw) as RecipeData;
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(`Réponse OpenAI non-JSON: ${(err as Error).message}`);
|
|
||||||
throw new Error('La génération de recette a retourné un format invalide');
|
|
||||||
}
|
|
||||||
|
|
||||||
recipeData.image_url = await generateRecipeImage(recipeData.titre || 'recette');
|
// 4. Attendre l'image (best-effort, peut être null)
|
||||||
return recipeData;
|
const { url: imageUrl } = await imagePromise;
|
||||||
|
flat.image_url = imageUrl;
|
||||||
|
|
||||||
|
return flat;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -184,3 +110,43 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
|||||||
return { success: true, localPath: filepath, isLocal: true };
|
return { success: true, localPath: filepath, isLocal: true };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit la recette structurée en `RecipeData` plat compatible avec
|
||||||
|
* l'ancien contrat utilisé par les routes Prisma.
|
||||||
|
*/
|
||||||
|
function flattenRecipe(recipe: StructuredRecipe): RecipeData {
|
||||||
|
const ingredients = recipe.ingredients.map((i) => {
|
||||||
|
const note = i.notes ? ` (${i.notes})` : '';
|
||||||
|
const tag = i.complement ? ' [+]' : '';
|
||||||
|
return `${i.quantite} ${i.nom}${note}${tag}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const etapes = recipe.etapes
|
||||||
|
.sort((a, b) => a.numero - b.numero)
|
||||||
|
.map((e) => {
|
||||||
|
const duree = e.duree_minutes ? ` (${e.duree_minutes} min)` : '';
|
||||||
|
return `${e.numero}. ${e.instruction}${duree}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const conseils = [
|
||||||
|
...recipe.conseils,
|
||||||
|
`Inspiration : ${recipe.origine_inspiration}`,
|
||||||
|
recipe.accord_boisson ? `Accord boisson : ${recipe.accord_boisson}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
titre: recipe.titre,
|
||||||
|
ingredients,
|
||||||
|
etapes,
|
||||||
|
temps_preparation: recipe.temps_preparation,
|
||||||
|
temps_cuisson: recipe.temps_cuisson,
|
||||||
|
portions: recipe.portions,
|
||||||
|
difficulte: recipe.difficulte,
|
||||||
|
conseils,
|
||||||
|
image_url: null, // rempli plus tard
|
||||||
|
structured: recipe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -63,7 +63,9 @@ const recipesRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|||||||
title: recipeData.titre || 'Recette sans titre',
|
title: recipeData.titre || 'Recette sans titre',
|
||||||
ingredients: ingredientsString,
|
ingredients: ingredientsString,
|
||||||
userPrompt: transcription,
|
userPrompt: transcription,
|
||||||
generatedRecipe: JSON.stringify(recipeData),
|
// On stocke la version structurée riche (avec notes, accord boisson,
|
||||||
|
// origine, etc.) plutôt que la forme aplatie.
|
||||||
|
generatedRecipe: JSON.stringify(recipeData.structured ?? recipeData),
|
||||||
imageUrl: recipeData.image_url ?? null,
|
imageUrl: recipeData.image_url ?? null,
|
||||||
preparationTime: recipeData.temps_preparation ?? null,
|
preparationTime: recipeData.temps_preparation ?? null,
|
||||||
cookingTime: recipeData.temps_cuisson ?? null,
|
cookingTime: recipeData.temps_cuisson ?? null,
|
||||||
|
|||||||
2
backend/src/types/fastify.d.ts
vendored
2
backend/src/types/fastify.d.ts
vendored
@ -15,6 +15,8 @@ export interface RecipeData {
|
|||||||
difficulte?: string;
|
difficulte?: string;
|
||||||
conseils?: string;
|
conseils?: string;
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
|
/** Forme structurée originale renvoyée par l'IA, pour stockage en JSON */
|
||||||
|
structured?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudioSaveResult {
|
export interface AudioSaveResult {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user