From e3b1a2583f20402c3ec4781cc8ff374299c84ca9 Mon Sep 17 00:00:00 2001 From: jeremy bayse Date: Wed, 25 Mar 2026 07:29:39 +0100 Subject: [PATCH] AI Analysis: add support for multiple providers (OpenAI, Claude, Gemini) --- app/Http/Controllers/AIAnalysisController.php | 4 +- app/Services/AIAnalysisService.php | 110 ++++++++++++++++-- resources/js/Pages/Admin/Candidates/Show.vue | 71 +++++++---- 3 files changed, 149 insertions(+), 36 deletions(-) diff --git a/app/Http/Controllers/AIAnalysisController.php b/app/Http/Controllers/AIAnalysisController.php index 6596352..7ea4d58 100644 --- a/app/Http/Controllers/AIAnalysisController.php +++ b/app/Http/Controllers/AIAnalysisController.php @@ -15,14 +15,14 @@ class AIAnalysisController extends Controller $this->aiService = $aiService; } - public function analyze(Candidate $candidate) + public function analyze(Request $request, Candidate $candidate) { if (!auth()->user()->isAdmin()) { abort(403); } try { - $analysis = $this->aiService->analyze($candidate); + $analysis = $this->aiService->analyze($candidate, $request->provider); // Persist the analysis on the candidate profile $candidate->update([ diff --git a/app/Services/AIAnalysisService.php b/app/Services/AIAnalysisService.php index db7c5a8..0903764 100644 --- a/app/Services/AIAnalysisService.php +++ b/app/Services/AIAnalysisService.php @@ -21,7 +21,7 @@ class AIAnalysisService /** * Analyze a candidate against their assigned Job Position. */ - public function analyze(Candidate $candidate) + public function analyze(Candidate $candidate, ?string $provider = null) { if (!$candidate->job_position_id) { throw new \Exception("Le candidat n'est associé à aucune fiche de poste."); @@ -36,7 +36,7 @@ class AIAnalysisService throw new \Exception("Impossible d'extraire le texte du CV."); } - return $this->callAI($candidate, $cvText, $letterText); + return $this->callAI($candidate, $cvText, $letterText, $provider); } /** @@ -75,8 +75,10 @@ class AIAnalysisService /** * Call the AI API (using a placeholder for now, or direct Http call). */ - protected function callAI(Candidate $candidate, string $cvText, ?string $letterText) + protected function callAI(Candidate $candidate, string $cvText, ?string $letterText, ?string $provider = null) { + $provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama'); + $jobTitle = $candidate->jobPosition->title; $jobDesc = $candidate->jobPosition->description; $requirements = implode(", ", $candidate->jobPosition->requirements ?? []); @@ -105,10 +107,17 @@ class AIAnalysisService - verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable) Réponds UNIQUEMENT en JSON pur."; - - // For now, I'll use a mocked response or try to use a generic endpoint if configured. - // I'll check if the user has an Ollama endpoint. - + + return match ($provider) { + 'openai' => $this->callOpenAI($prompt), + 'anthropic' => $this->callAnthropic($prompt), + 'gemini' => $this->callGemini($prompt), + default => $this->callOllama($prompt), + }; + } + + protected function callOllama(string $prompt) + { $ollamaUrl = env('OLLAMA_URL', 'http://localhost:11434/api/generate'); $ollamaModel = env('OLLAMA_MODEL', 'mistral'); @@ -123,16 +132,97 @@ class AIAnalysisService if ($response->successful()) { return json_decode($response->json('response'), true); } else { - Log::warning("AI Provider Error: HTTP " . $response->status() . " - " . $response->body()); + Log::warning("AI Provider Error (Ollama): HTTP " . $response->status() . " - " . $response->body()); } } catch (\Exception $e) { Log::error("AI Connection Failed (Ollama): " . $e->getMessage()); } - // Fallback for demo if Ollama is not running + return $this->getSimulatedAnalysis(); + } + + protected function callOpenAI(string $prompt) + { + $apiKey = env('OPENAI_API_KEY'); + if (!$apiKey) return $this->getSimulatedAnalysis(); + + try { + $response = Http::withToken($apiKey)->timeout(60)->post('https://api.openai.com/v1/chat/completions', [ + 'model' => 'gpt-4o', + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'response_format' => ['type' => 'json_object'] + ]); + + if ($response->successful()) { + return json_decode($response->json('choices.0.message.content'), true); + } + } catch (\Exception $e) { + Log::error("OpenAI Analysis Failed: " . $e->getMessage()); + } + + return $this->getSimulatedAnalysis(); + } + + protected function callAnthropic(string $prompt) + { + $apiKey = env('ANTHROPIC_API_KEY'); + if (!$apiKey) return $this->getSimulatedAnalysis(); + + try { + $response = Http::withHeaders([ + 'x-api-key' => $apiKey, + 'anthropic-version' => '2023-06-01', + 'content-type' => 'application/json' + ])->timeout(60)->post('https://api.anthropic.com/v1/messages', [ + 'model' => 'claude-3-5-sonnet-20240620', + 'max_tokens' => 1024, + 'messages' => [['role' => 'user', 'content' => $prompt]] + ]); + + if ($response->successful()) { + $content = $response->json('content.0.text'); + return json_decode($this->extractJson($content), true); + } + } catch (\Exception $e) { + Log::error("Anthropic Analysis Failed: " . $e->getMessage()); + } + + return $this->getSimulatedAnalysis(); + } + + protected function callGemini(string $prompt) + { + $apiKey = env('GEMINI_API_KEY'); + if (!$apiKey) return $this->getSimulatedAnalysis(); + + try { + $response = Http::timeout(60)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=" . $apiKey, [ + 'contents' => [['parts' => [['text' => $prompt]]]], + 'generationConfig' => ['responseMimeType' => 'application/json'] + ]); + + if ($response->successful()) { + $text = $response->json('candidates.0.content.parts.0.text'); + return json_decode($this->extractJson($text), true); + } + } catch (\Exception $e) { + Log::error("Gemini Analysis Failed: " . $e->getMessage()); + } + + return $this->getSimulatedAnalysis(); + } + + private function extractJson($string) + { + preg_match('/\{.*\}/s', $string, $matches); + return $matches[0] ?? '{}'; + } + + private function getSimulatedAnalysis() + { return [ 'match_score' => 75, - 'summary' => "Analyse simulée (IA non connectée). Le candidat semble avoir une solide expérience mais certains points techniques doivent être vérifiés.", + 'summary' => "Analyse simulée (IA non connectée ou erreur API). Le candidat semble avoir une solide expérience mais certains points techniques doivent être vérifiés.", 'strengths' => ["Expérience pertinente", "Bonne présentation"], 'gaps' => ["Compétences spécifiques à confirmer"], 'verdict' => "Favorable" diff --git a/resources/js/Pages/Admin/Candidates/Show.vue b/resources/js/Pages/Admin/Candidates/Show.vue index e285f4f..312d2ad 100644 --- a/resources/js/Pages/Admin/Candidates/Show.vue +++ b/resources/js/Pages/Admin/Candidates/Show.vue @@ -122,6 +122,7 @@ const updateAnswerScore = (answerId, score) => { const aiAnalysis = ref(props.candidate.ai_analysis || null); const isAnalyzing = ref(false); +const selectedProvider = ref('ollama'); const runAI = async () => { if (!props.candidate.job_position_id) { @@ -131,7 +132,14 @@ const runAI = async () => { isAnalyzing.value = true; try { - const response = await fetch(route('admin.candidates.analyze', props.candidate.id)); + const response = await fetch(route('admin.candidates.analyze', props.candidate.id), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, + body: JSON.stringify({ provider: selectedProvider.value }) + }); const data = await response.json(); if (data.error) { alert(data.error); @@ -363,37 +371,52 @@ const runAI = async () => {
-
+

- Analyse d'Adéquation IA + Intelligence Artificielle

-

Matching CV/Lettre vs Fiche de poste

+

Choisir l'IA pour l'analyse du matching

- - - - - - - Analyse en cours... - - - - - - - Lancer l'analyse intelligente - - +
+ +
+ +
+ + + + + + + + Analyse en cours... + + + + + + + Lancer l'analyse intelligente + + +