181 lines
5.4 KiB
PHP
181 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Ai\Agents\MealImageAnalyzer;
|
|
use App\Enums\AiUsageType;
|
|
use App\Enums\IngredientUnit;
|
|
use App\Enums\MealPostType;
|
|
use App\Http\Requests\MealImageAnalysisRequest;
|
|
use App\Services\AiUsageRecorder;
|
|
use Illuminate\Contracts\Support\Arrayable;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Ai\Responses\Data\Usage;
|
|
use Throwable;
|
|
|
|
class MealImageAnalysisController extends Controller
|
|
{
|
|
public function store(MealImageAnalysisRequest $request, AiUsageRecorder $aiUsageRecorder): JsonResponse
|
|
{
|
|
$image = $request->file('image');
|
|
$input = $this->analysisInput($image);
|
|
|
|
try {
|
|
$response = MealImageAnalyzer::make()->prompt(
|
|
$this->prompt(),
|
|
[$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' => $draft,
|
|
]);
|
|
}
|
|
|
|
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 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))
|
|
?? 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);
|
|
}
|
|
}
|