api/app/Services/AiMealPostGenerator.php

374 lines
12 KiB
PHP

<?php
namespace App\Services;
use App\Ai\Agents\MealMaker;
use App\Enums\AiUsageType;
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\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Ai\Image;
use Laravel\Ai\Responses\AgentResponse;
use Laravel\Ai\Responses\Data\Usage;
use Laravel\Ai\Responses\ImageResponse;
use RuntimeException;
use Throwable;
class AiMealPostGenerator
{
public function __construct(private AiUsageRecorder $aiUsageRecorder) {}
public function generate(): MealPosts
{
$aiUser = null;
$input = null;
try {
$aiUser = $this->aiUser();
$textPrompt = $this->mealPrompt();
$input = ['text_prompt' => $textPrompt];
[$draft, $draftResponse] = $this->generateMealDraft($textPrompt);
$imagePrompt = $this->imagePrompt($draft);
$input = $this->mealGenerationInput($textPrompt, $imagePrompt);
[$imagePath, $imageResponse] = $this->generateMealImage($imagePrompt);
$output = $this->mealGenerationOutput($draft, $imagePath);
$ingredients = $draft['ingredients'];
unset($draft['ingredients'], $draft['image_prompt']);
return DB::transaction(function () use ($aiUser, $draft, $draftResponse, $imagePath, $imageResponse, $ingredients, $input, $output): MealPosts {
$mealPost = MealPosts::create([
...$draft,
'user_id' => $aiUser->getKey(),
'image_url' => $imagePath,
'visibility' => MealPostVisibility::Public,
'ai_generated' => true,
'eaten_at' => now(),
]);
if ($ingredients !== []) {
$mealPost->ingredients()->createMany($ingredients);
}
$this->aiUsageRecorder->success(
user: $aiUser,
type: AiUsageType::MEAL_POST_GENERATION,
input: $input,
output: $output,
provider: $draftResponse->meta->provider ?? $imageResponse->meta->provider,
model: $draftResponse->meta->model ?? $imageResponse->meta->model,
promptTokens: $this->promptTokens($draftResponse->usage, $imageResponse->usage),
completionTokens: $this->completionTokens($draftResponse->usage, $imageResponse->usage),
totalTokens: $this->totalTokens($draftResponse->usage, $imageResponse->usage),
related: $mealPost,
);
return $mealPost->loadMissing('user:id,name,avatar_url', 'ingredients');
});
} catch (Throwable $exception) {
$this->aiUsageRecorder->failed(
user: $aiUser,
type: AiUsageType::MEAL_POST_GENERATION,
exception: $exception,
input: $input,
);
throw $exception;
}
}
/**
* @return array{0: array<string, mixed>, 1: AgentResponse}
*/
private function generateMealDraft(string $prompt): array
{
$response = MealMaker::make()->prompt($prompt, timeout: $this->textTimeout());
return [
$this->normalizeDraft(
$response instanceof Arrayable
? $response->toArray()
: (json_decode($response->text ?? '', true) ?: [])
),
$response,
];
}
/**
* @return array{0: string, 1: ImageResponse}
*/
private function generateMealImage(string $prompt): array
{
$response = Image::of($prompt)
->landscape()
->quality($this->imageQuality())
->timeout($this->imageTimeout())
->generate();
$path = $response->storePublicly($this->imageStoragePath(), );
if (! is_string($path)) {
throw new RuntimeException("L'image du repas IA n'a pas pu etre stockee.");
}
return [$path, $response];
}
/**
* @return array<string, mixed>
*/
private function mealGenerationInput(string $textPrompt, string $imagePrompt): array
{
return [
'text_prompt' => $textPrompt,
'image_prompt' => $imagePrompt,
'image' => [
'size' => '3:2',
'quality' => $this->imageQuality(),
'storage_path' => $this->imageStoragePath(),
],
];
}
/**
* @param array<string, mixed> $draft
* @return array<string, mixed>
*/
private function mealGenerationOutput(array $draft, string $imagePath): array
{
return [
'title' => $draft['title'],
'caption' => $draft['caption'],
'type' => $draft['type']->value,
'calories' => $draft['calories'],
'proteins' => $draft['proteins'],
'carbs' => $draft['carbs'],
'fats' => $draft['fats'],
'ingredients' => array_map(fn (array $ingredient): array => [
'position' => $ingredient['position'],
'ingredient' => $ingredient['ingredient'],
'quantity' => $ingredient['quantity'],
'unit' => $ingredient['unit']->value,
], $draft['ingredients']),
'image_prompt' => $draft['image_prompt'],
'image_url' => $imagePath,
];
}
private function promptTokens(Usage ...$usages): int
{
$tokens = 0;
foreach ($usages as $usage) {
$tokens += $usage->promptTokens;
}
return $tokens;
}
private function completionTokens(Usage ...$usages): int
{
$tokens = 0;
foreach ($usages as $usage) {
$tokens += $usage->completionTokens;
}
return $tokens;
}
private function totalTokens(Usage ...$usages): int
{
return $this->promptTokens(...$usages) + $this->completionTokens(...$usages);
}
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
{
$recentMainIngredients = $this->recentMainIngredientsToAvoid();
$avoidRecentIngredients = $recentMainIngredients === []
? '- Aucun ingredient recent a eviter pour cette generation.'
: '- Evite les ingredients principaux deja utilises dans les 10 derniers repas: '.implode(', ', $recentMainIngredients).'.';
return <<<PROMPT
Genere un plat completement aleatoire, sans tenir compte du mois actuel, de la saison ou des produits de saison.
Contraintes:
- Le plat doit etre realiste, appetissant et suffisamment different des classiques trop evidents.
- Choisis librement la cuisine, le pays, le moment de la journee et les ingredients.
- Retourne une seule portion avec calories, macros et ingredients coherents.
- Evite les aliments impossibles a representer clairement en photo.
{$avoidRecentIngredients}
- Aucun texte sur l'image.
PROMPT;
}
/**
* @return string[]
*/
private function recentMainIngredientsToAvoid(): array
{
return MealPosts::query()
->with([
'ingredients' => function (HasMany $query): void {
$query->where('position', '<=', 3);
},
])
->latest('eaten_at')
->limit(10)
->get()
->flatMap(fn (MealPosts $mealPost): array => $mealPost->ingredients
->pluck('ingredient')
->all())
->map(fn (mixed $ingredient): string => $this->cleanString($ingredient, 255))
->filter()
->unique(fn (string $ingredient): string => Str::lower($ingredient))
->values()
->all();
}
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;
}
}