AI Analysis: Service and UI implementation

This commit is contained in:
jeremy bayse
2026-03-22 22:25:43 +01:00
parent 878f4bb102
commit 6c1f6af523
4 changed files with 278 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Models\Candidate;
use App\Services\AIAnalysisService;
use Illuminate\Http\Request;
class AIAnalysisController extends Controller
{
protected $aiService;
public function __construct(AIAnalysisService $aiService)
{
$this->aiService = $aiService;
}
public function analyze(Candidate $candidate)
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
try {
$analysis = $this->aiService->analyze($candidate);
return response()->json($analysis);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
}

View File

@@ -0,0 +1,121 @@
<?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)
{
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);
}
/**
* 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));
return $pdf->getText();
} catch (\Exception $e) {
Log::error("PDF Extraction Error: " . $e->getMessage());
return null;
}
}
/**
* Call the AI API (using a placeholder for now, or direct Http call).
*/
protected function callAI(Candidate $candidate, string $cvText, ?string $letterText)
{
$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}'.
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)
Réponds UNIQUEMENT en JSON pur.";
// For now, I'll use a mocked response or try to use a generic endpoint if configured.
// I'll check if the user has an Ollama endpoint.
$ollamaUrl = config('services.ollama.url', 'http://localhost:11434/api/generate');
try {
$response = Http::timeout(60)->post($ollamaUrl, [
'model' => 'mistral', // or llama3
'prompt' => $prompt,
'stream' => false,
'format' => 'json'
]);
if ($response->successful()) {
return json_decode($response->json('response'), true);
}
} catch (\Exception $e) {
Log::error("AI Analysis Call Failed: " . $e->getMessage());
}
// Fallback for demo if Ollama is not running
return [
'match_score' => 75,
'summary' => "Analyse simulée (IA non connectée). 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"
];
}
}

View File

@@ -119,6 +119,31 @@ const updateAnswerScore = (answerId, score) => {
preserveScroll: true,
});
};
const aiAnalysis = ref(null);
const isAnalyzing = ref(false);
const runAI = async () => {
if (!props.candidate.job_position_id) {
alert("Veuillez d'abord associer une fiche de poste à ce candidat.");
return;
}
isAnalyzing.value = true;
try {
const response = await fetch(route('admin.candidates.analyze', props.candidate.id));
const data = await response.json();
if (data.error) {
alert(data.error);
} else {
aiAnalysis.value = data;
}
} catch (error) {
alert("Une erreur est survenue lors de l'analyse.");
} finally {
isAnalyzing.value = false;
}
};
</script>
<template>
@@ -415,6 +440,106 @@ const updateAnswerScore = (answerId, score) => {
</div>
</div>
<!-- AI Analysis Section -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8 overflow-hidden relative">
<div class="flex items-center justify-between mb-8">
<div>
<h4 class="text-xl font-bold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Analyse d'Adéquation IA
</h4>
<p class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">Matching CV/Lettre vs Fiche de poste</p>
</div>
<PrimaryButton
@click="runAI"
:disabled="isAnalyzing"
class="!bg-indigo-600 hover:!bg-indigo-500 !border-none !rounded-xl group"
>
<span v-if="isAnalyzing" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Analyse en cours...
</span>
<span v-else class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Lancer l'analyse intelligente
</span>
</PrimaryButton>
</div>
<!-- AI Results -->
<div v-if="aiAnalysis" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800 text-center flex flex-col items-center justify-center">
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Score d'Adéquation</div>
<div class="text-5xl font-black text-indigo-600 mb-2">{{ aiAnalysis.match_score }}%</div>
<div
class="px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest"
:class="[
aiAnalysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
aiAnalysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
]"
>
{{ aiAnalysis.verdict }}
</div>
</div>
<div class="md:col-span-2 bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800">
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Synthèse du Profil</div>
<p class="text-sm leading-relaxed text-slate-600 dark:text-slate-400 italic">
" {{ aiAnalysis.summary }} "
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-4">
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Points Forts Identifiés
</h5>
<ul class="space-y-3">
<li v-for="(strength, idx) in aiAnalysis.strengths" :key="idx" class="flex items-start gap-3 p-3 bg-emerald-50/50 dark:bg-emerald-900/10 rounded-2xl border border-emerald-100 dark:border-emerald-800/50 text-[13px]">
<span class="text-emerald-500 font-bold mt-0.5"></span>
<span class="text-emerald-800 dark:text-emerald-400">{{ strength }}</span>
</li>
</ul>
</div>
<div class="space-y-4">
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-amber-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Points de Vigilance / Gaps
</h5>
<ul class="space-y-3">
<li v-for="(gap, idx) in aiAnalysis.gaps" :key="idx" class="flex items-start gap-3 p-3 bg-amber-50/50 dark:bg-amber-900/10 rounded-2xl border border-amber-100 dark:border-amber-800/50 text-[13px]">
<span class="text-amber-500 font-bold mt-0.5"></span>
<span class="text-amber-800 dark:text-amber-400">{{ gap }}</span>
</li>
</ul>
</div>
</div>
</div>
<div v-else-if="!isAnalyzing" class="py-12 border-2 border-dashed border-slate-100 dark:border-slate-800 rounded-3xl text-center">
<p class="text-slate-400 text-sm font-medium">Aucune analyse effectuée pour le moment.</p>
</div>
<div v-if="isAnalyzing" class="absolute inset-0 bg-white/60 dark:bg-slate-800/60 backdrop-blur-[2px] z-10 flex flex-col items-center justify-center gap-4">
<div class="flex gap-1 animate-pulse"><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div></div>
<p class="text-sm font-black text-indigo-600 uppercase tracking-widest">Analyse en cours...</p>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
<h3 class="text-xl font-bold mb-8 flex items-center justify-between">
Historique des Tests

View File

@@ -75,6 +75,7 @@ Route::middleware('auth')->group(function () {
Route::patch('/candidates/{candidate}/notes', [\App\Http\Controllers\CandidateController::class, 'updateNotes'])->name('candidates.update-notes');
Route::patch('/candidates/{candidate}/scores', [\App\Http\Controllers\CandidateController::class, 'updateScores'])->name('candidates.update-scores');
Route::patch('/candidates/{candidate}/position', [\App\Http\Controllers\CandidateController::class, 'updatePosition'])->name('candidates.update-position');
Route::get('/candidates/{candidate}/analyze', [\App\Http\Controllers\AIAnalysisController::class, 'analyze'])->name('candidates.analyze');
Route::post('/candidates/{candidate}/reset-password', [\App\Http\Controllers\CandidateController::class, 'resetPassword'])->name('candidates.reset-password');
Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show');