feat: ai analyze
Laravel CI-CD / Tests Unitaires (push) Successful in 19s Details
Laravel CI-CD / Build & Push Docker (push) Successful in 45s Details
Laravel CI-CD / Deploy via Portainer (push) Successful in 1s Details

This commit is contained in:
Leon Morival 2026-05-18 15:42:56 +02:00
parent 8c612452f2
commit fe78c000f0
7 changed files with 350 additions and 4 deletions

View File

@ -0,0 +1,91 @@
<?php
namespace App\Ai\Agents;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;
class MealImageAnalyzer implements Agent, Conversational, HasStructuredOutput, HasTools
{
use Promptable;
public function instructions(): Stringable|string
{
return <<<'INSTRUCTIONS'
You analyze meal photos for a food tracking app.
Return a practical estimate for the visible portion only. Do not invent hidden dishes, drinks, sauces, or sides. If quantities are uncertain, make a reasonable conservative estimate.
Use French for title, caption, and ingredient names.
Use one of these units for ingredients: g, kg, ml, l, piece, tsp, tbsp.
Use one of these meal types: breakfast, lunch, dinner, brunch, snack, drink, supplement, other.
Nutrition values must estimate the whole visible meal.
INSTRUCTIONS;
}
/**
* @return Message[]
*/
public function messages(): iterable
{
return [];
}
/**
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
public function schema(JsonSchema $schema): array
{
return [
'title' => $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(),
];
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers;
use App\Ai\Agents\MealImageAnalyzer;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use App\Http\Requests\MealImageAnalysisRequest;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use Throwable;
class MealImageAnalysisController extends Controller
{
public function store(MealImageAnalysisRequest $request): JsonResponse
{
try {
$response = MealImageAnalyzer::make()->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);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class MealImageAnalysisRequest extends FormRequest
{
public function rules(): array
{
return [
'image' => ['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.",
];
}
}

View File

@ -13,11 +13,11 @@ return [
| |
*/ */
'default' => 'deepseek', 'default' => 'gemini',
'default_for_images' => 'gemini', 'default_for_images' => 'gemini',
'default_for_audio' => 'openai', 'default_for_audio' => 'gemini',
'default_for_transcription' => 'openai', 'default_for_transcription' => 'gemini',
'default_for_embeddings' => 'deepseek', 'default_for_embeddings' => 'gemini',
'default_for_reranking' => 'cohere', 'default_for_reranking' => 'cohere',
/* /*

View File

@ -27,6 +27,7 @@ services:
DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}" DB_DATABASE: "${DB_DATABASE:?DB_DATABASE is required}"
DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}" DB_USERNAME: "${DB_USERNAME:?DB_USERNAME is required}"
DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD 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_HOST: "redis"
REDIS_CLIENT: "predis" REDIS_CLIENT: "predis"
QUEUE_CONNECTION: "redis" QUEUE_CONNECTION: "redis"

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\DeviceTokenController; use App\Http\Controllers\DeviceTokenController;
use App\Http\Controllers\MealImageAnalysisController;
use App\Http\Controllers\MealPostController; use App\Http\Controllers\MealPostController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -25,6 +26,7 @@ Route::middleware('auth:sanctum')->group(function (): void {
Route::middleware('auth:sanctum')->group(function (): void { Route::middleware('auth:sanctum')->group(function (): void {
Route::get('meal-posts/stats', [MealPostController::class, 'stats']); 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::apiResource('meal-posts', MealPostController::class)->except(['update']);
Route::put('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.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'); Route::patch('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.patch');

View File

@ -0,0 +1,91 @@
<?php
use App\Ai\Agents\MealImageAnalyzer;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
function fakeMealAnalysisImage(): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'meal-analysis-');
file_put_contents($path, base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lmHqWQAAAABJRU5ErkJggg=='
));
return new UploadedFile($path, 'meal.png', 'image/png', null, true);
}
it('analyzes a meal image into a draft', function () {
Sanctum::actingAs(User::factory()->create());
MealImageAnalyzer::fake([
[
'title' => ' Bowl saumon avocat ',
'caption' => 'Un bowl avec du saumon, du riz et de lavocat.',
'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']);
});