feat: save ai usage

This commit is contained in:
2026-05-24 11:12:04 +02:00
parent 24ec4165a6
commit 8715267361
11 changed files with 629 additions and 40 deletions
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum AiUsageStatus: string
{
case SUCCESS = 'success';
case FAILED = 'failed';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum AiUsageType: string
{
case MEAL_IMAGE_ANALYSIS = 'meal_image_analysis';
case MEAL_POST_GENERATION = 'meal_post_generation';
}
@@ -3,38 +3,66 @@
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): JsonResponse
public function store(MealImageAnalysisRequest $request, AiUsageRecorder $aiUsageRecorder): JsonResponse
{
$image = $request->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))
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\AiUsageStatus;
use App\Enums\AiUsageType;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class AiUsage extends Model
{
/** @use HasFactory<\Database\Factories\AiUsageFactory> */
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',
];
}
}
+8 -2
View File
@@ -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);
+152 -30
View File
@@ -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<string, mixed>, 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<string, mixed>
*/
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<string, mixed> $draft
* @return array<string, mixed>
*/
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
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Services;
use App\Enums\AiUsageStatus;
use App\Enums\AiUsageType;
use App\Models\AiUsage;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Throwable;
class AiUsageRecorder
{
public function success(
?User $user,
AiUsageType $type,
?array $input = null,
?array $output = null,
?string $provider = null,
?string $model = null,
?int $promptTokens = null,
?int $completionTokens = null,
?int $totalTokens = null,
?Model $related = null,
): AiUsage {
return $this->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(),
]);
}
}