feat: cron
This commit is contained in:
parent
19d106a3ed
commit
f5a3e79c8a
|
|
@ -66,6 +66,15 @@ AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
OLLAMA_API_KEY=
|
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
|
SCOUT_DRIVER=meilisearch
|
||||||
MEILISEARCH_HOST=http://meilisearch:7700
|
MEILISEARCH_HOST=http://meilisearch:7700
|
||||||
MEILISEARCH_KEY=masterKey
|
MEILISEARCH_KEY=masterKey
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,38 @@
|
||||||
|
|
||||||
namespace App\Ai\Agents;
|
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\Agent;
|
||||||
use Laravel\Ai\Contracts\Conversational;
|
use Laravel\Ai\Contracts\Conversational;
|
||||||
|
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||||
use Laravel\Ai\Contracts\HasTools;
|
use Laravel\Ai\Contracts\HasTools;
|
||||||
use Laravel\Ai\Contracts\Tool;
|
use Laravel\Ai\Contracts\Tool;
|
||||||
use Laravel\Ai\Messages\Message;
|
use Laravel\Ai\Messages\Message;
|
||||||
use Laravel\Ai\Promptable;
|
use Laravel\Ai\Promptable;
|
||||||
use Stringable;
|
use Stringable;
|
||||||
|
|
||||||
class MealMaker implements Agent, Conversational, HasTools
|
class MealMaker implements Agent, Conversational, HasStructuredOutput, HasTools
|
||||||
{
|
{
|
||||||
use Promptable;
|
use Promptable;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the instructions that the agent should follow.
|
|
||||||
*/
|
|
||||||
public function instructions(): Stringable|string
|
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[]
|
* @return Message[]
|
||||||
*/
|
*/
|
||||||
public function messages(): iterable
|
public function messages(): iterable
|
||||||
|
|
@ -41,4 +50,48 @@ class MealMaker implements Agent, Conversational, HasTools
|
||||||
{
|
{
|
||||||
return [];
|
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));
|
$perPage = max(1, min($request->integer('per_page', 15), 100));
|
||||||
|
|
||||||
$mealPosts = MealPosts::query()
|
$mealPosts = MealPosts::query()
|
||||||
->where('user_id', $request->user()->getKey())
|
|
||||||
->with('user:id,name,avatar_url')
|
->with('user:id,name,avatar_url')
|
||||||
->latest('eaten_at')
|
->latest('eaten_at')
|
||||||
->paginate($perPage)
|
->paginate($perPage)
|
||||||
|
|
@ -126,7 +125,6 @@ class MealPostController extends Controller
|
||||||
|
|
||||||
public function show(Request $request, MealPosts $mealPost): MealPostsResource
|
public function show(Request $request, MealPosts $mealPost): MealPostsResource
|
||||||
{
|
{
|
||||||
$this->abortIfNotOwner($request, $mealPost);
|
|
||||||
|
|
||||||
return new MealPostsResource($mealPost->loadMissing('user:id,name,avatar_url', 'ingredients'));
|
return new MealPostsResource($mealPost->loadMissing('user:id,name,avatar_url', 'ingredients'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\DietType;
|
||||||
use App\Enums\IngredientUnit;
|
use App\Enums\IngredientUnit;
|
||||||
use App\Enums\MealPostType;
|
use App\Enums\MealPostType;
|
||||||
use App\Enums\MealPostVisibility;
|
use App\Enums\MealPostVisibility;
|
||||||
|
|
@ -26,6 +27,7 @@ class MealPostsRequest extends FormRequest
|
||||||
'eaten_at' => [$isCreating ? 'required' : 'sometimes', 'date'],
|
'eaten_at' => [$isCreating ? 'required' : 'sometimes', 'date'],
|
||||||
'type' => [$isCreating ? 'required' : 'sometimes', Rule::enum(MealPostType::class)],
|
'type' => [$isCreating ? 'required' : 'sometimes', Rule::enum(MealPostType::class)],
|
||||||
'visibility' => ['sometimes', Rule::enum(MealPostVisibility::class)],
|
'visibility' => ['sometimes', Rule::enum(MealPostVisibility::class)],
|
||||||
|
'diet_type' => ['sometimes', Rule::enum(DietType::class)],
|
||||||
'ingredients' => ['sometimes', 'array'],
|
'ingredients' => ['sometimes', 'array'],
|
||||||
'ingredients.*' => ['required', 'array:position,ingredient,quantity,unit'],
|
'ingredients.*' => ['required', 'array:position,ingredient,quantity,unit'],
|
||||||
'ingredients.*.position' => ['required', 'integer', 'min:0'],
|
'ingredients.*.position' => ['required', 'integer', 'min:0'],
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ class MealPostsResource extends JsonResource
|
||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'visibility' => $this->visibility,
|
'visibility' => $this->visibility,
|
||||||
'type' => $this->type,
|
'type' => $this->type,
|
||||||
|
'aiGenerated' => $this->ai_generated,
|
||||||
|
'dietType' => $this->diet_type,
|
||||||
'eatenAt' => $this->eaten_at,
|
'eatenAt' => $this->eaten_at,
|
||||||
'createdAt' => $this->created_at,
|
'createdAt' => $this->created_at,
|
||||||
'updatedAt' => $this->updated_at,
|
'updatedAt' => $this->updated_at,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\DietType;
|
||||||
use App\Enums\MealPostType;
|
use App\Enums\MealPostType;
|
||||||
use App\Enums\MealPostVisibility;
|
use App\Enums\MealPostVisibility;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||||
|
|
@ -27,10 +28,14 @@ class MealPosts extends Model
|
||||||
'eaten_at',
|
'eaten_at',
|
||||||
'visibility',
|
'visibility',
|
||||||
'type',
|
'type',
|
||||||
|
'ai_generated',
|
||||||
|
'diet_type',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'visibility' => MealPostVisibility::Private->value,
|
'visibility' => MealPostVisibility::Private->value,
|
||||||
|
'ai_generated' => false,
|
||||||
|
'diet_type' => DietType::OMNIVORE->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
|
|
@ -49,6 +54,8 @@ class MealPosts extends Model
|
||||||
'eaten_at' => 'datetime',
|
'eaten_at' => 'datetime',
|
||||||
'visibility' => MealPostVisibility::class,
|
'visibility' => MealPostVisibility::class,
|
||||||
'type' => MealPostType::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;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\DietType;
|
||||||
use App\Enums\MealPostVisibility;
|
use App\Enums\MealPostVisibility;
|
||||||
use App\Models\MealPosts;
|
use App\Models\MealPosts;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
@ -24,6 +25,8 @@ class MealPostsFactory extends Factory
|
||||||
'title' => $this->faker->word(),
|
'title' => $this->faker->word(),
|
||||||
'eaten_at' => Carbon::now(),
|
'eaten_at' => Carbon::now(),
|
||||||
'visibility' => MealPostVisibility::Private,
|
'visibility' => MealPostVisibility::Private,
|
||||||
|
'ai_generated' => false,
|
||||||
|
'diet_type' => DietType::OMNIVORE,
|
||||||
'created_at' => Carbon::now(),
|
'created_at' => Carbon::now(),
|
||||||
'updated_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:
|
networks:
|
||||||
- internal
|
- 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:
|
adminer:
|
||||||
image: adminer:latest
|
image: adminer:latest
|
||||||
container_name: bemeal-adminer
|
container_name: bemeal-adminer
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,7 @@ Artisan::command('inspire', function () {
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
Schedule::command('horizon:snapshot')->everyFiveMinutes();
|
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