222 lines
6.7 KiB
PHP
222 lines
6.7 KiB
PHP
<?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;
|
|
}
|
|
}
|