feat: ai analyze
This commit is contained in:
parent
8c612452f2
commit
fe78c000f0
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 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']);
|
||||
});
|
||||
Loading…
Reference in New Issue