feat: cron
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user