From f5a3e79c8ae2c820a935ef2ab341f708b8b2c782 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Mon, 18 May 2026 20:32:32 +0200 Subject: [PATCH] feat: cron --- .env.example | 9 + app/Ai/Agents/MealMaker.php | 67 +++++- app/Console/Commands/GenerateAiMealPost.php | 31 +++ app/Enums/DietType.php | 14 ++ app/Http/Controllers/MealPostController.php | 2 - app/Http/Requests/MealPostsRequest.php | 2 + app/Http/Resources/MealPostsResource.php | 2 + app/Models/MealPosts.php | 7 + app/Services/AiMealPostGenerator.php | 221 ++++++++++++++++++ config/meal_posts.php | 12 + database/factories/MealPostsFactory.php | 3 + ..._170714_add_ai_generated_to_meal_posts.php | 29 +++ ...1214_add_diet_type_to_meal_posts_table.php | 28 +++ docker-compose.prod.yml | 39 ++++ routes/console.php | 4 + tests/Feature/GenerateAiMealPostTest.php | 86 +++++++ 16 files changed, 547 insertions(+), 9 deletions(-) create mode 100644 app/Console/Commands/GenerateAiMealPost.php create mode 100644 app/Enums/DietType.php create mode 100644 app/Services/AiMealPostGenerator.php create mode 100644 config/meal_posts.php create mode 100644 database/migrations/2026_05_18_170714_add_ai_generated_to_meal_posts.php create mode 100644 database/migrations/2026_05_18_171214_add_diet_type_to_meal_posts_table.php create mode 100644 tests/Feature/GenerateAiMealPostTest.php diff --git a/.env.example b/.env.example index fb74b25..3e0a901 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,15 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false OLLAMA_API_KEY= +GEMINI_API_KEY= + +AI_MEAL_USER_NAME="Daily Meal AI" +AI_MEAL_USER_EMAIL=ai@daily-meal.local +AI_MEAL_IMAGE_PATH=meal-posts/ai-generated +AI_MEAL_IMAGE_QUALITY=medium +AI_MEAL_TEXT_TIMEOUT=90 +AI_MEAL_IMAGE_TIMEOUT=120 + SCOUT_DRIVER=meilisearch MEILISEARCH_HOST=http://meilisearch:7700 MEILISEARCH_KEY=masterKey diff --git a/app/Ai/Agents/MealMaker.php b/app/Ai/Agents/MealMaker.php index 3ed7d1c..fc9da6e 100644 --- a/app/Ai/Agents/MealMaker.php +++ b/app/Ai/Agents/MealMaker.php @@ -2,29 +2,38 @@ namespace App\Ai\Agents; +use App\Enums\IngredientUnit; +use App\Enums\MealPostType; +use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\Conversational; +use Laravel\Ai\Contracts\HasStructuredOutput; use Laravel\Ai\Contracts\HasTools; use Laravel\Ai\Contracts\Tool; use Laravel\Ai\Messages\Message; use Laravel\Ai\Promptable; use Stringable; -class MealMaker implements Agent, Conversational, HasTools +class MealMaker implements Agent, Conversational, HasStructuredOutput, HasTools { use Promptable; - /** - * Get the instructions that the agent should follow. - */ public function instructions(): Stringable|string { - return 'You are a helpful assistant.'; + return <<<'INSTRUCTIONS' +You create varied fictional meal posts for a French food tracking app. + +Return one realistic meal only. Vary cuisines, seasons, ingredients, and meal types between requests. The meal must be plausible as a single serving. + +Use French for title, caption, and ingredient names. +Use one of these units for ingredients: g, kg, ml, l, piece, tsp, tbsp. +Use one of these meal types: breakfast, lunch, dinner, brunch, snack, drink, supplement, other. +Nutrition values must be coherent with the ingredients and portion size. +The image prompt should be in English, optimized for photorealistic food photography, and must describe the exact generated dish. +INSTRUCTIONS; } /** - * Get the list of messages comprising the conversation so far. - * * @return Message[] */ public function messages(): iterable @@ -41,4 +50,48 @@ class MealMaker implements Agent, Conversational, HasTools { return []; } + + public function schema(JsonSchema $schema): array + { + return [ + 'title' => $schema->string() + ->description('Short French meal title.') + ->required(), + 'caption' => $schema->string() + ->description('One concise French sentence describing the meal.') + ->required(), + 'type' => $schema->string() + ->enum(MealPostType::class) + ->description('Best matching meal type.') + ->required(), + 'calories' => $schema->integer() + ->min(0) + ->description('Estimated calories for the whole serving.') + ->required(), + 'proteins' => $schema->number() + ->min(0) + ->description('Estimated proteins in grams.') + ->required(), + 'carbs' => $schema->number() + ->min(0) + ->description('Estimated carbohydrates in grams.') + ->required(), + 'fats' => $schema->number() + ->min(0) + ->description('Estimated fats in grams.') + ->required(), + 'ingredients' => $schema->array() + ->items($schema->object([ + 'position' => $schema->integer()->min(1)->required(), + 'ingredient' => $schema->string()->required(), + 'quantity' => $schema->integer()->min(1)->required(), + 'unit' => $schema->string()->enum(IngredientUnit::class)->required(), + ])) + ->description('Ingredients ordered by importance.') + ->required(), + 'image_prompt' => $schema->string() + ->description('English photorealistic image generation prompt for the exact meal.') + ->required(), + ]; + } } diff --git a/app/Console/Commands/GenerateAiMealPost.php b/app/Console/Commands/GenerateAiMealPost.php new file mode 100644 index 0000000..6f44b6d --- /dev/null +++ b/app/Console/Commands/GenerateAiMealPost.php @@ -0,0 +1,31 @@ +generate(); + } catch (Throwable $exception) { + report($exception); + + $this->error('Impossible de generer le repas IA.'); + + return self::FAILURE; + } + + $this->info("Repas IA genere: {$mealPost->title} ({$mealPost->getKey()})"); + + return self::SUCCESS; + } +} diff --git a/app/Enums/DietType.php b/app/Enums/DietType.php new file mode 100644 index 0000000..8e819c8 --- /dev/null +++ b/app/Enums/DietType.php @@ -0,0 +1,14 @@ +integer('per_page', 15), 100)); $mealPosts = MealPosts::query() - ->where('user_id', $request->user()->getKey()) ->with('user:id,name,avatar_url') ->latest('eaten_at') ->paginate($perPage) @@ -126,7 +125,6 @@ class MealPostController extends Controller public function show(Request $request, MealPosts $mealPost): MealPostsResource { - $this->abortIfNotOwner($request, $mealPost); return new MealPostsResource($mealPost->loadMissing('user:id,name,avatar_url', 'ingredients')); } diff --git a/app/Http/Requests/MealPostsRequest.php b/app/Http/Requests/MealPostsRequest.php index a8d2b6b..45e84de 100644 --- a/app/Http/Requests/MealPostsRequest.php +++ b/app/Http/Requests/MealPostsRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Enums\DietType; use App\Enums\IngredientUnit; use App\Enums\MealPostType; use App\Enums\MealPostVisibility; @@ -26,6 +27,7 @@ class MealPostsRequest extends FormRequest 'eaten_at' => [$isCreating ? 'required' : 'sometimes', 'date'], 'type' => [$isCreating ? 'required' : 'sometimes', Rule::enum(MealPostType::class)], 'visibility' => ['sometimes', Rule::enum(MealPostVisibility::class)], + 'diet_type' => ['sometimes', Rule::enum(DietType::class)], 'ingredients' => ['sometimes', 'array'], 'ingredients.*' => ['required', 'array:position,ingredient,quantity,unit'], 'ingredients.*.position' => ['required', 'integer', 'min:0'], diff --git a/app/Http/Resources/MealPostsResource.php b/app/Http/Resources/MealPostsResource.php index dc9e9eb..8666c94 100644 --- a/app/Http/Resources/MealPostsResource.php +++ b/app/Http/Resources/MealPostsResource.php @@ -26,6 +26,8 @@ class MealPostsResource extends JsonResource 'title' => $this->title, 'visibility' => $this->visibility, 'type' => $this->type, + 'aiGenerated' => $this->ai_generated, + 'dietType' => $this->diet_type, 'eatenAt' => $this->eaten_at, 'createdAt' => $this->created_at, 'updatedAt' => $this->updated_at, diff --git a/app/Models/MealPosts.php b/app/Models/MealPosts.php index 755e1ea..3bf7059 100644 --- a/app/Models/MealPosts.php +++ b/app/Models/MealPosts.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\DietType; use App\Enums\MealPostType; use App\Enums\MealPostVisibility; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -27,10 +28,14 @@ class MealPosts extends Model 'eaten_at', 'visibility', 'type', + 'ai_generated', + 'diet_type', ]; protected $attributes = [ 'visibility' => MealPostVisibility::Private->value, + 'ai_generated' => false, + 'diet_type' => DietType::OMNIVORE->value, ]; public function user(): BelongsTo @@ -49,6 +54,8 @@ class MealPosts extends Model 'eaten_at' => 'datetime', 'visibility' => MealPostVisibility::class, 'type' => MealPostType::class, + 'diet_type' => DietType::class, + 'ai_generated' => 'boolean', ]; } } diff --git a/app/Services/AiMealPostGenerator.php b/app/Services/AiMealPostGenerator.php new file mode 100644 index 0000000..d92265f --- /dev/null +++ b/app/Services/AiMealPostGenerator.php @@ -0,0 +1,221 @@ +generateMealDraft(); + $imagePath = $this->generateMealImage($draft); + $ingredients = $draft['ingredients']; + + unset($draft['ingredients'], $draft['image_prompt']); + + return DB::transaction(function () use ($draft, $imagePath, $ingredients): MealPosts { + $mealPost = MealPosts::create([ + ...$draft, + 'user_id' => $this->aiUser()->getKey(), + 'image_url' => $imagePath, + 'visibility' => MealPostVisibility::Public, + 'ai_generated' => true, + 'eaten_at' => now(), + ]); + + if ($ingredients !== []) { + $mealPost->ingredients()->createMany($ingredients); + } + + return $mealPost->loadMissing('user:id,name,avatar_url', 'ingredients'); + }); + } + + private function generateMealDraft(): array + { + $response = MealMaker::make()->prompt($this->mealPrompt(), timeout: $this->textTimeout()); + + return $this->normalizeDraft( + $response instanceof Arrayable + ? $response->toArray() + : (json_decode($response->text ?? '', true) ?: []) + ); + } + + private function generateMealImage(array $draft): string + { + $path = Image::of($this->imagePrompt($draft)) + ->landscape() + ->quality($this->imageQuality()) + ->timeout($this->imageTimeout()) + ->generate() + ->storePublicly($this->imageStoragePath(), 'public'); + + if (! is_string($path)) { + throw new RuntimeException("L'image du repas IA n'a pas pu etre stockee."); + } + + return $path; + } + + 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 + { + $season = now()->locale('fr')->translatedFormat('F'); + + return <<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; + } +} diff --git a/config/meal_posts.php b/config/meal_posts.php new file mode 100644 index 0000000..4f10da5 --- /dev/null +++ b/config/meal_posts.php @@ -0,0 +1,12 @@ + [ + 'user_name' => env('AI_MEAL_USER_NAME', 'Daily Meal AI'), + 'user_email' => env('AI_MEAL_USER_EMAIL', 'ai@daily-meal.local'), + 'image_path' => env('AI_MEAL_IMAGE_PATH', 'meal-posts/ai-generated'), + 'image_quality' => env('AI_MEAL_IMAGE_QUALITY', 'medium'), + 'text_timeout' => (int) env('AI_MEAL_TEXT_TIMEOUT', 90), + 'image_timeout' => (int) env('AI_MEAL_IMAGE_TIMEOUT', 120), + ], +]; diff --git a/database/factories/MealPostsFactory.php b/database/factories/MealPostsFactory.php index 94082b3..8790cc6 100644 --- a/database/factories/MealPostsFactory.php +++ b/database/factories/MealPostsFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\DietType; use App\Enums\MealPostVisibility; use App\Models\MealPosts; use App\Models\User; @@ -24,6 +25,8 @@ class MealPostsFactory extends Factory 'title' => $this->faker->word(), 'eaten_at' => Carbon::now(), 'visibility' => MealPostVisibility::Private, + 'ai_generated' => false, + 'diet_type' => DietType::OMNIVORE, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), diff --git a/database/migrations/2026_05_18_170714_add_ai_generated_to_meal_posts.php b/database/migrations/2026_05_18_170714_add_ai_generated_to_meal_posts.php new file mode 100644 index 0000000..7daf726 --- /dev/null +++ b/database/migrations/2026_05_18_170714_add_ai_generated_to_meal_posts.php @@ -0,0 +1,29 @@ +boolean('ai_generated')->default(false)->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('meal_posts', function (Blueprint $table) { + $table->dropIndex(['ai_generated']); + $table->dropColumn('ai_generated'); + }); + } +}; diff --git a/database/migrations/2026_05_18_171214_add_diet_type_to_meal_posts_table.php b/database/migrations/2026_05_18_171214_add_diet_type_to_meal_posts_table.php new file mode 100644 index 0000000..f12b1d8 --- /dev/null +++ b/database/migrations/2026_05_18_171214_add_diet_type_to_meal_posts_table.php @@ -0,0 +1,28 @@ +string('diet_type')->default('omnivore'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('meal_posts', function (Blueprint $table) { + $table->dropColumn('diet_type'); + }); + } +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d6fc28a..be130b0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -96,6 +96,45 @@ services: networks: - internal + scheduler: + image: gitea.leonmorival.com/daily_meal/bemeal-api:latest + container_name: bemeal-scheduler + restart: unless-stopped + depends_on: + pgsql: + condition: service_healthy + redis: + condition: service_started + environment: + APP_LOCALE: "fr" + APP_ENV: "production" + APP_KEY: "${APP_KEY}" + APP_URL: "${APP_URL}" + APP_DEBUG: "${APP_DEBUG}" + FILESYSTEM_DISK: "public" + DB_CONNECTION: "pgsql" + DB_HOST: "pgsql" + DB_PORT: 5432 + DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}" + DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}" + DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}" + GEMINI_API_KEY: "${GEMINI_API_KEY:?GEMINI_API_KEY is required}" + AI_MEAL_USER_NAME: "${AI_MEAL_USER_NAME:-Lolo AI}" + AI_MEAL_USER_EMAIL: "${AI_MEAL_USER_EMAIL:-loloai@maily.com}" + AI_MEAL_IMAGE_PATH: "${AI_MEAL_IMAGE_PATH:-meal-posts/ai-generated}" + AI_MEAL_IMAGE_QUALITY: "${AI_MEAL_IMAGE_QUALITY:-medium}" + AI_MEAL_TEXT_TIMEOUT: "${AI_MEAL_TEXT_TIMEOUT:-90}" + AI_MEAL_IMAGE_TIMEOUT: "${AI_MEAL_IMAGE_TIMEOUT:-120}" + REDIS_HOST: "redis" + REDIS_CLIENT: "predis" + QUEUE_CONNECTION: "redis" + volumes: + - storage-data:/var/www/html/storage/app + - storage-public-data:/var/www/html/storage/app/public + command: "php artisan schedule:work" + networks: + - internal + adminer: image: adminer:latest container_name: bemeal-adminer diff --git a/routes/console.php b/routes/console.php index b93d2db..0aeaeff 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,3 +9,7 @@ Artisan::command('inspire', function () { })->purpose('Display an inspiring quote'); Schedule::command('horizon:snapshot')->everyFiveMinutes(); +Schedule::command('meal-posts:generate-ai') + ->daily() + ->withoutOverlapping(55) + ->onOneServer(); diff --git a/tests/Feature/GenerateAiMealPostTest.php b/tests/Feature/GenerateAiMealPostTest.php new file mode 100644 index 0000000..4e00008 --- /dev/null +++ b/tests/Feature/GenerateAiMealPostTest.php @@ -0,0 +1,86 @@ +set('meal_posts.ai_generation.user_name', 'Chef IA'); + config()->set('meal_posts.ai_generation.user_email', 'chef-ia@example.test'); + config()->set('meal_posts.ai_generation.image_path', 'meal-posts/ai-generated'); + config()->set('meal_posts.ai_generation.image_quality', 'medium'); + + MealMaker::fake([ + [ + 'title' => 'Bowl de saumon croustillant', + 'caption' => 'Un bowl colore avec saumon, riz vinaigre et legumes croquants.', + 'type' => MealPostType::LUNCH->value, + 'calories' => 640, + 'proteins' => 42.4, + 'carbs' => 62.2, + 'fats' => 24.8, + 'ingredients' => [ + [ + 'position' => 12, + 'ingredient' => 'Riz vinaigre', + 'quantity' => 160, + 'unit' => IngredientUnit::GRAM->value, + ], + [ + 'position' => 13, + 'ingredient' => 'Saumon', + 'quantity' => 130, + 'unit' => IngredientUnit::GRAM->value, + ], + ], + 'image_prompt' => 'Photorealistic crispy salmon bowl with vinegared rice and crunchy vegetables', + ], + ]); + + Image::fake([ + base64_encode('fake-image-content'), + ]); + + $this->artisan('meal-posts:generate-ai') + ->assertExitCode(0); + + $mealPost = MealPosts::query()->with('user', 'ingredients')->firstOrFail(); + + expect($mealPost->ai_generated)->toBeTrue() + ->and($mealPost->title)->toBe('Bowl de saumon croustillant') + ->and($mealPost->type)->toBe(MealPostType::LUNCH) + ->and($mealPost->visibility)->toBe(MealPostVisibility::Public) + ->and($mealPost->user->email)->toBe('chef-ia@example.test') + ->and($mealPost->user->name)->toBe('Chef IA') + ->and($mealPost->ingredients)->toHaveCount(2) + ->and($mealPost->ingredients[0]->position)->toBe(1) + ->and($mealPost->ingredients[0]->ingredient)->toBe('Riz vinaigre'); + + expect($mealPost->image_url)->toStartWith('meal-posts/ai-generated/'); + Storage::disk('public')->assertExists($mealPost->image_url); + + $this->assertDatabaseHas('meal_posts', [ + 'id' => $mealPost->id, + 'ai_generated' => true, + 'visibility' => MealPostVisibility::Public->value, + ]); + + MealMaker::assertPrompted( + fn ($prompt): bool => str_contains($prompt->prompt, 'Genere un plat aleatoire') + ); + + Image::assertGenerated( + fn ($prompt): bool => $prompt->contains('crispy salmon bowl') + && $prompt->isLandscape() + && $prompt->quality === 'medium' + ); +});