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]]]] ]); 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" ]; } }