feat: implementation des dossiers candidats PDF, gestion des entretiens et optimisation de l'analyse IA
This commit is contained in:
@@ -79,14 +79,16 @@ class AIAnalysisService
|
||||
{
|
||||
$provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama');
|
||||
|
||||
$jobTitle = $candidate->jobPosition->title;
|
||||
$jobDesc = $candidate->jobPosition->description;
|
||||
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []);
|
||||
$job = $candidate->jobPosition;
|
||||
$jobTitle = $job->title;
|
||||
$jobDesc = $job->description;
|
||||
$requirements = implode(", ", $job->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}'.";
|
||||
// Static Part: The job context and instructions (Cacheable)
|
||||
$staticPrompt = "Tu es un expert en recrutement technique spécialisé dans l'infrastructure et la cybersécurité. 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.
|
||||
if (!$job->ai_prompt) {
|
||||
$staticPrompt .= " 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}
|
||||
@@ -94,11 +96,6 @@ class AIAnalysisService
|
||||
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
|
||||
@@ -106,8 +103,7 @@ class AIAnalysisService
|
||||
- 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 .= "
|
||||
$staticPrompt .= "
|
||||
|
||||
CONTEXTE DU POSTE:
|
||||
{$jobDesc}
|
||||
@@ -115,23 +111,23 @@ class AIAnalysisService
|
||||
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;
|
||||
" . $job->ai_prompt;
|
||||
}
|
||||
|
||||
$prompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide.";
|
||||
$staticPrompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide.";
|
||||
|
||||
// Dynamic Part: The candidate data (Not cached)
|
||||
$dynamicPrompt = "CONTENU DU CV DU CANDIDAT:\n{$cvText}\n\nCONTENU DE LA LETTRE DE MOTIVATION:\n" . ($letterText ?? "Non fournie");
|
||||
|
||||
// Full prompt for providers not using context caching
|
||||
$fullPrompt = $staticPrompt . "\n\n" . $dynamicPrompt;
|
||||
|
||||
$analysis = match ($provider) {
|
||||
'openai' => $this->callOpenAI($prompt),
|
||||
'anthropic' => $this->callAnthropic($prompt),
|
||||
'gemini' => $this->callGemini($prompt),
|
||||
default => $this->callOllama($prompt),
|
||||
'openai' => $this->callOpenAI($fullPrompt),
|
||||
'anthropic' => $this->callAnthropic($fullPrompt),
|
||||
'gemini' => $this->callGemini($dynamicPrompt, $staticPrompt, $job),
|
||||
default => $this->callOllama($fullPrompt),
|
||||
};
|
||||
|
||||
// Normalize keys for frontend compatibility
|
||||
@@ -257,29 +253,122 @@ class AIAnalysisService
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
protected function callGemini(string $prompt)
|
||||
protected function callGemini(string $dynamicPrompt, ?string $staticPrompt = null, ?\App\Models\JobPosition $job = null)
|
||||
{
|
||||
$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]]]]
|
||||
]);
|
||||
// Models to try in order (Updated for 2026 models)
|
||||
$models = [
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-1.5-flash-latest'
|
||||
];
|
||||
|
||||
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());
|
||||
foreach ($models as $model) {
|
||||
try {
|
||||
$version = (str_contains($model, '2.0') || str_contains($model, '3.')) ? 'v1beta' : 'v1';
|
||||
$url = "https://generativelanguage.googleapis.com/{$version}/models/{$model}:generateContent?key=" . $apiKey;
|
||||
|
||||
$generationConfig = [
|
||||
'temperature' => 0.2,
|
||||
'responseMimeType' => 'application/json'
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'generationConfig' => $generationConfig,
|
||||
'contents' => [
|
||||
['role' => 'user', 'parts' => [['text' => $dynamicPrompt]]]
|
||||
]
|
||||
];
|
||||
|
||||
// Attempt to use Context Caching if static prompt and job are provided
|
||||
if ($staticPrompt && $job && $version === 'v1beta') {
|
||||
$cacheId = $this->getOrCreateContextCache($job, $staticPrompt, $model);
|
||||
if ($cacheId) {
|
||||
$payload['cachedContent'] = $cacheId;
|
||||
// When using cache, the static part is already in the cache
|
||||
} else {
|
||||
// Fallback: prepend static part if cache fails
|
||||
$payload['contents'][0]['parts'][0]['text'] = $staticPrompt . "\n\n" . $dynamicPrompt;
|
||||
}
|
||||
} else if ($staticPrompt) {
|
||||
// Non-cached fallback
|
||||
$payload['contents'][0]['parts'][0]['text'] = $staticPrompt . "\n\n" . $dynamicPrompt;
|
||||
}
|
||||
|
||||
$response = Http::timeout(60)->post($url, $payload);
|
||||
|
||||
if ($response->successful()) {
|
||||
$candidate = $response->json('candidates.0');
|
||||
if (isset($candidate['finishReason']) && $candidate['finishReason'] !== 'STOP') {
|
||||
Log::warning("Gemini warning: Analysis finished with reason " . $candidate['finishReason']);
|
||||
}
|
||||
|
||||
$text = $candidate['content']['parts'][0]['text'] ?? null;
|
||||
if ($text) {
|
||||
$json = $this->extractJson($text);
|
||||
$decoded = json_decode($json, true);
|
||||
if ($decoded) return $decoded;
|
||||
}
|
||||
} else {
|
||||
Log::error("Gemini API Error ($model): " . $response->status() . " - " . $response->body());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Connection Failed ($model): " . $e->getMessage());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Connection Failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a Gemini Context Cache for a specific Job Position.
|
||||
*/
|
||||
protected function getOrCreateContextCache(\App\Models\JobPosition $job, string $staticPrompt, string $model)
|
||||
{
|
||||
|
||||
if (strlen($staticPrompt) < 120000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we already have a valid cache for this job
|
||||
if ($job->gemini_cache_id && $job->gemini_cache_expires_at && $job->gemini_cache_expires_at->isFuture()) {
|
||||
// Basic verification: the cache is tied to a specific model
|
||||
// We assume the stored cache is for the primary model
|
||||
return $job->gemini_cache_id;
|
||||
}
|
||||
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
try {
|
||||
// Create Context Cache (TTL of 1 hour)
|
||||
$response = Http::timeout(30)->post("https://generativelanguage.googleapis.com/v1beta/cachedContents?key=" . $apiKey, [
|
||||
'model' => "models/{$model}",
|
||||
'contents' => [
|
||||
['role' => 'user', 'parts' => [['text' => $staticPrompt]]]
|
||||
],
|
||||
'ttl' => '3600s'
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$cacheId = $response->json('name');
|
||||
$job->update([
|
||||
'gemini_cache_id' => $cacheId,
|
||||
'gemini_cache_expires_at' => now()->addHour()
|
||||
]);
|
||||
return $cacheId;
|
||||
}
|
||||
|
||||
// Log l'erreur pour comprendre pourquoi le cache a été refusé
|
||||
Log::warning("Gemini Cache Refused: " . $response->body());
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Cache Lifecycle Error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractJson($string)
|
||||
{
|
||||
preg_match('/\{.*\}/s', $string, $matches);
|
||||
|
||||
Reference in New Issue
Block a user