feat: save ai usage
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Ai\Agents\MealMaker;
|
||||
use App\Enums\AiUsageType;
|
||||
use App\Enums\IngredientUnit;
|
||||
use App\Enums\MealPostType;
|
||||
use App\Enums\MealPostVisibility;
|
||||
@@ -14,61 +15,182 @@ 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
|
||||
{
|
||||
$draft = $this->generateMealDraft();
|
||||
$imagePath = $this->generateMealImage($draft);
|
||||
$ingredients = $draft['ingredients'];
|
||||
$aiUser = null;
|
||||
$input = null;
|
||||
|
||||
unset($draft['ingredients'], $draft['image_prompt']);
|
||||
try {
|
||||
$aiUser = $this->aiUser();
|
||||
$textPrompt = $this->mealPrompt();
|
||||
$input = ['text_prompt' => $textPrompt];
|
||||
|
||||
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(),
|
||||
]);
|
||||
[$draft, $draftResponse] = $this->generateMealDraft($textPrompt);
|
||||
|
||||
if ($ingredients !== []) {
|
||||
$mealPost->ingredients()->createMany($ingredients);
|
||||
}
|
||||
$imagePrompt = $this->imagePrompt($draft);
|
||||
$input = $this->mealGenerationInput($textPrompt, $imagePrompt);
|
||||
|
||||
return $mealPost->loadMissing('user:id,name,avatar_url', 'ingredients');
|
||||
});
|
||||
[$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;
|
||||
}
|
||||
}
|
||||
|
||||
private function generateMealDraft(): array
|
||||
/**
|
||||
* @return array{0: array<string, mixed>, 1: AgentResponse}
|
||||
*/
|
||||
private function generateMealDraft(string $prompt): array
|
||||
{
|
||||
$response = MealMaker::make()->prompt($this->mealPrompt(), timeout: $this->textTimeout());
|
||||
$response = MealMaker::make()->prompt($prompt, timeout: $this->textTimeout());
|
||||
|
||||
return $this->normalizeDraft(
|
||||
$response instanceof Arrayable
|
||||
? $response->toArray()
|
||||
: (json_decode($response->text ?? '', true) ?: [])
|
||||
);
|
||||
return [
|
||||
$this->normalizeDraft(
|
||||
$response instanceof Arrayable
|
||||
? $response->toArray()
|
||||
: (json_decode($response->text ?? '', true) ?: [])
|
||||
),
|
||||
$response,
|
||||
];
|
||||
}
|
||||
|
||||
private function generateMealImage(array $draft): string
|
||||
/**
|
||||
* @return array{0: string, 1: ImageResponse}
|
||||
*/
|
||||
private function generateMealImage(string $prompt): array
|
||||
{
|
||||
$path = Image::of($this->imagePrompt($draft))
|
||||
$response = Image::of($prompt)
|
||||
->landscape()
|
||||
->quality($this->imageQuality())
|
||||
->timeout($this->imageTimeout())
|
||||
->generate()
|
||||
->storePublicly($this->imageStoragePath(), 'public');
|
||||
->generate();
|
||||
|
||||
$path = $response->storePublicly($this->imageStoragePath(), 'public');
|
||||
|
||||
if (! is_string($path)) {
|
||||
throw new RuntimeException("L'image du repas IA n'a pas pu etre stockee.");
|
||||
}
|
||||
|
||||
return $path;
|
||||
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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\AiUsageStatus;
|
||||
use App\Enums\AiUsageType;
|
||||
use App\Models\AiUsage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class AiUsageRecorder
|
||||
{
|
||||
public function success(
|
||||
?User $user,
|
||||
AiUsageType $type,
|
||||
?array $input = null,
|
||||
?array $output = null,
|
||||
?string $provider = null,
|
||||
?string $model = null,
|
||||
?int $promptTokens = null,
|
||||
?int $completionTokens = null,
|
||||
?int $totalTokens = null,
|
||||
?Model $related = null,
|
||||
): AiUsage {
|
||||
return $this->record(
|
||||
user: $user,
|
||||
type: $type,
|
||||
status: AiUsageStatus::SUCCESS,
|
||||
input: $input,
|
||||
output: $output,
|
||||
provider: $provider,
|
||||
model: $model,
|
||||
promptTokens: $promptTokens,
|
||||
completionTokens: $completionTokens,
|
||||
totalTokens: $totalTokens,
|
||||
related: $related,
|
||||
);
|
||||
}
|
||||
|
||||
public function failed(
|
||||
?User $user,
|
||||
AiUsageType $type,
|
||||
Throwable $exception,
|
||||
?array $input = null,
|
||||
?Model $related = null,
|
||||
): AiUsage {
|
||||
return $this->record(
|
||||
user: $user,
|
||||
type: $type,
|
||||
status: AiUsageStatus::FAILED,
|
||||
input: $input,
|
||||
errorMessage: Str::limit($exception::class.': '.$exception->getMessage(), 1000, ''),
|
||||
related: $related,
|
||||
);
|
||||
}
|
||||
|
||||
private function record(
|
||||
?User $user,
|
||||
AiUsageType $type,
|
||||
AiUsageStatus $status,
|
||||
?array $input = null,
|
||||
?array $output = null,
|
||||
?string $provider = null,
|
||||
?string $model = null,
|
||||
?int $promptTokens = null,
|
||||
?int $completionTokens = null,
|
||||
?int $totalTokens = null,
|
||||
?string $errorMessage = null,
|
||||
?Model $related = null,
|
||||
): AiUsage {
|
||||
return AiUsage::create([
|
||||
'user_id' => $user?->id,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
'input' => $input,
|
||||
'output' => $output,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'prompt_tokens' => $promptTokens,
|
||||
'completion_tokens' => $completionTokens,
|
||||
'total_tokens' => $totalTokens,
|
||||
'error_message' => $errorMessage,
|
||||
'related_type' => $related ? $related::class : null,
|
||||
'related_id' => $related?->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user