236 lines
8.3 KiB
PHP
236 lines
8.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Candidate;
|
|
use App\Models\Document;
|
|
use Smalot\PdfParser\Parser;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class AIAnalysisService
|
|
{
|
|
protected $parser;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->parser = new Parser();
|
|
}
|
|
|
|
/**
|
|
* Analyze a candidate against their assigned Job Position.
|
|
*/
|
|
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.");
|
|
}
|
|
|
|
$candidate->load(['documents', 'jobPosition']);
|
|
|
|
$cvText = $this->extractTextFromDocument($candidate->documents->where('type', 'cv')->first());
|
|
$letterText = $this->extractTextFromDocument($candidate->documents->where('type', 'cover_letter')->first());
|
|
|
|
if (!$cvText) {
|
|
throw new \Exception("Impossible d'extraire le texte du CV.");
|
|
}
|
|
|
|
return $this->callAI($candidate, $cvText, $letterText, $provider);
|
|
}
|
|
|
|
/**
|
|
* Extract text from a PDF document.
|
|
*/
|
|
protected function extractTextFromDocument(?Document $document): ?string
|
|
{
|
|
if (!$document || !Storage::disk('local')->exists($document->file_path)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$pdf = $this->parser->parseFile(Storage::disk('local')->path($document->file_path));
|
|
$text = $pdf->getText();
|
|
return $this->cleanText($text);
|
|
} catch (\Exception $e) {
|
|
Log::error("PDF Extraction Error: " . $e->getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean text to ensure it's valid UTF-8 and fits well in JSON.
|
|
*/
|
|
protected function cleanText(string $text): string
|
|
{
|
|
// Remove non-UTF8 characters
|
|
$text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
|
|
|
|
// Remove control characters (except newlines and tabs)
|
|
$text = preg_replace('/[^\x20-\x7E\xA0-\xFF\x0A\x0D\x09]/u', '', $text);
|
|
|
|
return trim($text);
|
|
}
|
|
|
|
/**
|
|
* Call the AI API (using a placeholder for now, or direct Http call).
|
|
*/
|
|
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 ?? []);
|
|
|
|
$prompt = "Tu es un expert en recrutement technique. Analyse le CV (et la lettre de motivation si présente) d'un candidat pour le poste de '{$jobTitle}' attache une grande importance aux compétences techniques et à l'expérience du candidat, mais aussi à sa capacité à s'intégrer dans une équipe et à sa motivation.
|
|
|
|
DESCRIPTION DU POSTE:
|
|
{$jobDesc}
|
|
|
|
COMPÉTENCES REQUISES:
|
|
{$requirements}
|
|
|
|
CONTENU DU CV:
|
|
{$cvText}
|
|
CONTENU DE LA LETTRE DE MOTIVATION:
|
|
" . ($letterText ?? "Non fournie") . "
|
|
|
|
CONTEXTE ADDITIONNEL & INSTRUCTIONS PARTICULIÈRES:
|
|
" . ($candidate->jobPosition->ai_prompt ?? "Aucune instruction spécifique.") . "
|
|
|
|
Fournis une analyse structurée en JSON avec les clés suivantes:
|
|
- match_score: note de 0 à 100
|
|
- summary: résumé de 3-4 phrases sur le profil et la ville d'origine du candidat
|
|
- strengths: liste des points forts par rapport au poste
|
|
- gaps: liste des compétences manquantes ou points de vigilance
|
|
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)
|
|
|
|
Réponds UNIQUEMENT en JSON pur.";
|
|
|
|
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');
|
|
|
|
try {
|
|
$response = Http::timeout(120)->post($ollamaUrl, [
|
|
'model' => $ollamaModel,
|
|
'prompt' => $prompt,
|
|
'stream' => false,
|
|
'format' => 'json'
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
return json_decode($response->json('response'), true);
|
|
} else {
|
|
Log::warning("AI Provider Error (Ollama): HTTP " . $response->status() . " - " . $response->body());
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error("AI Connection Failed (Ollama): " . $e->getMessage());
|
|
}
|
|
|
|
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/v1/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);
|
|
} else {
|
|
Log::error("Gemini API Error: " . $response->status() . " - " . $response->body());
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error("Gemini Connection 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 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"
|
|
];
|
|
}
|
|
}
|