From 871526736105c9befd10c32480ccaf704df6e093 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Sun, 24 May 2026 11:12:04 +0200 Subject: [PATCH] feat: save ai usage --- app/Enums/AiUsageStatus.php | 9 + app/Enums/AiUsageType.php | 9 + .../MealImageAnalysisController.php | 62 +++++- app/Models/AiUsage.php | 56 ++++++ app/Models/User.php | 10 +- app/Services/AiMealPostGenerator.php | 182 +++++++++++++++--- app/Services/AiUsageRecorder.php | 89 +++++++++ database/factories/AiUsageFactory.php | 57 ++++++ ...26_05_24_084353_create_ai_usages_table.php | 44 +++++ tests/Feature/GenerateAiMealPostTest.php | 80 ++++++++ tests/Feature/MealImageAnalysisTest.php | 71 ++++++- 11 files changed, 629 insertions(+), 40 deletions(-) create mode 100644 app/Enums/AiUsageStatus.php create mode 100644 app/Enums/AiUsageType.php create mode 100644 app/Models/AiUsage.php create mode 100644 app/Services/AiUsageRecorder.php create mode 100644 database/factories/AiUsageFactory.php create mode 100644 database/migrations/2026_05_24_084353_create_ai_usages_table.php diff --git a/app/Enums/AiUsageStatus.php b/app/Enums/AiUsageStatus.php new file mode 100644 index 0000000..aa02b5a --- /dev/null +++ b/app/Enums/AiUsageStatus.php @@ -0,0 +1,9 @@ +file('image'); + $input = $this->analysisInput($image); + try { $response = MealImageAnalyzer::make()->prompt( $this->prompt(), - [$request->file('image')], + [$image], timeout: 90, ); } catch (Throwable $exception) { report($exception); + $aiUsageRecorder->failed( + user: $request->user(), + type: AiUsageType::MEAL_IMAGE_ANALYSIS, + exception: $exception, + input: $input, + ); + return response()->json([ 'message' => __('api.meal_image_analysis.failed'), ], 502); } + $draft = $this->normalizeDraft( + $response instanceof Arrayable + ? $response->toArray() + : (json_decode($response->text, true) ?: []) + ); + + $aiUsageRecorder->success( + user: $request->user(), + type: AiUsageType::MEAL_IMAGE_ANALYSIS, + input: $input, + output: $draft, + provider: $response->meta->provider ?? null, + model: $response->meta->model ?? null, + promptTokens: $response->usage->promptTokens ?? null, + completionTokens: $response->usage->completionTokens ?? null, + totalTokens: $this->totalTokens($response->usage ?? null), + ); + return response()->json([ - 'data' => $this->normalizeDraft( - $response instanceof Arrayable - ? $response->toArray() - : (json_decode($response->text, true) ?: []) - ), + 'data' => $draft, ]); } @@ -51,6 +79,26 @@ Contraintes: PROMPT; } + private function analysisInput(UploadedFile $image): array + { + return [ + 'image' => [ + 'client_original_name' => $image->getClientOriginalName(), + 'mime_type' => $image->getMimeType(), + 'size' => $image->getSize(), + ], + ]; + } + + private function totalTokens(?Usage $usage): ?int + { + if (! $usage) { + return null; + } + + return $usage->promptTokens + $usage->completionTokens; + } + private function normalizeDraft(array $draft): array { $type = MealPostType::tryFrom($this->cleanString($draft['type'] ?? '', 32)) diff --git a/app/Models/AiUsage.php b/app/Models/AiUsage.php new file mode 100644 index 0000000..3281b24 --- /dev/null +++ b/app/Models/AiUsage.php @@ -0,0 +1,56 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'user_id', + 'type', + 'status', + 'input', + 'output', + 'provider', + 'model', + 'prompt_tokens', + 'completion_tokens', + 'total_tokens', + 'error_message', + 'related_type', + 'related_id', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function related(): MorphTo + { + return $this->morphTo(); + } + + protected function casts(): array + { + return [ + 'type' => AiUsageType::class, + 'status' => AiUsageStatus::class, + 'input' => 'array', + 'output' => 'array', + 'prompt_tokens' => 'integer', + 'completion_tokens' => 'integer', + 'total_tokens' => 'integer', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index ca76097..da1ad1c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,7 +25,7 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable implements FilamentUser, HasLocalePreference, MustVerifyEmail, HasAvatar +class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocalePreference, MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; @@ -97,8 +97,9 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference, public function getFilamentAvatarUrl(): ?string { - return Storage::url($this->avatar_url); + return Storage::url($this->avatar_url); } + public function deviceTokens(): HasMany { return $this->hasMany(DeviceToken::class); @@ -109,6 +110,11 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference, return $this->hasMany(MealPosts::class); } + public function aiUsages(): HasMany + { + return $this->hasMany(AiUsage::class); + } + public function workouts(): HasMany { return $this->hasMany(WorkoutSessions::class); diff --git a/app/Services/AiMealPostGenerator.php b/app/Services/AiMealPostGenerator.php index b53e772..23e4b5d 100644 --- a/app/Services/AiMealPostGenerator.php +++ b/app/Services/AiMealPostGenerator.php @@ -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, 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 + */ + 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 $draft + * @return array + */ + 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 diff --git a/app/Services/AiUsageRecorder.php b/app/Services/AiUsageRecorder.php new file mode 100644 index 0000000..05cfb5d --- /dev/null +++ b/app/Services/AiUsageRecorder.php @@ -0,0 +1,89 @@ +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(), + ]); + } +} diff --git a/database/factories/AiUsageFactory.php b/database/factories/AiUsageFactory.php new file mode 100644 index 0000000..9cb509a --- /dev/null +++ b/database/factories/AiUsageFactory.php @@ -0,0 +1,57 @@ + + */ +class AiUsageFactory extends Factory +{ + protected $model = AiUsage::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'type' => AiUsageType::MEAL_IMAGE_ANALYSIS, + 'status' => AiUsageStatus::SUCCESS, + 'input' => [ + 'image' => [ + 'mime_type' => 'image/png', + 'size' => 1024, + ], + ], + 'output' => [ + 'title' => $this->faker->words(3, true), + ], + 'provider' => null, + 'model' => null, + 'prompt_tokens' => null, + 'completion_tokens' => null, + 'total_tokens' => null, + 'error_message' => null, + 'related_type' => null, + 'related_id' => null, + ]; + } + + public function failed(): static + { + return $this->state(fn (): array => [ + 'status' => AiUsageStatus::FAILED, + 'output' => null, + 'error_message' => $this->faker->sentence(), + ]); + } +} diff --git a/database/migrations/2026_05_24_084353_create_ai_usages_table.php b/database/migrations/2026_05_24_084353_create_ai_usages_table.php new file mode 100644 index 0000000..43e5521 --- /dev/null +++ b/database/migrations/2026_05_24_084353_create_ai_usages_table.php @@ -0,0 +1,44 @@ +ulid('id')->primary(); + $table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('type', 64); + $table->string('status', 32); + $table->json('input')->nullable(); + $table->json('output')->nullable(); + $table->string('provider')->nullable(); + $table->string('model')->nullable(); + $table->unsignedInteger('prompt_tokens')->nullable(); + $table->unsignedInteger('completion_tokens')->nullable(); + $table->unsignedInteger('total_tokens')->nullable(); + $table->text('error_message')->nullable(); + $table->string('related_type')->nullable(); + $table->string('related_id', 36)->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'type', 'created_at']); + $table->index(['type', 'status', 'created_at']); + $table->index(['related_type', 'related_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_usages'); + } +}; diff --git a/tests/Feature/GenerateAiMealPostTest.php b/tests/Feature/GenerateAiMealPostTest.php index 7bf8dbf..06b1257 100644 --- a/tests/Feature/GenerateAiMealPostTest.php +++ b/tests/Feature/GenerateAiMealPostTest.php @@ -1,11 +1,15 @@ isLandscape() && $prompt->quality === 'medium' ); + + $usage = AiUsage::query()->sole(); + + expect($usage->user_id)->toBe($mealPost->user_id) + ->and($usage->type)->toBe(AiUsageType::MEAL_POST_GENERATION) + ->and($usage->status)->toBe(AiUsageStatus::SUCCESS) + ->and($usage->related_type)->toBe(MealPosts::class) + ->and($usage->related_id)->toBe($mealPost->id) + ->and($usage->input['image'])->toMatchArray([ + 'size' => '3:2', + 'quality' => 'medium', + 'storage_path' => 'meal-posts/ai-generated', + ]) + ->and($usage->output)->toMatchArray([ + 'title' => 'Bowl de saumon croustillant', + 'type' => MealPostType::LUNCH->value, + 'calories' => 640, + 'image_url' => $mealPost->image_url, + 'ingredients' => [ + [ + 'position' => 1, + 'ingredient' => 'Riz vinaigre', + 'quantity' => 160, + 'unit' => IngredientUnit::GRAM->value, + ], + [ + 'position' => 2, + 'ingredient' => 'Saumon', + 'quantity' => 130, + 'unit' => IngredientUnit::GRAM->value, + ], + ], + ]); +}); + +it('records a failed ai meal post generation attempt', function () { + Storage::fake('public'); + Exceptions::fake(); + + config()->set('meal_posts.ai_generation.user_name', 'Chef IA'); + config()->set('meal_posts.ai_generation.user_email', 'chef-ia@example.test'); + config()->set('meal_posts.ai_generation.image_quality', 'medium'); + + MealMaker::fake([ + [ + 'title' => 'Bowl impossible', + 'caption' => 'Un bowl qui ne sera pas publie.', + 'type' => MealPostType::LUNCH->value, + 'calories' => 640, + 'proteins' => 42.4, + 'carbs' => 62.2, + 'fats' => 24.8, + 'ingredients' => [], + 'image_prompt' => 'Photorealistic impossible bowl', + ], + ]); + + Image::fake( + fn () => throw new \RuntimeException('Image provider unavailable') + ); + + $this->artisan('meal-posts:generate-ai') + ->assertExitCode(1); + + Exceptions::assertReported(\RuntimeException::class); + + expect(MealPosts::query()->where('ai_generated', true)->count())->toBe(0); + + $usage = AiUsage::query()->sole(); + + expect($usage->user->email)->toBe('chef-ia@example.test') + ->and($usage->type)->toBe(AiUsageType::MEAL_POST_GENERATION) + ->and($usage->status)->toBe(AiUsageStatus::FAILED) + ->and($usage->input['image_prompt'])->toContain('impossible bowl') + ->and($usage->output)->toBeNull() + ->and($usage->error_message)->toContain('Image provider unavailable'); }); diff --git a/tests/Feature/MealImageAnalysisTest.php b/tests/Feature/MealImageAnalysisTest.php index e8f6906..caa43a0 100644 --- a/tests/Feature/MealImageAnalysisTest.php +++ b/tests/Feature/MealImageAnalysisTest.php @@ -1,11 +1,15 @@ create()); + $user = User::factory()->create(); + + Sanctum::actingAs($user); MealImageAnalyzer::fake([ [ @@ -70,6 +76,69 @@ it('analyzes a meal image into a draft', function () { fn ($prompt): bool => str_contains($prompt->prompt, "Analyse l'image") && $prompt->attachments->count() === 1 ); + + $usage = AiUsage::query()->sole(); + + expect($usage->user_id)->toBe($user->id) + ->and($usage->type)->toBe(AiUsageType::MEAL_IMAGE_ANALYSIS) + ->and($usage->status)->toBe(AiUsageStatus::SUCCESS) + ->and($usage->input['image'])->toMatchArray([ + 'client_original_name' => 'meal.png', + 'mime_type' => 'image/png', + ]) + ->and($usage->output)->toMatchArray([ + 'title' => 'Bowl saumon avocat', + 'type' => MealPostType::LUNCH->value, + 'calories' => 612, + 'proteins' => 39.4, + 'ingredients' => [ + [ + 'position' => 1, + 'ingredient' => 'Riz', + 'quantity' => 150, + 'unit' => IngredientUnit::GRAM->value, + ], + [ + 'position' => 2, + 'ingredient' => 'Saumon', + 'quantity' => 120, + 'unit' => IngredientUnit::GRAM->value, + ], + ], + ]); +}); + +it('records a failed meal image analysis attempt', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user); + Exceptions::fake(); + + MealImageAnalyzer::fake( + fn () => throw new \RuntimeException('Provider unavailable') + ); + + $this + ->withHeader('Accept', 'application/json') + ->post('/api/meal-posts/analyze-image', [ + 'image' => fakeMealAnalysisImage(), + ]) + ->assertStatus(502) + ->assertJsonPath('message', __('api.meal_image_analysis.failed')); + + Exceptions::assertReported(\RuntimeException::class); + + $usage = AiUsage::query()->sole(); + + expect($usage->user_id)->toBe($user->id) + ->and($usage->type)->toBe(AiUsageType::MEAL_IMAGE_ANALYSIS) + ->and($usage->status)->toBe(AiUsageStatus::FAILED) + ->and($usage->input['image'])->toMatchArray([ + 'client_original_name' => 'meal.png', + 'mime_type' => 'image/png', + ]) + ->and($usage->output)->toBeNull() + ->and($usage->error_message)->toContain('Provider unavailable'); }); it('requires authentication to analyze a meal image', function () {