AI Analysis: add support for multiple providers (OpenAI, Claude, Gemini)

This commit is contained in:
jeremy bayse
2026-03-25 07:29:39 +01:00
parent e02c6849fe
commit e3b1a2583f
3 changed files with 149 additions and 36 deletions

View File

@@ -15,14 +15,14 @@ class AIAnalysisController extends Controller
$this->aiService = $aiService; $this->aiService = $aiService;
} }
public function analyze(Candidate $candidate) public function analyze(Request $request, Candidate $candidate)
{ {
if (!auth()->user()->isAdmin()) { if (!auth()->user()->isAdmin()) {
abort(403); abort(403);
} }
try { try {
$analysis = $this->aiService->analyze($candidate); $analysis = $this->aiService->analyze($candidate, $request->provider);
// Persist the analysis on the candidate profile // Persist the analysis on the candidate profile
$candidate->update([ $candidate->update([

View File

@@ -21,7 +21,7 @@ class AIAnalysisService
/** /**
* Analyze a candidate against their assigned Job Position. * 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) { if (!$candidate->job_position_id) {
throw new \Exception("Le candidat n'est associé à aucune fiche de poste."); 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."); 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). * 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; $jobTitle = $candidate->jobPosition->title;
$jobDesc = $candidate->jobPosition->description; $jobDesc = $candidate->jobPosition->description;
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []); $requirements = implode(", ", $candidate->jobPosition->requirements ?? []);
@@ -106,9 +108,16 @@ class AIAnalysisService
Réponds UNIQUEMENT en JSON pur."; Réponds UNIQUEMENT en JSON pur.";
// For now, I'll use a mocked response or try to use a generic endpoint if configured. return match ($provider) {
// I'll check if the user has an Ollama endpoint. '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'); $ollamaUrl = env('OLLAMA_URL', 'http://localhost:11434/api/generate');
$ollamaModel = env('OLLAMA_MODEL', 'mistral'); $ollamaModel = env('OLLAMA_MODEL', 'mistral');
@@ -123,16 +132,97 @@ class AIAnalysisService
if ($response->successful()) { if ($response->successful()) {
return json_decode($response->json('response'), true); return json_decode($response->json('response'), true);
} else { } 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) { } catch (\Exception $e) {
Log::error("AI Connection Failed (Ollama): " . $e->getMessage()); 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 [ return [
'match_score' => 75, '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"], 'strengths' => ["Expérience pertinente", "Bonne présentation"],
'gaps' => ["Compétences spécifiques à confirmer"], 'gaps' => ["Compétences spécifiques à confirmer"],
'verdict' => "Favorable" 'verdict' => "Favorable"

View File

@@ -122,6 +122,7 @@ const updateAnswerScore = (answerId, score) => {
const aiAnalysis = ref(props.candidate.ai_analysis || null); const aiAnalysis = ref(props.candidate.ai_analysis || null);
const isAnalyzing = ref(false); const isAnalyzing = ref(false);
const selectedProvider = ref('ollama');
const runAI = async () => { const runAI = async () => {
if (!props.candidate.job_position_id) { if (!props.candidate.job_position_id) {
@@ -131,7 +132,14 @@ const runAI = async () => {
isAnalyzing.value = true; isAnalyzing.value = true;
try { 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(); const data = await response.json();
if (data.error) { if (data.error) {
alert(data.error); alert(data.error);
@@ -363,15 +371,29 @@ const runAI = async () => {
<!-- AI Analysis Section --> <!-- AI Analysis Section -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8 overflow-hidden relative"> <div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8 overflow-hidden relative">
<div class="flex items-center justify-between mb-8"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div> <div>
<h4 class="text-xl font-bold flex items-center gap-2"> <h4 class="text-xl font-bold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
Analyse d'Adéquation IA Intelligence Artificielle
</h4> </h4>
<p class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">Matching CV/Lettre vs Fiche de poste</p> <p class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">Choisir l'IA pour l'analyse du matching</p>
</div>
<div class="flex flex-wrap items-center gap-4">
<!-- Provider Selector -->
<div class="flex items-center bg-slate-100 dark:bg-slate-900/50 p-1.5 rounded-2xl border border-slate-200 dark:border-slate-800">
<button
v-for="provider in ['ollama', 'openai', 'anthropic', 'gemini']"
:key="provider"
@click="selectedProvider = provider"
class="px-4 py-2 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
:class="selectedProvider === provider ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'"
>
{{ provider }}
</button>
</div> </div>
<PrimaryButton <PrimaryButton
@@ -395,6 +417,7 @@ const runAI = async () => {
</span> </span>
</PrimaryButton> </PrimaryButton>
</div> </div>
</div>
<!-- AI Results --> <!-- AI Results -->
<div v-if="aiAnalysis" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700"> <div v-if="aiAnalysis" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">