aiUser(); $textPrompt = $this->mealPrompt(); $input = ['text_prompt' => $textPrompt]; [$draft, $draftResponse] = $this->generateMealDraft($textPrompt); $imagePrompt = $this->imagePrompt($draft); $input = $this->mealGenerationInput($textPrompt, $imagePrompt); [$imagePath, $imageResponse] = $this->generateMealImage($imagePrompt); $output = $this->mealGenerationOutput($draft, $imagePath); $ingredients = $draft['ingredients']; unset($draft['ingredients'], $draft['image_prompt']); return DB::transaction(function () use ($aiUser, $draft, $draftResponse, $imagePath, $imageResponse, $ingredients, $input, $output): MealPosts { $mealPost = MealPosts::create([ ...$draft, 'user_id' => $aiUser->getKey(), 'image_url' => $imagePath, 'visibility' => MealPostVisibility::Public, 'ai_generated' => true, 'eaten_at' => now(), ]); if ($ingredients !== []) { $mealPost->ingredients()->createMany($ingredients); } $this->aiUsageRecorder->success( user: $aiUser, type: AiUsageType::MEAL_POST_GENERATION, input: $input, output: $output, provider: $draftResponse->meta->provider ?? $imageResponse->meta->provider, model: $draftResponse->meta->model ?? $imageResponse->meta->model, promptTokens: $this->promptTokens($draftResponse->usage, $imageResponse->usage), completionTokens: $this->completionTokens($draftResponse->usage, $imageResponse->usage), totalTokens: $this->totalTokens($draftResponse->usage, $imageResponse->usage), related: $mealPost, ); return $mealPost->loadMissing('user:id,name,avatar_url', 'ingredients'); }); } catch (Throwable $exception) { $this->aiUsageRecorder->failed( user: $aiUser, type: AiUsageType::MEAL_POST_GENERATION, exception: $exception, input: $input, ); throw $exception; } } /** * @return array{0: array, 1: AgentResponse} */ private function generateMealDraft(string $prompt): array { $response = MealMaker::make()->prompt($prompt, timeout: $this->textTimeout()); return [ $this->normalizeDraft( $response instanceof Arrayable ? $response->toArray() : (json_decode($response->text ?? '', true) ?: []) ), $response, ]; } /** * @return array{0: string, 1: ImageResponse} */ private function generateMealImage(string $prompt): array { $response = Image::of($prompt) ->landscape() ->quality($this->imageQuality()) ->timeout($this->imageTimeout()) ->generate(); $path = $response->store($this->imageStoragePath()); if (! is_string($path)) { throw new RuntimeException("L'image du repas IA n'a pas pu etre stockee."); } return [$path, $response]; } /** * @return array */ private function mealGenerationInput(string $textPrompt, string $imagePrompt): array { return [ 'text_prompt' => $textPrompt, 'image_prompt' => $imagePrompt, 'image' => [ 'size' => '3:2', 'quality' => $this->imageQuality(), 'storage_path' => $this->imageStoragePath(), ], ]; } /** * @param array $draft * @return array */ private function mealGenerationOutput(array $draft, string $imagePath): array { return [ 'title' => $draft['title'], 'caption' => $draft['caption'], 'type' => $draft['type']->value, 'calories' => $draft['calories'], 'proteins' => $draft['proteins'], 'carbs' => $draft['carbs'], 'fats' => $draft['fats'], 'ingredients' => array_map(fn (array $ingredient): array => [ 'position' => $ingredient['position'], 'ingredient' => $ingredient['ingredient'], 'quantity' => $ingredient['quantity'], 'unit' => $ingredient['unit']->value, ], $draft['ingredients']), 'image_prompt' => $draft['image_prompt'], 'image_url' => $imagePath, ]; } private function promptTokens(Usage ...$usages): int { $tokens = 0; foreach ($usages as $usage) { $tokens += $usage->promptTokens; } return $tokens; } private function completionTokens(Usage ...$usages): int { $tokens = 0; foreach ($usages as $usage) { $tokens += $usage->completionTokens; } return $tokens; } private function totalTokens(Usage ...$usages): int { return $this->promptTokens(...$usages) + $this->completionTokens(...$usages); } private function aiUser(): User { $email = $this->configString('user_email', 'ai@daily-meal.local'); return User::query()->firstOrCreate( ['email' => $email], [ 'name' => $this->configString('user_name', 'Daily Meal AI'), 'password' => Hash::make(Str::random(64)), 'bio' => 'Compte utilise pour publier des repas generes automatiquement.', ], ); } private function mealPrompt(): string { $recentMainIngredients = $this->recentMainIngredientsToAvoid(); $avoidRecentIngredients = $recentMainIngredients === [] ? '- Aucun ingredient recent a eviter pour cette generation.' : '- Evite les ingredients principaux deja utilises dans les 10 derniers repas: '.implode(', ', $recentMainIngredients).'.'; return <<with([ 'ingredients' => function (HasMany $query): void { $query->where('position', '<=', 3); }, ]) ->latest('eaten_at') ->limit(10) ->get() ->flatMap(fn (MealPosts $mealPost): array => $mealPost->ingredients ->pluck('ingredient') ->all()) ->map(fn (mixed $ingredient): string => $this->cleanString($ingredient, 255)) ->filter() ->unique(fn (string $ingredient): string => Str::lower($ingredient)) ->values() ->all(); } private function imagePrompt(array $draft): string { $prompt = $this->cleanString($draft['image_prompt'] ?? '', 1500); if ($prompt === '') { $prompt = "Photorealistic food photography of {$draft['title']}, single serving, natural light, restaurant plating."; } return $prompt.' No text, no watermark, no people, no hands.'; } private function normalizeDraft(array $draft): array { $type = MealPostType::tryFrom($this->cleanString($draft['type'] ?? '', 32)) ?? MealPostType::OTHER; return [ 'title' => $this->cleanString($draft['title'] ?? 'Repas IA', 255) ?: 'Repas IA', 'caption' => $this->cleanString($draft['caption'] ?? '', 1000), 'type' => $type, 'calories' => $this->positiveInteger($draft['calories'] ?? 0), 'proteins' => $this->positiveNumber($draft['proteins'] ?? 0), 'carbs' => $this->positiveNumber($draft['carbs'] ?? 0), 'fats' => $this->positiveNumber($draft['fats'] ?? 0), 'ingredients' => $this->normalizeIngredients($draft['ingredients'] ?? []), 'image_prompt' => $this->cleanString($draft['image_prompt'] ?? '', 1500), ]; } private function normalizeIngredients(mixed $ingredients): array { if (! is_array($ingredients)) { return []; } $normalized = []; foreach (array_slice($ingredients, 0, 12) as $ingredient) { if (! is_array($ingredient)) { continue; } $name = $this->cleanString($ingredient['ingredient'] ?? '', 255); if ($name === '') { continue; } $unit = IngredientUnit::tryFrom($this->cleanString($ingredient['unit'] ?? '', 16)) ?? IngredientUnit::GRAM; $normalized[] = [ 'position' => count($normalized) + 1, 'ingredient' => $name, 'quantity' => max(1, $this->positiveInteger($ingredient['quantity'] ?? 1)), 'unit' => $unit, ]; } return $normalized; } private function cleanString(mixed $value, int $limit): string { if (! is_string($value) && ! is_numeric($value)) { return ''; } $cleaned = trim((string) preg_replace('/\s+/', ' ', (string) $value)); return Str::limit($cleaned, $limit, ''); } private function positiveInteger(mixed $value): int { if (! is_numeric($value)) { return 0; } return max(0, (int) round((float) $value)); } private function positiveNumber(mixed $value): float { if (! is_numeric($value)) { return 0; } return round(max(0, (float) $value), 1); } private function imageQuality(): string { $quality = $this->configString('image_quality', 'medium'); return in_array($quality, ['low', 'medium', 'high'], true) ? $quality : 'medium'; } private function imageStoragePath(): string { return $this->configString('image_path', 'meal-posts/ai-generated'); } private function textTimeout(): int { return max(1, (int) config('meal_posts.ai_generation.text_timeout', 90)); } private function imageTimeout(): int { return max(1, (int) config('meal_posts.ai_generation.image_timeout', 120)); } private function configString(string $key, string $fallback): string { $value = trim((string) config("meal_posts.ai_generation.{$key}", $fallback)); return $value !== '' ? $value : $fallback; } }