impmementation de gemini vertex & maistral ai

This commit is contained in:
jeremy bayse
2026-06-22 11:24:06 +02:00
parent b85c2ba5a1
commit 61c3c979c4
10 changed files with 849 additions and 24 deletions
+1
View File
@@ -19,6 +19,7 @@
/public/hot /public/hot
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/app/*.json
/storage/pail /storage/pail
/vendor /vendor
_ide_helper.php _ide_helper.php
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Attachment; use App\Models\Attachment;
use App\Services\GeminiService; use App\Services\GeminiService;
use App\Services\OllamaService; use App\Services\OllamaService;
use App\Services\MistralService;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -38,7 +39,7 @@ class AttachmentController extends Controller
/** /**
* Effectue une OCRisation de la pièce jointe à l'aide de l'API Gemini. * Effectue une OCRisation de la pièce jointe à l'aide de l'API Gemini.
*/ */
public function ocr(Request $request, Attachment $attachment, GeminiService $geminiService, OllamaService $ollamaService): JsonResponse public function ocr(Request $request, Attachment $attachment, GeminiService $geminiService, OllamaService $ollamaService, MistralService $mistralService): JsonResponse
{ {
// On vérifie si l'utilisateur a le droit de voir la commande liée à cette pièce jointe // On vérifie si l'utilisateur a le droit de voir la commande liée à cette pièce jointe
$order = $attachment->order; $order = $attachment->order;
@@ -48,6 +49,8 @@ class AttachmentController extends Controller
$mode = $request->input('mode', 'gemini'); $mode = $request->input('mode', 'gemini');
if ($mode === 'ollama') { if ($mode === 'ollama') {
$data = $ollamaService->analyzeQuote($attachment); $data = $ollamaService->analyzeQuote($attachment);
} elseif ($mode === 'mistral') {
$data = $mistralService->analyzeQuote($attachment);
} else { } else {
$data = $geminiService->analyzeQuote($attachment); $data = $geminiService->analyzeQuote($attachment);
} }
@@ -66,7 +69,7 @@ class AttachmentController extends Controller
/** /**
* Effectue une OCRisation directe sur un fichier téléchargé temporairement. * Effectue une OCRisation directe sur un fichier téléchargé temporairement.
*/ */
public function ocrUpload(Request $request, GeminiService $geminiService, OllamaService $ollamaService): JsonResponse public function ocrUpload(Request $request, GeminiService $geminiService, OllamaService $ollamaService, MistralService $mistralService): JsonResponse
{ {
$request->validate([ $request->validate([
'file' => 'required|file|mimes:pdf,png,jpg,jpeg,webp|max:10240', 'file' => 'required|file|mimes:pdf,png,jpg,jpeg,webp|max:10240',
@@ -77,6 +80,8 @@ class AttachmentController extends Controller
$mode = $request->input('mode', 'gemini'); $mode = $request->input('mode', 'gemini');
if ($mode === 'ollama') { if ($mode === 'ollama') {
$data = $ollamaService->analyzeUploadedFile($request->file('file')); $data = $ollamaService->analyzeUploadedFile($request->file('file'));
} elseif ($mode === 'mistral') {
$data = $mistralService->analyzeUploadedFile($request->file('file'));
} else { } else {
$data = $geminiService->analyzeUploadedFile($request->file('file')); $data = $geminiService->analyzeUploadedFile($request->file('file'));
} }
+88 -14
View File
@@ -6,16 +6,23 @@ use App\Models\Attachment;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Google\Auth\Credentials\ServiceAccountCredentials;
class GeminiService class GeminiService
{ {
protected ?string $apiKey; protected ?string $apiKey;
protected string $model; protected string $model;
protected string $region;
protected ?string $projectId;
protected ?string $credentialsPath;
public function __construct() public function __construct()
{ {
$this->apiKey = config('services.gemini.key'); $this->apiKey = config('services.gemini.key');
$this->model = config('services.gemini.model', 'gemini-1.5-flash'); $this->model = config('services.gemini.model', 'gemini-1.5-flash');
$this->region = config('services.gemini.region', 'us-central1');
$this->projectId = config('services.gemini.project_id');
$this->credentialsPath = config('services.gemini.credentials_path');
} }
/** /**
@@ -51,10 +58,6 @@ class GeminiService
*/ */
protected function analyzeContent(string $fileContent, string $mimeType, string $fileName): array protected function analyzeContent(string $fileContent, string $mimeType, string $fileName): array
{ {
if (empty($this->apiKey)) {
throw new \Exception("La clé API Gemini n'est pas configurée dans l'environnement.");
}
// Encodage Base64 pour l'API Gemini // Encodage Base64 pour l'API Gemini
$base64Data = base64_encode($fileContent); $base64Data = base64_encode($fileContent);
@@ -86,15 +89,86 @@ Règles importantes :
3. Ne réponds QUE par le JSON brut, valide, sans aucun texte explicatif avant ou après, et sans bloc de code markdown. 3. Ne réponds QUE par le JSON brut, valide, sans aucun texte explicatif avant ou après, et sans bloc de code markdown.
4. Assure-toi que les montants et quantités sont des nombres."; 4. Assure-toi que les montants et quantités sont des nombres.";
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}"; // Détection automatique de Vertex AI vs Gemini (Google AI Studio)
$isVertex = false;
$credentials = null;
Log::info("Début de l'analyse OCR Gemini pour le fichier : {$fileName}"); $resolvedPath = $this->credentialsPath;
if ($resolvedPath && !str_starts_with($resolvedPath, '{')) {
if (!file_exists($resolvedPath) && file_exists(base_path($resolvedPath))) {
$resolvedPath = base_path($resolvedPath);
}
}
$response = Http::withHeaders([ if ($resolvedPath && (file_exists($resolvedPath) || str_starts_with($resolvedPath, '{'))) {
'Content-Type' => 'application/json', $isVertex = true;
])->post($url, [ $credentials = $resolvedPath;
} elseif ($this->apiKey) {
$resolvedApiKey = $this->apiKey;
if (!str_starts_with($resolvedApiKey, '{')) {
if (!file_exists($resolvedApiKey) && file_exists(base_path($resolvedApiKey))) {
$resolvedApiKey = base_path($resolvedApiKey);
}
}
if (str_starts_with($resolvedApiKey, '{') || (file_exists($resolvedApiKey) && str_ends_with($resolvedApiKey, '.json'))) {
$isVertex = true;
$credentials = $resolvedApiKey;
}
}
if ($isVertex) {
Log::info("Début de l'analyse OCR Vertex AI pour le fichier : {$fileName}");
// Extraction du Project ID à partir des identifiants si non fourni explicitement
$projectId = $this->projectId;
if (!$projectId && $credentials) {
try {
$jsonContent = file_exists($credentials) ? file_get_contents($credentials) : $credentials;
$decodedCreds = json_decode($jsonContent, true);
$projectId = $decodedCreds['project_id'] ?? null;
} catch (\Exception $e) {
Log::warning("Impossible d'extraire le project_id de la configuration : " . $e->getMessage());
}
}
if (!$projectId) {
throw new \Exception("Le Project ID Google Cloud est requis pour Vertex AI.");
}
// Génération du token OAuth2 avec le SDK
try {
$scopes = ['https://www.googleapis.com/auth/cloud-platform'];
$sa = new ServiceAccountCredentials($scopes, $credentials);
$token = $sa->fetchAuthToken();
$accessToken = $token['access_token'];
} catch (\Exception $e) {
Log::error("Erreur d'authentification Vertex AI : " . $e->getMessage());
throw new \Exception("Échec de l'authentification Vertex AI : " . $e->getMessage());
}
// Vertex AI Endpoint
$url = "https://{$this->region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$this->region}/publishers/google/models/{$this->model}:generateContent";
$headers = [
'Authorization' => 'Bearer ' . $accessToken,
'Content-Type' => 'application/json',
];
} else {
if (empty($this->apiKey)) {
throw new \Exception("La clé API Gemini n'est pas configurée dans l'environnement.");
}
Log::info("Début de l'analyse OCR Gemini (Google AI Studio) pour le fichier : {$fileName}");
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}";
$headers = [
'Content-Type' => 'application/json',
];
}
$response = Http::withHeaders($headers)->post($url, [
'contents' => [ 'contents' => [
[ [
'role' => 'user',
'parts' => [ 'parts' => [
['text' => $prompt], ['text' => $prompt],
[ [
@@ -112,25 +186,25 @@ Règles importantes :
]); ]);
if ($response->failed()) { if ($response->failed()) {
Log::error("Échec de l'appel Gemini API : " . $response->body()); Log::error("Échec de l'appel API : " . $response->body());
throw new \Exception("Erreur de communication avec l'API Gemini : " . $response->status()); throw new \Exception("Erreur de communication avec l'API Google : " . $response->status());
} }
$data = $response->json(); $data = $response->json();
$textResult = $data['candidates'][0]['content']['parts'][0]['text'] ?? null; $textResult = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
if (!$textResult) { if (!$textResult) {
Log::error("Gemini n'a renvoyé aucun contenu exploitable."); Log::error("Google API n'a renvoyé aucun contenu exploitable.");
throw new \Exception("L'analyse du devis n'a retourné aucun résultat."); throw new \Exception("L'analyse du devis n'a retourné aucun résultat.");
} }
$decoded = json_decode($textResult, true); $decoded = json_decode($textResult, true);
if (json_last_error() !== JSON_ERROR_NONE) { if (json_last_error() !== JSON_ERROR_NONE) {
Log::error("Impossible de parser le JSON retourné par Gemini : " . $textResult); Log::error("Impossible de parser le JSON retourné par l'API : " . $textResult);
throw new \Exception("Le format des données retournées par l'OCR est invalide."); throw new \Exception("Le format des données retournées par l'OCR est invalide.");
} }
Log::info("OCR Gemini réussi pour le fichier : {$fileName}"); Log::info("OCR Google réussi pour le fichier : {$fileName}");
return $decoded; return $decoded;
} }
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace App\Services;
use App\Models\Attachment;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class MistralService
{
protected ?string $apiKey;
protected string $model;
public function __construct()
{
$this->apiKey = config('services.mistral.key');
$this->model = config('services.mistral.model', 'pixtral-12b-2409');
}
/**
* Analyse un document joint via l'API Mistral AI et extrait les détails structurés du devis.
*/
public function analyzeQuote(Attachment $attachment): array
{
$disk = Storage::disk('public');
if (!$disk->exists($attachment->file_path)) {
throw new \Exception("Le fichier joint n'existe pas sur le disque.");
}
$path = $disk->path($attachment->file_path);
$fileContent = file_get_contents($path);
$mimeType = $disk->mimeType($attachment->file_path);
return $this->analyzeContent($fileContent, $mimeType, $attachment->file_name);
}
/**
* Analyse directement un fichier téléchargé temporairement via l'API Mistral AI.
*/
public function analyzeUploadedFile(\Illuminate\Http\UploadedFile $file): array
{
$fileContent = file_get_contents($file->getRealPath());
$mimeType = $file->getMimeType();
return $this->analyzeContent($fileContent, $mimeType, $file->getClientOriginalName());
}
/**
* Exécute l'appel à l'API Mistral AI pour extraire les données du devis.
*/
protected function analyzeContent(string $fileContent, string $mimeType, string $fileName): array
{
if (empty($this->apiKey)) {
throw new \Exception("La clé API Mistral AI n'est pas configurée dans l'environnement.");
}
if ($mimeType === 'application/pdf') {
throw new \Exception("Le modèle Mistral AI ne prend en charge que les images (PNG, JPG, JPEG, WEBP). Pour analyser un fichier PDF, veuillez utiliser Gemini ou convertir votre document PDF en image.");
}
// Encodage Base64
$base64Data = base64_encode($fileContent);
// Prompt strict demandant un format JSON conforme aux attentes du formulaire
$prompt = "Tu es un assistant administratif spécialisé dans l'analyse de devis.
Analyse le document joint pour en extraire très précisément les informations requises et retourne-les au format JSON uniquement.
Structure JSON attendue :
{
\"title\": \"Un titre descriptif pour le devis (ex: Achat matériel informatique ou Prestation d'installation)\",
\"date_devis\": \"La date d'émission du devis au format YYYY-MM-DD (si non trouvée, renvoie null)\",
\"supplier\": \"Le nom de l'entreprise émettrice / fournisseur\",
\"quote_number\": \"Le numéro de devis du fournisseur\",
\"tva_rate\": 20.0, // le taux de TVA standard constaté dans le document (valeur numérique, ex: 20 ou 5.5)
\"total_ht\": 1250.00, // Le montant total HT du devis (nombre décimal)
\"items\": [
{
\"type\": \"Matériel|Licence|Prestation|Infrastructure|Autre\", // Choisis la catégorie la plus adaptée parmi ces 5 valeurs exactes
\"designation\": \"La description de l'article ou de la prestation\",
\"quantite\": 1, // La quantité (entier ou décimal)
\"prix_unitaire\": 120.50 // Le prix unitaire HT (nombre décimal)
}
]
}
Règles importantes :
1. Extrais TOUTES les lignes d'articles réelles présentes dans le devis. Ne résume pas ou ne groupe pas les lignes si elles sont détaillées.
2. Pour chaque article, détermine la catégorie la plus adaptée ('Matériel', 'Licence', 'Prestation', 'Infrastructure', 'Autre').
3. Ne réponds QUE par le JSON brut, valide, sans aucun texte explicatif avant ou après, et sans bloc de code markdown.
4. Assure-toi que les montants et quantités sont des nombres.";
$url = "https://api.mistral.ai/v1/chat/completions";
Log::info("Début de l'analyse OCR Mistral AI pour le fichier : {$fileName}");
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
])->post($url, [
'model' => $this->model,
'messages' => [
[
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => $prompt
],
[
'type' => 'image_url',
'image_url' => "data:{$mimeType};base64,{$base64Data}"
]
]
]
],
'response_format' => [
'type' => 'json_object'
],
'temperature' => 0.0
]);
if ($response->failed()) {
Log::error("Échec de l'appel Mistral AI API : " . $response->body());
throw new \Exception("Erreur de communication avec l'API Mistral AI : " . $response->status());
}
$data = $response->json();
$textResult = $data['choices'][0]['message']['content'] ?? null;
if (!$textResult) {
Log::error("Mistral AI n'a renvoyé aucun contenu exploitable.");
throw new \Exception("L'analyse du devis n'a retourné aucun résultat.");
}
$decoded = json_decode($textResult, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error("Impossible de parser le JSON retourné par Mistral AI : " . $textResult);
throw new \Exception("Le format des données retournées par l'OCR Mistral est invalide.");
}
Log::info("OCR Mistral AI réussi pour le fichier : {$fileName}");
return $decoded;
}
}
+1
View File
@@ -8,6 +8,7 @@
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1", "barryvdh/laravel-dompdf": "^3.1",
"google/cloud-ai-platform": "^1.60",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
Generated
+521 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4f62a75d8d199b96cde494e8eb771347", "content-hash": "17b3c487893a41f0c9f34d31655b70f1",
"packages": [ "packages": [
{ {
"name": "barryvdh/laravel-dompdf", "name": "barryvdh/laravel-dompdf",
@@ -740,6 +740,72 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "time": "2025-03-06T22:45:56+00:00"
}, },
{
"name": "firebase/php-jwt",
"version": "v7.1.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpseclib/phpseclib": "~3.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
"phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/googleapis/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.1.0"
},
"time": "2026-06-11T17:54:14+00:00"
},
{ {
"name": "fruitcake/php-cors", "name": "fruitcake/php-cors",
"version": "v1.4.0", "version": "v1.4.0",
@@ -811,6 +877,367 @@
], ],
"time": "2025-12-03T09:33:47+00:00" "time": "2025-12-03T09:33:47+00:00"
}, },
{
"name": "google/auth",
"version": "v1.51.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-auth-library-php.git",
"reference": "4c4776e398ff255e81b3b8c4373983f5e1b765bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/4c4776e398ff255e81b3b8c4373983f5e1b765bf",
"reference": "4c4776e398ff255e81b3b8c4373983f5e1b765bf",
"shasum": ""
},
"require": {
"firebase/php-jwt": "^6.0||^7.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.4.5",
"php": "^8.1",
"psr/cache": "^2.0||^3.0",
"psr/http-message": "^1.1||^2.0",
"psr/log": "^2.0||^3.0"
},
"require-dev": {
"guzzlehttp/promises": "^2.0",
"kelvinmo/simplejwt": "^1.1.0",
"phpseclib/phpseclib": "^3.0.35",
"phpspec/prophecy-phpunit": "^2.1",
"phpunit/phpunit": "^9.6",
"sebastian/comparator": ">=1.2.3",
"squizlabs/php_codesniffer": "^4.0",
"symfony/filesystem": "^6.3||^7.3",
"symfony/process": "^6.0||^7.0",
"webmozart/assert": "^1.11||^2.0"
},
"suggest": {
"phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."
},
"type": "library",
"autoload": {
"psr-4": {
"Google\\Auth\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google Auth Library for PHP",
"homepage": "https://github.com/google/google-auth-library-php",
"keywords": [
"Authentication",
"google",
"oauth2"
],
"support": {
"docs": "https://cloud.google.com/php/docs/reference/auth/latest",
"issues": "https://github.com/googleapis/google-auth-library-php/issues",
"source": "https://github.com/googleapis/google-auth-library-php/tree/v1.51.0"
},
"time": "2026-06-10T00:39:33+00:00"
},
{
"name": "google/cloud-ai-platform",
"version": "v1.60.1",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-cloud-php-ai-platform.git",
"reference": "16415ca56f0e13056ff15d2ef41c5585b5b315fb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-cloud-php-ai-platform/zipball/16415ca56f0e13056ff15d2ef41c5585b5b315fb",
"reference": "16415ca56f0e13056ff15d2ef41c5585b5b315fb",
"shasum": ""
},
"require": {
"google/gax": "^1.38.0",
"php": "^8.1"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
},
"suggest": {
"ext-grpc": "Enables use of gRPC, a universal high-performance RPC framework created by Google.",
"ext-protobuf": "Provides a significant increase in throughput over the pure PHP protobuf implementation. See https://cloud.google.com/php/grpc for installation instructions."
},
"type": "library",
"extra": {
"component": {
"id": "cloud-ai-platform",
"path": "AiPlatform",
"entry": null,
"target": "googleapis/google-cloud-php-ai-platform.git"
}
},
"autoload": {
"psr-4": {
"Google\\Cloud\\AIPlatform\\": "src",
"GPBMetadata\\Google\\Cloud\\Aiplatform\\": "metadata"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google Cloud Ai Platform Client for PHP",
"support": {
"source": "https://github.com/googleapis/google-cloud-php-ai-platform/tree/v1.60.1"
},
"time": "2026-06-17T23:07:32+00:00"
},
{
"name": "google/common-protos",
"version": "4.14.1",
"source": {
"type": "git",
"url": "https://github.com/googleapis/common-protos-php.git",
"reference": "4eb6813b8068653e055fc8a63dbda3446f3e8869"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/common-protos-php/zipball/4eb6813b8068653e055fc8a63dbda3446f3e8869",
"reference": "4eb6813b8068653e055fc8a63dbda3446f3e8869",
"shasum": ""
},
"require": {
"google/protobuf": "^4.31||^5.0",
"php": "^8.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6"
},
"type": "library",
"extra": {
"component": {
"id": "common-protos",
"path": "CommonProtos",
"entry": "README.md",
"target": "googleapis/common-protos-php.git"
}
},
"autoload": {
"psr-4": {
"Google\\Api\\": "src/Api",
"Google\\Iam\\": "src/Iam",
"Google\\Rpc\\": "src/Rpc",
"Google\\Type\\": "src/Type",
"Google\\Cloud\\": "src/Cloud",
"GPBMetadata\\Google\\Api\\": "metadata/Api",
"GPBMetadata\\Google\\Iam\\": "metadata/Iam",
"GPBMetadata\\Google\\Rpc\\": "metadata/Rpc",
"GPBMetadata\\Google\\Type\\": "metadata/Type",
"GPBMetadata\\Google\\Cloud\\": "metadata/Cloud",
"GPBMetadata\\Google\\Logging\\": "metadata/Logging"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google API Common Protos for PHP",
"homepage": "https://github.com/googleapis/common-protos-php",
"keywords": [
"google"
],
"support": {
"source": "https://github.com/googleapis/common-protos-php/tree/v4.14.1"
},
"time": "2026-06-17T23:07:32+00:00"
},
{
"name": "google/gax",
"version": "v1.43.1",
"source": {
"type": "git",
"url": "https://github.com/googleapis/gax-php.git",
"reference": "9ee87e3b9fa9f71b13d64544a05b483d4460fa9c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/gax-php/zipball/9ee87e3b9fa9f71b13d64544a05b483d4460fa9c",
"reference": "9ee87e3b9fa9f71b13d64544a05b483d4460fa9c",
"shasum": ""
},
"require": {
"google/auth": "^1.49",
"google/common-protos": "^4.4",
"google/grpc-gcp": "^0.4",
"google/longrunning": "~0.4",
"google/protobuf": "^4.31||^5.34",
"grpc/grpc": "^1.13",
"guzzlehttp/promises": "^2.0",
"guzzlehttp/psr7": "^2.0",
"php": "^8.1",
"ramsey/uuid": "^4.0"
},
"conflict": {
"ext-protobuf": "<4.31.0"
},
"require-dev": {
"google/cloud-tools": "^0.16.1",
"phpspec/prophecy-phpunit": "^2.1",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"psr-4": {
"Google\\ApiCore\\": "src",
"GPBMetadata\\ApiCore\\": "metadata/ApiCore"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "Google API Core for PHP",
"homepage": "https://github.com/googleapis/gax-php",
"keywords": [
"google"
],
"support": {
"issues": "https://github.com/googleapis/gax-php/issues",
"source": "https://github.com/googleapis/gax-php/tree/v1.43.1"
},
"time": "2026-06-09T18:21:06+00:00"
},
{
"name": "google/grpc-gcp",
"version": "0.4.2",
"source": {
"type": "git",
"url": "https://github.com/GoogleCloudPlatform/grpc-gcp-php.git",
"reference": "1049c0c15b6a1789fdeb52af688a94d540932469"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/GoogleCloudPlatform/grpc-gcp-php/zipball/1049c0c15b6a1789fdeb52af688a94d540932469",
"reference": "1049c0c15b6a1789fdeb52af688a94d540932469",
"shasum": ""
},
"require": {
"google/auth": "^1.3",
"google/protobuf": "^v3.25.3||^4.26.1||^5.0",
"grpc/grpc": "^v1.13.0",
"php": "^8.0",
"psr/cache": "^1.0.1||^2.0.0||^3.0.0"
},
"require-dev": {
"google/cloud-spanner": "^1.7",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Grpc\\Gcp\\": "src/"
},
"classmap": [
"src/generated/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "gRPC GCP library for channel management",
"support": {
"issues": "https://github.com/GoogleCloudPlatform/grpc-gcp-php/issues",
"source": "https://github.com/GoogleCloudPlatform/grpc-gcp-php/tree/v0.4.2"
},
"time": "2026-03-12T22:56:09+00:00"
},
{
"name": "google/longrunning",
"version": "0.7.1",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-longrunning.git",
"reference": "cac9bedf199239ae2b1acd4a8e4ea2276bd9f55a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-longrunning/zipball/cac9bedf199239ae2b1acd4a8e4ea2276bd9f55a",
"reference": "cac9bedf199239ae2b1acd4a8e4ea2276bd9f55a",
"shasum": ""
},
"require-dev": {
"google/gax": "^1.38.0",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"component": {
"id": "longrunning",
"path": "LongRunning",
"entry": null,
"target": "googleapis/php-longrunning"
}
},
"autoload": {
"psr-4": {
"Google\\LongRunning\\": "src/LongRunning",
"Google\\ApiCore\\LongRunning\\": "src/ApiCore/LongRunning",
"GPBMetadata\\Google\\Longrunning\\": "metadata/Longrunning"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google LongRunning Client for PHP",
"support": {
"source": "https://github.com/googleapis/php-longrunning/tree/v0.7.1"
},
"time": "2026-03-31T19:52:22+00:00"
},
{
"name": "google/protobuf",
"version": "v5.35.1",
"source": {
"type": "git",
"url": "https://github.com/protocolbuffers/protobuf-php.git",
"reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/55bb4a7d6739b5af0927b96213c1371a3afb7cfb",
"reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb",
"shasum": ""
},
"require": {
"php": ">=8.2.0"
},
"require-dev": {
"phpunit/phpunit": ">=11.5.0 <12.0.0"
},
"suggest": {
"ext-bcmath": "Need to support JSON deserialization"
},
"type": "library",
"autoload": {
"psr-4": {
"Google\\Protobuf\\": "src/Google/Protobuf",
"GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "proto library for PHP",
"homepage": "https://developers.google.com/protocol-buffers/",
"keywords": [
"proto"
],
"support": {
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.35.1"
},
"time": "2026-06-11T21:19:23+00:00"
},
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.4", "version": "v1.1.4",
@@ -873,6 +1300,50 @@
], ],
"time": "2025-12-27T19:43:20+00:00" "time": "2025-12-27T19:43:20+00:00"
}, },
{
"name": "grpc/grpc",
"version": "1.81.0",
"source": {
"type": "git",
"url": "https://github.com/grpc/grpc-php.git",
"reference": "47046d6b2a6cc7e68806a287d6a1fee57e0c40da"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/grpc/grpc-php/zipball/47046d6b2a6cc7e68806a287d6a1fee57e0c40da",
"reference": "47046d6b2a6cc7e68806a287d6a1fee57e0c40da",
"shasum": ""
},
"require": {
"php": ">=7.1.0"
},
"require-dev": {
"google/auth": "^v1.3.0"
},
"suggest": {
"ext-protobuf": "For better performance, install the protobuf C extension.",
"google/protobuf": "To get started using grpc quickly, install the native protobuf library."
},
"type": "library",
"autoload": {
"psr-4": {
"Grpc\\": "src/lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "gRPC library for PHP",
"homepage": "https://grpc.io",
"keywords": [
"rpc"
],
"support": {
"source": "https://github.com/grpc/grpc-php/tree/v1.81.0"
},
"time": "2026-05-20T09:30:50+00:00"
},
{ {
"name": "guzzlehttp/guzzle", "name": "guzzlehttp/guzzle",
"version": "7.11.1", "version": "7.11.1",
@@ -3051,6 +3522,55 @@
], ],
"time": "2025-12-27T19:41:33+00:00" "time": "2025-12-27T19:41:33+00:00"
}, },
{
"name": "psr/cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/cache.git",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for caching libraries",
"keywords": [
"cache",
"psr",
"psr-6"
],
"support": {
"source": "https://github.com/php-fig/cache/tree/3.0.0"
},
"time": "2021-02-03T23:26:27+00:00"
},
{ {
"name": "psr/clock", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",
+8
View File
@@ -38,6 +38,9 @@ return [
'gemini' => [ 'gemini' => [
'key' => env('GEMINI_API_KEY'), 'key' => env('GEMINI_API_KEY'),
'model' => env('GEMINI_MODEL', 'gemini-1.5-flash'), 'model' => env('GEMINI_MODEL', 'gemini-1.5-flash'),
'region' => env('VERTEX_REGION', 'us-central1'),
'project_id' => env('VERTEX_PROJECT_ID'),
'credentials_path' => env('VERTEX_CREDENTIALS_PATH'),
], ],
'ollama' => [ 'ollama' => [
@@ -45,5 +48,10 @@ return [
'model' => env('OLLAMA_MODEL', 'glm-ocr'), 'model' => env('OLLAMA_MODEL', 'glm-ocr'),
], ],
'mistral' => [
'key' => env('MISTRAL_API_KEY'),
'model' => env('MISTRAL_MODEL', 'pixtral-12b-2409'),
],
]; ];
+4 -3
View File
@@ -136,7 +136,7 @@ const handleQuoteOcr = async () => {
let fileToSend = form.quote_file; let fileToSend = form.quote_file;
const isPdf = /\.pdf$/i.test(form.quote_file.name); const isPdf = /\.pdf$/i.test(form.quote_file.name);
if (selectedModel.value === 'ollama' && isPdf) { if ((selectedModel.value === 'ollama' || selectedModel.value === 'mistral') && isPdf) {
fileToSend = await convertPdfBlobToImageFile(form.quote_file); fileToSend = await convertPdfBlobToImageFile(form.quote_file);
} }
@@ -408,11 +408,12 @@ const demandeurs = ['Jérémy', 'Sylvain', 'Kévin'];
/> />
<div v-if="form.quote_file" class="mt-2 flex flex-col gap-2"> <div v-if="form.quote_file" class="mt-2 flex flex-col gap-2">
<!-- Sélecteur de modèle --> <!-- Sélecteur de modèle -->
<div v-if="ollamaAvailable" class="flex items-center gap-2 bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg px-2.5 py-1.5 shadow-sm"> <div class="flex items-center gap-2 bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg px-2.5 py-1.5 shadow-sm">
<span class="text-xxs text-slate-500 dark:text-slate-400">Modèle OCR :</span> <span class="text-xxs text-slate-500 dark:text-slate-400">Modèle OCR :</span>
<select v-model="selectedModel" class="text-xxs font-semibold border-0 p-0 pr-6 focus:ring-0 bg-transparent text-slate-700 dark:text-slate-300"> <select v-model="selectedModel" class="text-xxs font-semibold border-0 p-0 pr-6 focus:ring-0 bg-transparent text-slate-700 dark:text-slate-300">
<option value="gemini">Gemini API</option> <option value="gemini">Gemini API</option>
<option value="ollama">GLM-OCR (Ollama)</option> <option value="mistral">Mistral AI</option>
<option v-if="ollamaAvailable" value="ollama">GLM-OCR (Ollama)</option>
</select> </select>
</div> </div>
<button <button
@@ -61,13 +61,13 @@ const runOcr = async (att) => {
let response; let response;
const isPdf = /\.pdf$/i.test(att.file_name); const isPdf = /\.pdf$/i.test(att.file_name);
if (selectedModel.value === 'ollama' && isPdf) { if ((selectedModel.value === 'ollama' || selectedModel.value === 'mistral') && isPdf) {
const pdfResponse = await axios.get(route('attachments.show', att.id), { responseType: 'blob' }); const pdfResponse = await axios.get(route('attachments.show', att.id), { responseType: 'blob' });
const convertedFile = await convertPdfBlobToImageFile(pdfResponse.data, att.file_name); const convertedFile = await convertPdfBlobToImageFile(pdfResponse.data, att.file_name);
const formData = new FormData(); const formData = new FormData();
formData.append('file', convertedFile); formData.append('file', convertedFile);
formData.append('mode', 'ollama'); formData.append('mode', selectedModel.value);
response = await axios.post(route('attachments.ocr-upload'), formData, { response = await axios.post(route('attachments.ocr-upload'), formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
@@ -195,11 +195,12 @@ const submit = () => {
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<!-- Sélecteur de modèle --> <!-- Sélecteur de modèle -->
<div v-if="ollamaAvailable" class="flex items-center gap-2 bg-white/80 dark:bg-slate-900/80 border border-violet-100 dark:border-violet-900/40 rounded-lg px-2.5 py-1.5 shadow-sm"> <div class="flex items-center gap-2 bg-white/80 dark:bg-slate-900/80 border border-violet-100 dark:border-violet-900/40 rounded-lg px-2.5 py-1.5 shadow-sm">
<span class="text-xs text-slate-500 dark:text-slate-400">Modèle OCR :</span> <span class="text-xs text-slate-500 dark:text-slate-400">Modèle OCR :</span>
<select v-model="selectedModel" class="text-xs font-semibold border-0 p-0 pr-6 focus:ring-0 bg-transparent text-slate-700 dark:text-slate-300"> <select v-model="selectedModel" class="text-xs font-semibold border-0 p-0 pr-6 focus:ring-0 bg-transparent text-slate-700 dark:text-slate-300">
<option value="gemini">Gemini API</option> <option value="gemini">Gemini API</option>
<option value="ollama">GLM-OCR (Ollama)</option> <option value="mistral">Mistral AI</option>
<option v-if="ollamaAvailable" value="ollama">GLM-OCR (Ollama)</option>
</select> </select>
</div> </div>
<button <button
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Tests\Unit;
use Tests\TestCase;
use App\Services\GeminiService;
use Illuminate\Support\Facades\Config;
use ReflectionMethod;
class GeminiServiceTest extends TestCase
{
/**
* Test dynamic detection of Vertex AI vs AI Studio drivers.
*/
public function test_driver_detection()
{
// 1. Test standard AI Studio API Key
Config::set('services.gemini.key', 'AIzaSyFakeKey');
Config::set('services.gemini.credentials_path', null);
$service = new GeminiService();
$method = new ReflectionMethod(GeminiService::class, 'analyzeContent');
$method->setAccessible(true);
try {
$method->invoke($service, 'content', 'image/png', 'test.png');
} catch (\Exception $e) {
// Exception is expected since the key is fake, but it should try Gemini
$this->assertTrue(
str_contains($e->getMessage(), 'Gemini') ||
str_contains($e->getMessage(), 'communication avec l\'API') ||
str_contains($e->getMessage(), 'La clé API Gemini')
);
}
// 2. Test Vertex AI JSON Credentials string
$fakeCreds = json_encode([
'type' => 'service_account',
'project_id' => 'my-vertex-project',
]);
Config::set('services.gemini.key', $fakeCreds);
$serviceVertex = new GeminiService();
try {
$method->invoke($serviceVertex, 'content', 'image/png', 'test.png');
} catch (\Exception $e) {
// Exception expected during auth token exchange, but it must be a Vertex error
$this->assertStringContainsString('Vertex', $e->getMessage());
}
}
/**
* Test MistralService features.
*/
public function test_mistral_service()
{
Config::set('services.mistral.key', 'fake-mistral-key');
$mistral = new \App\Services\MistralService();
$method = new ReflectionMethod(\App\Services\MistralService::class, 'analyzeContent');
$method->setAccessible(true);
// PDF should be rejected
$this->expectException(\Exception::class);
$this->expectExceptionMessage('ne prend en charge que les images');
$method->invoke($mistral, 'content', 'application/pdf', 'test.pdf');
}
}