Files
RecruIT/app/Services/AIAnalysisService.php

300 lines
11 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.
*/
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. Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$jobTitle}'.";
if (!$candidate->jobPosition->ai_prompt) {
$prompt .= " Attache une grande importance aux compétences techniques et à l'expérience, mais aussi à la capacité d'intégration et à la motivation.
DESCRIPTION DU POSTE:
{$jobDesc}
COMPÉTENCES REQUISES:
{$requirements}
CONTENU DU CV:
{$cvText}
CONTENU DE LA LETTRE DE MOTIVATION:
" . ($letterText ?? "Non fournie") . "
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
- 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)";
} else {
// Context injection for the custom prompt
$prompt .= "
CONTEXTE DU POSTE:
{$jobDesc}
COMPÉTENCES REQUISES:
{$requirements}
CONTENU DU CV DU CANDIDAT:
{$cvText}
CONTENU DE LA LETTRE DE MOTIVATION:
" . ($letterText ?? "Non fournie") . "
CONSIGNES D'ANALYSE SPÉCIFIQUES:
" . $candidate->jobPosition->ai_prompt;
}
$prompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide.";
$analysis = match ($provider) {
'openai' => $this->callOpenAI($prompt),
'anthropic' => $this->callAnthropic($prompt),
'gemini' => $this->callGemini($prompt),
default => $this->callOllama($prompt),
};
// Normalize keys for frontend compatibility
$normalized = $this->normalizeAnalysis($analysis);
// Inject metadata
$normalized['provider'] = $provider;
$normalized['analyzed_at'] = now()->toIso8601String();
return $normalized;
}
/**
* Normalize the AI response keys to ensure frontend compatibility.
*/
protected function normalizeAnalysis(array $data): array
{
$normalized = $data;
// Map custom keys to standard keys if they exist
if (isset($data['score_global']) && !isset($data['match_score'])) {
$normalized['match_score'] = $data['score_global'];
}
if (isset($data['recommandation']) && !isset($data['verdict'])) {
$normalized['verdict'] = $data['recommandation'];
}
if (isset($data['synthese']) && !isset($data['summary'])) {
$normalized['summary'] = $data['synthese'];
}
if (isset($data['points_vigilance']) && !isset($data['gaps'])) {
// Handle if points_vigilance is a list of objects (as in user's prompt)
if (is_array($data['points_vigilance']) && isset($data['points_vigilance'][0]) && is_array($data['points_vigilance'][0])) {
$normalized['gaps'] = array_map(fn($i) => ($i['type'] ?? '') . ': ' . ($i['description'] ?? ''), $data['points_vigilance']);
} else {
$normalized['gaps'] = $data['points_vigilance'];
}
}
// Ensure default keys exist even if empty
$normalized['match_score'] = $normalized['match_score'] ?? 0;
$normalized['summary'] = $normalized['summary'] ?? "Pas de résumé généré.";
$normalized['verdict'] = $normalized['verdict'] ?? "Indéterminé";
$normalized['strengths'] = $normalized['strengths'] ?? [];
$normalized['gaps'] = $normalized['gaps'] ?? [];
return $normalized;
}
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' => 2048,
'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-2.5-flash:generateContent?key=" . $apiKey, [
'contents' => [['parts' => [['text' => $prompt]]]]
]);
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 peut avoir un profil intéressant mais une vérification manuelle est nécessaire.",
'strengths' => ["Expérience pertinente", "Bonne présentation"],
'gaps' => ["Compétences spécifiques à confirmer"],
'verdict' => "Favorable"
];
}
}