feat: cron
This commit is contained in:
parent
19d106a3ed
commit
f5a3e79c8a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
];
|
||||
|
|
@ -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(),
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue