212 lines
8.8 KiB
PHP
212 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Attachment;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Google\Auth\Credentials\ServiceAccountCredentials;
|
|
|
|
class GeminiService
|
|
{
|
|
protected ?string $apiKey;
|
|
protected string $model;
|
|
protected string $region;
|
|
protected ?string $projectId;
|
|
protected ?string $credentialsPath;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->apiKey = config('services.gemini.key');
|
|
$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');
|
|
}
|
|
|
|
/**
|
|
* Analyse un document joint via l'API Gemini 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 Gemini.
|
|
*/
|
|
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 Gemini pour extraire les données du devis.
|
|
*/
|
|
protected function analyzeContent(string $fileContent, string $mimeType, string $fileName): array
|
|
{
|
|
// Encodage Base64 pour l'API Gemini
|
|
$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 informatiques et de prestations.
|
|
Analyse le document joint (devis) et extrais de façon très précise les informations suivantes 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.";
|
|
|
|
// Détection automatique de Vertex AI vs Gemini (Google AI Studio)
|
|
$isVertex = false;
|
|
$credentials = null;
|
|
|
|
$resolvedPath = $this->credentialsPath;
|
|
if ($resolvedPath && !str_starts_with($resolvedPath, '{')) {
|
|
if (!file_exists($resolvedPath) && file_exists(base_path($resolvedPath))) {
|
|
$resolvedPath = base_path($resolvedPath);
|
|
}
|
|
}
|
|
|
|
if ($resolvedPath && (file_exists($resolvedPath) || str_starts_with($resolvedPath, '{'))) {
|
|
$isVertex = true;
|
|
$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' => [
|
|
[
|
|
'role' => 'user',
|
|
'parts' => [
|
|
['text' => $prompt],
|
|
[
|
|
'inlineData' => [
|
|
'mimeType' => $mimeType,
|
|
'data' => $base64Data
|
|
]
|
|
]
|
|
]
|
|
]
|
|
],
|
|
'generationConfig' => [
|
|
'responseMimeType' => 'application/json',
|
|
]
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
Log::error("Échec de l'appel API : " . $response->body());
|
|
throw new \Exception("Erreur de communication avec l'API Google : " . $response->status());
|
|
}
|
|
|
|
$data = $response->json();
|
|
$textResult = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
|
|
|
if (!$textResult) {
|
|
Log::error("Google API 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 l'API : " . $textResult);
|
|
throw new \Exception("Le format des données retournées par l'OCR est invalide.");
|
|
}
|
|
|
|
Log::info("OCR Google réussi pour le fichier : {$fileName}");
|
|
|
|
return $decoded;
|
|
}
|
|
}
|