feat: cron

This commit is contained in:
2026-05-18 20:32:32 +02:00
parent 19d106a3ed
commit f5a3e79c8a
16 changed files with 547 additions and 9 deletions
+60 -7
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(),
];
}
}
@@ -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
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';
}
@@ -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
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'],
+2
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,
+7
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',
];
}
}
+221
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;
}
}