From 4017e3d9c5d9e00649d5329f22251e4b9964908e Mon Sep 17 00:00:00 2001 From: jeremy bayse Date: Sun, 19 Apr 2026 08:28:28 +0200 Subject: [PATCH] feat(ai): optimize candidate analysis and implement batch processing --- app/Services/AIAnalysisService.php | 119 +++++++--- resources/js/Pages/Admin/Candidates/Index.vue | 104 ++++++++- resources/js/Pages/Admin/Candidates/Show.vue | 213 ++++++++++++------ 3 files changed, 334 insertions(+), 102 deletions(-) diff --git a/app/Services/AIAnalysisService.php b/app/Services/AIAnalysisService.php index 682ead0..5940a4f 100644 --- a/app/Services/AIAnalysisService.php +++ b/app/Services/AIAnalysisService.php @@ -73,7 +73,7 @@ class AIAnalysisService } /** - * Call the AI API (using a placeholder for now, or direct Http call). + * Call the AI API. */ protected function callAI(Candidate $candidate, string $cvText, ?string $letterText, ?string $provider = null) { @@ -83,30 +83,49 @@ class AIAnalysisService $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. + $prompt = "Tu es un expert en recrutement technique. Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$jobTitle}'."; - DESCRIPTION DU POSTE: - {$jobDesc} + 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; + } - 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."; + $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), @@ -115,11 +134,53 @@ class AIAnalysisService default => $this->callOllama($prompt), }; - // Inject metadata for display and tracking - $analysis['provider'] = $provider; - $analysis['analyzed_at'] = now()->toIso8601String(); + // Normalize keys for frontend compatibility + $normalized = $this->normalizeAnalysis($analysis); - return $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) @@ -181,7 +242,7 @@ class AIAnalysisService 'content-type' => 'application/json' ])->timeout(60)->post('https://api.anthropic.com/v1/messages', [ 'model' => 'claude-3-5-sonnet-20240620', - 'max_tokens' => 1024, + 'max_tokens' => 2048, 'messages' => [['role' => 'user', 'content' => $prompt]] ]); @@ -229,7 +290,7 @@ class AIAnalysisService { 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.", + '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" diff --git a/resources/js/Pages/Admin/Candidates/Index.vue b/resources/js/Pages/Admin/Candidates/Index.vue index 5ab6e3c..f7b874c 100644 --- a/resources/js/Pages/Admin/Candidates/Index.vue +++ b/resources/js/Pages/Admin/Candidates/Index.vue @@ -2,6 +2,7 @@ import AdminLayout from '@/Layouts/AdminLayout.vue'; import { Head, useForm, Link, usePage, router } from '@inertiajs/vue3'; import { ref, computed } from 'vue'; +import axios from 'axios'; const page = usePage(); const flashSuccess = computed(() => page.props.flash?.success); @@ -56,8 +57,8 @@ const openPreview = (doc) => { }; // Sorting Logic -const sortKey = ref('user.name'); -const sortOrder = ref(1); // 1 = asc, -1 = desc +const sortKey = ref('ai_analysis.match_score'); +const sortOrder = ref(-1); // 1 = asc, -1 = desc const sortBy = (key) => { if (sortKey.value === key) { @@ -106,6 +107,61 @@ const sortedCandidates = computed(() => { return 0; }); }); + +const selectedIds = ref([]); +const isBatchAnalyzing = ref(false); +const analysisProgress = ref({ current: 0, total: 0 }); + +const toggleSelectAll = (e) => { + if (e.target.checked) { + selectedIds.value = sortedCandidates.value.map(c => c.id); + } else { + selectedIds.value = []; + } +}; + +const batchAnalyze = async () => { + if (selectedIds.value.length === 0) return; + + if (!confirm(`Voulez-vous lancer l'analyse IA pour les ${selectedIds.value.length} candidats sélectionnés ?`)) { + return; + } + + isBatchAnalyzing.value = true; + analysisProgress.value = { current: 0, total: selectedIds.value.length }; + + const results = { success: 0, errors: 0, details: [] }; + + // Copy the IDs to avoid issues if selection changes during process + const idsToProcess = [...selectedIds.value]; + + for (const id of idsToProcess) { + analysisProgress.value.current++; + try { + await axios.post(route('admin.candidates.analyze', id)); + results.success++; + } catch (error) { + results.errors++; + const candidate = props.candidates.find(c => c.id === id); + results.details.push({ + candidate: candidate?.user?.name || `ID #${id}`, + error: error.response?.data?.error || error.message + }); + } + } + + // Finished processing all + router.reload({ + onSuccess: () => { + isBatchAnalyzing.value = false; + selectedIds.value = []; + alert(`Analyse terminée : ${results.success} succès, ${results.errors} erreurs.`); + if (results.details.length > 0) { + console.table(results.details); + } + } + }); +};