From fe78c000f0ad46fefb5440776f2ebb945653c080 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Mon, 18 May 2026 15:42:56 +0200 Subject: [PATCH] feat: ai analyze --- app/Ai/Agents/MealImageAnalyzer.php | 91 ++++++++++++ .../MealImageAnalysisController.php | 132 ++++++++++++++++++ .../Requests/MealImageAnalysisRequest.php | 29 ++++ config/ai.php | 8 +- docker-compose.prod.yml | 1 + routes/api.php | 2 + tests/Feature/MealImageAnalysisTest.php | 91 ++++++++++++ 7 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 app/Ai/Agents/MealImageAnalyzer.php create mode 100644 app/Http/Controllers/MealImageAnalysisController.php create mode 100644 app/Http/Requests/MealImageAnalysisRequest.php create mode 100644 tests/Feature/MealImageAnalysisTest.php diff --git a/app/Ai/Agents/MealImageAnalyzer.php b/app/Ai/Agents/MealImageAnalyzer.php new file mode 100644 index 0000000..c919229 --- /dev/null +++ b/app/Ai/Agents/MealImageAnalyzer.php @@ -0,0 +1,91 @@ + $schema->string() + ->description('Short French meal title.') + ->required(), + 'caption' => $schema->string() + ->description('One concise French sentence describing the visible meal.') + ->required(), + 'type' => $schema->string() + ->enum(MealPostType::class) + ->description('Best matching meal type.') + ->required(), + 'calories' => $schema->integer() + ->min(0) + ->description('Estimated calories for the visible meal.') + ->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('Visible ingredients ordered by importance.') + ->required(), + ]; + } +} diff --git a/app/Http/Controllers/MealImageAnalysisController.php b/app/Http/Controllers/MealImageAnalysisController.php new file mode 100644 index 0000000..2e43fcf --- /dev/null +++ b/app/Http/Controllers/MealImageAnalysisController.php @@ -0,0 +1,132 @@ +prompt( + $this->prompt(), + [$request->file('image')], + timeout: 90, + ); + } catch (Throwable $exception) { + report($exception); + + return response()->json([ + 'message' => "Impossible d'analyser l'image pour le moment.", + ], 502); + } + + return response()->json([ + 'data' => $this->normalizeDraft( + $response instanceof Arrayable + ? $response->toArray() + : (json_decode($response->text, true) ?: []) + ), + ]); + } + + private function prompt(): string + { + return <<<'PROMPT' +Analyse l'image du repas et prépare un brouillon éditable pour l'utilisateur. + +Contraintes: +- Ne renseigne que ce qui est visible ou très probable dans l'image. +- Estime les quantités pour une portion consommable. +- Retourne des calories et macros cohérentes entre elles. +- Si l'image ne montre pas clairement un repas, retourne un titre générique et des valeurs à 0 avec une liste d'ingrédients vide. +PROMPT; + } + + private function normalizeDraft(array $draft): array + { + $type = MealPostType::tryFrom($this->cleanString($draft['type'] ?? '', 32)) + ?? MealPostType::OTHER; + + return [ + 'title' => $this->cleanString($draft['title'] ?? 'Repas', 255) ?: 'Repas', + 'caption' => $this->cleanString($draft['caption'] ?? '', 1000), + 'type' => $type->value, + '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'] ?? []), + ]; + } + + 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->value, + ]; + } + + 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); + } +} diff --git a/app/Http/Requests/MealImageAnalysisRequest.php b/app/Http/Requests/MealImageAnalysisRequest.php new file mode 100644 index 0000000..99c2a85 --- /dev/null +++ b/app/Http/Requests/MealImageAnalysisRequest.php @@ -0,0 +1,29 @@ + ['required', 'image', 'max:4096'], + ]; + } + + public function authorize(): bool + { + return true; + } + + public function messages(): array + { + return [ + 'image.required' => 'Ajoute une image à analyser.', + 'image.image' => 'Le fichier doit être une image.', + 'image.max' => "L'image ne doit pas dépasser 4 Mo.", + ]; + } +} diff --git a/config/ai.php b/config/ai.php index c2b1491..938733c 100644 --- a/config/ai.php +++ b/config/ai.php @@ -13,11 +13,11 @@ return [ | */ - 'default' => 'deepseek', + 'default' => 'gemini', 'default_for_images' => 'gemini', - 'default_for_audio' => 'openai', - 'default_for_transcription' => 'openai', - 'default_for_embeddings' => 'deepseek', + 'default_for_audio' => 'gemini', + 'default_for_transcription' => 'gemini', + 'default_for_embeddings' => 'gemini', 'default_for_reranking' => 'cohere', /* diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index aa0732a..d6fc28a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -27,6 +27,7 @@ services: DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}" DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}" DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD is required}" + GEMINI_API_KEY: "${GEMINI_API_KEY:?GEMINI_API_KEY is required}" REDIS_HOST: "redis" REDIS_CLIENT: "predis" QUEUE_CONNECTION: "redis" diff --git a/routes/api.php b/routes/api.php index e961857..8458d45 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\DeviceTokenController; +use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealPostController; use Illuminate\Support\Facades\Route; @@ -25,6 +26,7 @@ Route::middleware('auth:sanctum')->group(function (): void { Route::middleware('auth:sanctum')->group(function (): void { Route::get('meal-posts/stats', [MealPostController::class, 'stats']); + Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']); Route::apiResource('meal-posts', MealPostController::class)->except(['update']); Route::put('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.update'); Route::patch('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.patch'); diff --git a/tests/Feature/MealImageAnalysisTest.php b/tests/Feature/MealImageAnalysisTest.php new file mode 100644 index 0000000..e8f6906 --- /dev/null +++ b/tests/Feature/MealImageAnalysisTest.php @@ -0,0 +1,91 @@ +create()); + + MealImageAnalyzer::fake([ + [ + 'title' => ' Bowl saumon avocat ', + 'caption' => 'Un bowl avec du saumon, du riz et de l’avocat.', + 'type' => MealPostType::LUNCH->value, + 'calories' => 612, + 'proteins' => 39.4, + 'carbs' => 58.2, + 'fats' => 25.1, + 'ingredients' => [ + [ + 'position' => 12, + 'ingredient' => ' Riz ', + 'quantity' => 150, + 'unit' => IngredientUnit::GRAM->value, + ], + [ + 'position' => 13, + 'ingredient' => 'Saumon', + 'quantity' => 120, + 'unit' => IngredientUnit::GRAM->value, + ], + ], + ], + ]); + + $response = $this + ->withHeader('Accept', 'application/json') + ->post('/api/meal-posts/analyze-image', [ + 'image' => fakeMealAnalysisImage(), + ]); + + $response + ->assertOk() + ->assertJsonPath('data.title', 'Bowl saumon avocat') + ->assertJsonPath('data.type', MealPostType::LUNCH->value) + ->assertJsonPath('data.calories', 612) + ->assertJsonPath('data.proteins', 39.4) + ->assertJsonPath('data.ingredients.0.position', 1) + ->assertJsonPath('data.ingredients.0.ingredient', 'Riz') + ->assertJsonPath('data.ingredients.1.ingredient', 'Saumon'); + + MealImageAnalyzer::assertPrompted( + fn ($prompt): bool => str_contains($prompt->prompt, "Analyse l'image") + && $prompt->attachments->count() === 1 + ); +}); + +it('requires authentication to analyze a meal image', function () { + $this + ->withHeader('Accept', 'application/json') + ->post('/api/meal-posts/analyze-image', [ + 'image' => fakeMealAnalysisImage(), + ]) + ->assertUnauthorized(); +}); + +it('validates the analyzed image', function () { + Sanctum::actingAs(User::factory()->create()); + + $this + ->postJson('/api/meal-posts/analyze-image') + ->assertUnprocessable() + ->assertJsonValidationErrors(['image']); +});