feat: implementation des dossiers candidats PDF, gestion des entretiens et optimisation de l'analyse IA

This commit is contained in:
jeremy bayse
2026-04-19 15:35:16 +02:00
parent 4017e3d9c5
commit f3d630d741
27 changed files with 2550 additions and 741 deletions

View File

@@ -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);