feat: cron
Laravel CI-CD / Tests Unitaires (push) Successful in 22s Details
Laravel CI-CD / Build & Push Docker (push) Successful in 47s Details
Laravel CI-CD / Deploy via Portainer (push) Successful in 1s Details

This commit is contained in:
Leon Morival 2026-05-18 20:32:32 +02:00
parent 19d106a3ed
commit f5a3e79c8a
16 changed files with 547 additions and 9 deletions

View File

@ -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

View File

@ -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(),
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Console\Commands;
use App\Services\AiMealPostGenerator;
use Illuminate\Console\Command;
use Throwable;
class GenerateAiMealPost extends Command
{
protected $signature = 'meal-posts:generate-ai';
protected $description = 'Generate one AI meal post with an image.';
public function handle(AiMealPostGenerator $generator): int
{
try {
$mealPost = $generator->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;
}
}

14
app/Enums/DietType.php Normal file
View File

@ -0,0 +1,14 @@
<?php
namespace App\Enums;
enum DietType: string
{
case OMNIVORE = 'omnivore';
case VEGETARIAN = 'vegetarian';
case VEGAN = 'vegan';
case PESCATARIAN = 'pescatarian';
case KETO = 'keto';
case LOW_CARB = 'low_carb';
}

View File

@ -23,7 +23,6 @@ class MealPostController extends Controller
$perPage = max(1, min($request->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'));
}

View File

@ -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'],

View File

@ -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,

View File

@ -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',
];
}
}

View File

@ -0,0 +1,221 @@
<?php
namespace App\Services;
use App\Ai\Agents\MealMaker;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use App\Enums\MealPostVisibility;
use App\Models\MealPosts;
use App\Models\User;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Ai\Image;
use RuntimeException;
class AiMealPostGenerator
{
public function generate(): MealPosts
{
$draft = $this->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 <<<PROMPT
Genere un plat aleatoire pour le mois de {$season}.
Contraintes:
- Le plat doit etre realiste, appetissant et suffisamment different des classiques trop evidents.
- Retourne une seule portion avec calories, macros et ingredients coherents.
- Evite les aliments impossibles a representer clairement en photo.
- Auncun texte sur l'image
PROMPT;
}
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;
}
}

12
config/meal_posts.php Normal file
View File

@ -0,0 +1,12 @@
<?php
return [
'ai_generation' => [
'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),
],
];

View File

@ -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(),

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('meal_posts', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('meal_posts', function (Blueprint $table) {
$table->string('diet_type')->default('omnivore');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('meal_posts', function (Blueprint $table) {
$table->dropColumn('diet_type');
});
}
};

View File

@ -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

View File

@ -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();

View File

@ -0,0 +1,86 @@
<?php
use App\Ai\Agents\MealMaker;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use App\Enums\MealPostVisibility;
use App\Models\MealPosts;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Image;
uses(RefreshDatabase::class);
it('generates an ai meal post with an image', function () {
Storage::fake('public');
config()->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'
);
});