feat: save ai usage
This commit is contained in:
parent
24ec4165a6
commit
8715267361
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum AiUsageStatus: string
|
||||||
|
{
|
||||||
|
case SUCCESS = 'success';
|
||||||
|
case FAILED = 'failed';
|
||||||
|
}
|
||||||
|
|
@ -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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Ai\Agents\MealImageAnalyzer;
|
use App\Ai\Agents\MealImageAnalyzer;
|
||||||
|
use App\Enums\AiUsageType;
|
||||||
use App\Enums\IngredientUnit;
|
use App\Enums\IngredientUnit;
|
||||||
use App\Enums\MealPostType;
|
use App\Enums\MealPostType;
|
||||||
use App\Http\Requests\MealImageAnalysisRequest;
|
use App\Http\Requests\MealImageAnalysisRequest;
|
||||||
|
use App\Services\AiUsageRecorder;
|
||||||
use Illuminate\Contracts\Support\Arrayable;
|
use Illuminate\Contracts\Support\Arrayable;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Ai\Responses\Data\Usage;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class MealImageAnalysisController extends Controller
|
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 {
|
try {
|
||||||
$response = MealImageAnalyzer::make()->prompt(
|
$response = MealImageAnalyzer::make()->prompt(
|
||||||
$this->prompt(),
|
$this->prompt(),
|
||||||
[$request->file('image')],
|
[$image],
|
||||||
timeout: 90,
|
timeout: 90,
|
||||||
);
|
);
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
report($exception);
|
report($exception);
|
||||||
|
|
||||||
|
$aiUsageRecorder->failed(
|
||||||
|
user: $request->user(),
|
||||||
|
type: AiUsageType::MEAL_IMAGE_ANALYSIS,
|
||||||
|
exception: $exception,
|
||||||
|
input: $input,
|
||||||
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => __('api.meal_image_analysis.failed'),
|
'message' => __('api.meal_image_analysis.failed'),
|
||||||
], 502);
|
], 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([
|
return response()->json([
|
||||||
'data' => $this->normalizeDraft(
|
'data' => $draft,
|
||||||
$response instanceof Arrayable
|
|
||||||
? $response->toArray()
|
|
||||||
: (json_decode($response->text, true) ?: [])
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +79,26 @@ Contraintes:
|
||||||
PROMPT;
|
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
|
private function normalizeDraft(array $draft): array
|
||||||
{
|
{
|
||||||
$type = MealPostType::tryFrom($this->cleanString($draft['type'] ?? '', 32))
|
$type = MealPostType::tryFrom($this->cleanString($draft['type'] ?? '', 32))
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
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 HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
|
use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
|
||||||
|
|
@ -97,8 +97,9 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
|
||||||
|
|
||||||
public function getFilamentAvatarUrl(): ?string
|
public function getFilamentAvatarUrl(): ?string
|
||||||
{
|
{
|
||||||
return Storage::url($this->avatar_url);
|
return Storage::url($this->avatar_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deviceTokens(): HasMany
|
public function deviceTokens(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(DeviceToken::class);
|
return $this->hasMany(DeviceToken::class);
|
||||||
|
|
@ -109,6 +110,11 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
|
||||||
return $this->hasMany(MealPosts::class);
|
return $this->hasMany(MealPosts::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function aiUsages(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AiUsage::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function workouts(): HasMany
|
public function workouts(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(WorkoutSessions::class);
|
return $this->hasMany(WorkoutSessions::class);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Ai\Agents\MealMaker;
|
use App\Ai\Agents\MealMaker;
|
||||||
|
use App\Enums\AiUsageType;
|
||||||
use App\Enums\IngredientUnit;
|
use App\Enums\IngredientUnit;
|
||||||
use App\Enums\MealPostType;
|
use App\Enums\MealPostType;
|
||||||
use App\Enums\MealPostVisibility;
|
use App\Enums\MealPostVisibility;
|
||||||
|
|
@ -14,61 +15,182 @@ use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Ai\Image;
|
use Laravel\Ai\Image;
|
||||||
|
use Laravel\Ai\Responses\AgentResponse;
|
||||||
|
use Laravel\Ai\Responses\Data\Usage;
|
||||||
|
use Laravel\Ai\Responses\ImageResponse;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class AiMealPostGenerator
|
class AiMealPostGenerator
|
||||||
{
|
{
|
||||||
|
public function __construct(private AiUsageRecorder $aiUsageRecorder) {}
|
||||||
|
|
||||||
public function generate(): MealPosts
|
public function generate(): MealPosts
|
||||||
{
|
{
|
||||||
$draft = $this->generateMealDraft();
|
$aiUser = null;
|
||||||
$imagePath = $this->generateMealImage($draft);
|
$input = null;
|
||||||
$ingredients = $draft['ingredients'];
|
|
||||||
|
|
||||||
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 {
|
[$draft, $draftResponse] = $this->generateMealDraft($textPrompt);
|
||||||
$mealPost = MealPosts::create([
|
|
||||||
...$draft,
|
|
||||||
'user_id' => $this->aiUser()->getKey(),
|
|
||||||
'image_url' => $imagePath,
|
|
||||||
'visibility' => MealPostVisibility::Public,
|
|
||||||
'ai_generated' => true,
|
|
||||||
'eaten_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($ingredients !== []) {
|
$imagePrompt = $this->imagePrompt($draft);
|
||||||
$mealPost->ingredients()->createMany($ingredients);
|
$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(
|
return [
|
||||||
$response instanceof Arrayable
|
$this->normalizeDraft(
|
||||||
? $response->toArray()
|
$response instanceof Arrayable
|
||||||
: (json_decode($response->text ?? '', true) ?: [])
|
? $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()
|
->landscape()
|
||||||
->quality($this->imageQuality())
|
->quality($this->imageQuality())
|
||||||
->timeout($this->imageTimeout())
|
->timeout($this->imageTimeout())
|
||||||
->generate()
|
->generate();
|
||||||
->storePublicly($this->imageStoragePath(), 'public');
|
|
||||||
|
$path = $response->storePublicly($this->imageStoragePath(), 'public');
|
||||||
|
|
||||||
if (! is_string($path)) {
|
if (! is_string($path)) {
|
||||||
throw new RuntimeException("L'image du repas IA n'a pas pu etre stockee.");
|
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
|
private function aiUser(): User
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\AiUsageStatus;
|
||||||
|
use App\Enums\AiUsageType;
|
||||||
|
use App\Models\AiUsage;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<AiUsage>
|
||||||
|
*/
|
||||||
|
class AiUsageFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = AiUsage::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'type' => AiUsageType::MEAL_IMAGE_ANALYSIS,
|
||||||
|
'status' => AiUsageStatus::SUCCESS,
|
||||||
|
'input' => [
|
||||||
|
'image' => [
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'size' => 1024,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'output' => [
|
||||||
|
'title' => $this->faker->words(3, true),
|
||||||
|
],
|
||||||
|
'provider' => null,
|
||||||
|
'model' => null,
|
||||||
|
'prompt_tokens' => null,
|
||||||
|
'completion_tokens' => null,
|
||||||
|
'total_tokens' => null,
|
||||||
|
'error_message' => null,
|
||||||
|
'related_type' => null,
|
||||||
|
'related_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'status' => AiUsageStatus::FAILED,
|
||||||
|
'output' => null,
|
||||||
|
'error_message' => $this->faker->sentence(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('ai_usages', function (Blueprint $table) {
|
||||||
|
$table->ulid('id')->primary();
|
||||||
|
$table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('type', 64);
|
||||||
|
$table->string('status', 32);
|
||||||
|
$table->json('input')->nullable();
|
||||||
|
$table->json('output')->nullable();
|
||||||
|
$table->string('provider')->nullable();
|
||||||
|
$table->string('model')->nullable();
|
||||||
|
$table->unsignedInteger('prompt_tokens')->nullable();
|
||||||
|
$table->unsignedInteger('completion_tokens')->nullable();
|
||||||
|
$table->unsignedInteger('total_tokens')->nullable();
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->string('related_type')->nullable();
|
||||||
|
$table->string('related_id', 36)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'type', 'created_at']);
|
||||||
|
$table->index(['type', 'status', 'created_at']);
|
||||||
|
$table->index(['related_type', 'related_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ai_usages');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Ai\Agents\MealMaker;
|
use App\Ai\Agents\MealMaker;
|
||||||
|
use App\Enums\AiUsageStatus;
|
||||||
|
use App\Enums\AiUsageType;
|
||||||
use App\Enums\IngredientUnit;
|
use App\Enums\IngredientUnit;
|
||||||
use App\Enums\MealPostType;
|
use App\Enums\MealPostType;
|
||||||
use App\Enums\MealPostVisibility;
|
use App\Enums\MealPostVisibility;
|
||||||
|
use App\Models\AiUsage;
|
||||||
use App\Models\MealPosts;
|
use App\Models\MealPosts;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Exceptions;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Ai\Image;
|
use Laravel\Ai\Image;
|
||||||
|
|
||||||
|
|
@ -119,4 +123,80 @@ it('generates an ai meal post with an image', function () {
|
||||||
&& $prompt->isLandscape()
|
&& $prompt->isLandscape()
|
||||||
&& $prompt->quality === 'medium'
|
&& $prompt->quality === 'medium'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$usage = AiUsage::query()->sole();
|
||||||
|
|
||||||
|
expect($usage->user_id)->toBe($mealPost->user_id)
|
||||||
|
->and($usage->type)->toBe(AiUsageType::MEAL_POST_GENERATION)
|
||||||
|
->and($usage->status)->toBe(AiUsageStatus::SUCCESS)
|
||||||
|
->and($usage->related_type)->toBe(MealPosts::class)
|
||||||
|
->and($usage->related_id)->toBe($mealPost->id)
|
||||||
|
->and($usage->input['image'])->toMatchArray([
|
||||||
|
'size' => '3:2',
|
||||||
|
'quality' => 'medium',
|
||||||
|
'storage_path' => 'meal-posts/ai-generated',
|
||||||
|
])
|
||||||
|
->and($usage->output)->toMatchArray([
|
||||||
|
'title' => 'Bowl de saumon croustillant',
|
||||||
|
'type' => MealPostType::LUNCH->value,
|
||||||
|
'calories' => 640,
|
||||||
|
'image_url' => $mealPost->image_url,
|
||||||
|
'ingredients' => [
|
||||||
|
[
|
||||||
|
'position' => 1,
|
||||||
|
'ingredient' => 'Riz vinaigre',
|
||||||
|
'quantity' => 160,
|
||||||
|
'unit' => IngredientUnit::GRAM->value,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'position' => 2,
|
||||||
|
'ingredient' => 'Saumon',
|
||||||
|
'quantity' => 130,
|
||||||
|
'unit' => IngredientUnit::GRAM->value,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a failed ai meal post generation attempt', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
Exceptions::fake();
|
||||||
|
|
||||||
|
config()->set('meal_posts.ai_generation.user_name', 'Chef IA');
|
||||||
|
config()->set('meal_posts.ai_generation.user_email', 'chef-ia@example.test');
|
||||||
|
config()->set('meal_posts.ai_generation.image_quality', 'medium');
|
||||||
|
|
||||||
|
MealMaker::fake([
|
||||||
|
[
|
||||||
|
'title' => 'Bowl impossible',
|
||||||
|
'caption' => 'Un bowl qui ne sera pas publie.',
|
||||||
|
'type' => MealPostType::LUNCH->value,
|
||||||
|
'calories' => 640,
|
||||||
|
'proteins' => 42.4,
|
||||||
|
'carbs' => 62.2,
|
||||||
|
'fats' => 24.8,
|
||||||
|
'ingredients' => [],
|
||||||
|
'image_prompt' => 'Photorealistic impossible bowl',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Image::fake(
|
||||||
|
fn () => throw new \RuntimeException('Image provider unavailable')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->artisan('meal-posts:generate-ai')
|
||||||
|
->assertExitCode(1);
|
||||||
|
|
||||||
|
Exceptions::assertReported(\RuntimeException::class);
|
||||||
|
|
||||||
|
expect(MealPosts::query()->where('ai_generated', true)->count())->toBe(0);
|
||||||
|
|
||||||
|
$usage = AiUsage::query()->sole();
|
||||||
|
|
||||||
|
expect($usage->user->email)->toBe('chef-ia@example.test')
|
||||||
|
->and($usage->type)->toBe(AiUsageType::MEAL_POST_GENERATION)
|
||||||
|
->and($usage->status)->toBe(AiUsageStatus::FAILED)
|
||||||
|
->and($usage->input['image_prompt'])->toContain('impossible bowl')
|
||||||
|
->and($usage->output)->toBeNull()
|
||||||
|
->and($usage->error_message)->toContain('Image provider unavailable');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Ai\Agents\MealImageAnalyzer;
|
use App\Ai\Agents\MealImageAnalyzer;
|
||||||
|
use App\Enums\AiUsageStatus;
|
||||||
|
use App\Enums\AiUsageType;
|
||||||
use App\Enums\IngredientUnit;
|
use App\Enums\IngredientUnit;
|
||||||
use App\Enums\MealPostType;
|
use App\Enums\MealPostType;
|
||||||
|
use App\Models\AiUsage;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Exceptions;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
@ -22,7 +26,9 @@ function fakeMealAnalysisImage(): UploadedFile
|
||||||
}
|
}
|
||||||
|
|
||||||
it('analyzes a meal image into a draft', function () {
|
it('analyzes a meal image into a draft', function () {
|
||||||
Sanctum::actingAs(User::factory()->create());
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
MealImageAnalyzer::fake([
|
MealImageAnalyzer::fake([
|
||||||
[
|
[
|
||||||
|
|
@ -70,6 +76,69 @@ it('analyzes a meal image into a draft', function () {
|
||||||
fn ($prompt): bool => str_contains($prompt->prompt, "Analyse l'image")
|
fn ($prompt): bool => str_contains($prompt->prompt, "Analyse l'image")
|
||||||
&& $prompt->attachments->count() === 1
|
&& $prompt->attachments->count() === 1
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$usage = AiUsage::query()->sole();
|
||||||
|
|
||||||
|
expect($usage->user_id)->toBe($user->id)
|
||||||
|
->and($usage->type)->toBe(AiUsageType::MEAL_IMAGE_ANALYSIS)
|
||||||
|
->and($usage->status)->toBe(AiUsageStatus::SUCCESS)
|
||||||
|
->and($usage->input['image'])->toMatchArray([
|
||||||
|
'client_original_name' => 'meal.png',
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
])
|
||||||
|
->and($usage->output)->toMatchArray([
|
||||||
|
'title' => 'Bowl saumon avocat',
|
||||||
|
'type' => MealPostType::LUNCH->value,
|
||||||
|
'calories' => 612,
|
||||||
|
'proteins' => 39.4,
|
||||||
|
'ingredients' => [
|
||||||
|
[
|
||||||
|
'position' => 1,
|
||||||
|
'ingredient' => 'Riz',
|
||||||
|
'quantity' => 150,
|
||||||
|
'unit' => IngredientUnit::GRAM->value,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'position' => 2,
|
||||||
|
'ingredient' => 'Saumon',
|
||||||
|
'quantity' => 120,
|
||||||
|
'unit' => IngredientUnit::GRAM->value,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a failed meal image analysis attempt', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
Exceptions::fake();
|
||||||
|
|
||||||
|
MealImageAnalyzer::fake(
|
||||||
|
fn () => throw new \RuntimeException('Provider unavailable')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this
|
||||||
|
->withHeader('Accept', 'application/json')
|
||||||
|
->post('/api/meal-posts/analyze-image', [
|
||||||
|
'image' => fakeMealAnalysisImage(),
|
||||||
|
])
|
||||||
|
->assertStatus(502)
|
||||||
|
->assertJsonPath('message', __('api.meal_image_analysis.failed'));
|
||||||
|
|
||||||
|
Exceptions::assertReported(\RuntimeException::class);
|
||||||
|
|
||||||
|
$usage = AiUsage::query()->sole();
|
||||||
|
|
||||||
|
expect($usage->user_id)->toBe($user->id)
|
||||||
|
->and($usage->type)->toBe(AiUsageType::MEAL_IMAGE_ANALYSIS)
|
||||||
|
->and($usage->status)->toBe(AiUsageStatus::FAILED)
|
||||||
|
->and($usage->input['image'])->toMatchArray([
|
||||||
|
'client_original_name' => 'meal.png',
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
])
|
||||||
|
->and($usage->output)->toBeNull()
|
||||||
|
->and($usage->error_message)->toContain('Provider unavailable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires authentication to analyze a meal image', function () {
|
it('requires authentication to analyze a meal image', function () {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue