Compare commits
15 Commits
feature/ai
...
7d94be7a8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d94be7a8c | ||
|
|
03006051a9 | ||
|
|
837bf367e9 | ||
|
|
7a05b7e6b3 | ||
|
|
40c8aa2e5a | ||
|
|
30918870a2 | ||
|
|
5a8d9b494b | ||
|
|
0a82bf5017 | ||
|
|
bee5215a5b | ||
|
|
78245f2bee | ||
|
|
cd70edb483 | ||
|
|
4660c94869 | ||
|
|
e3b1a2583f | ||
|
|
e02c6849fe | ||
|
|
10b866fc47 |
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Candidate;
|
use App\Models\Candidate;
|
||||||
use App\Services\AIAnalysisService;
|
use App\Services\AIAnalysisService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
class AIAnalysisController extends Controller
|
class AIAnalysisController extends Controller
|
||||||
{
|
{
|
||||||
@@ -15,14 +16,24 @@ class AIAnalysisController extends Controller
|
|||||||
$this->aiService = $aiService;
|
$this->aiService = $aiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function analyze(Candidate $candidate)
|
public function analyze(Request $request, Candidate $candidate)
|
||||||
{
|
{
|
||||||
if (!auth()->user()->isAdmin()) {
|
if (!auth()->user()->isAdmin()) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restriction: Une analyse tous les 7 jours maximum par candidat
|
||||||
|
if ($candidate->ai_analysis && isset($candidate->ai_analysis['analyzed_at'])) {
|
||||||
|
$lastAnalysis = Carbon::parse($candidate->ai_analysis['analyzed_at']);
|
||||||
|
if ($lastAnalysis->diffInDays(now()) < 7) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => "Une analyse a déjà été effectuée il y a moins de 7 jours. Merci de patienter avant de relancer l'IA."
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$analysis = $this->aiService->analyze($candidate);
|
$analysis = $this->aiService->analyze($candidate, $request->provider);
|
||||||
|
|
||||||
// Persist the analysis on the candidate profile
|
// Persist the analysis on the candidate profile
|
||||||
$candidate->update([
|
$candidate->update([
|
||||||
|
|||||||
60
app/Http/Controllers/BackupController.php
Normal file
60
app/Http/Controllers/BackupController.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class BackupController extends Controller
|
||||||
|
{
|
||||||
|
public function download()
|
||||||
|
{
|
||||||
|
$databaseName = env('DB_DATABASE');
|
||||||
|
$userName = env('DB_USERNAME');
|
||||||
|
$password = env('DB_PASSWORD');
|
||||||
|
$host = env('DB_HOST', '127.0.0.1');
|
||||||
|
|
||||||
|
$backupFileName = 'backup_quizzcabm_' . date('Y-m-d_H-i-s') . '.zip';
|
||||||
|
$backupFilePath = storage_path('app/' . $backupFileName);
|
||||||
|
|
||||||
|
$sqlDumpFilePath = storage_path('app/dump.sql');
|
||||||
|
|
||||||
|
// Execute mysqldump
|
||||||
|
$command = "mysqldump --user={$userName} --password={$password} --host={$host} {$databaseName} > " . escapeshellarg($sqlDumpFilePath);
|
||||||
|
if (empty($password)) {
|
||||||
|
$command = "mysqldump --user={$userName} --host={$host} {$databaseName} > " . escapeshellarg($sqlDumpFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
exec($command);
|
||||||
|
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
if ($zip->open($backupFilePath, ZipArchive::CREATE) === TRUE) {
|
||||||
|
// Add DB dump
|
||||||
|
if (file_exists($sqlDumpFilePath)) {
|
||||||
|
$zip->addFile($sqlDumpFilePath, 'database.sql');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add documents specifically searching the local disk
|
||||||
|
$allFiles = Storage::disk('local')->allFiles();
|
||||||
|
foreach ($allFiles as $filePath) {
|
||||||
|
$fullPath = Storage::disk('local')->path($filePath);
|
||||||
|
// The zip structure will have a folder 'storage_files' containing the relative path
|
||||||
|
$zip->addFile($fullPath, 'storage_files/' . $filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_exists($sqlDumpFilePath)) {
|
||||||
|
unlink($sqlDumpFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_exists($backupFilePath)) {
|
||||||
|
return response()->download($backupFilePath)->deleteFileAfterSend(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('error', 'Erreur lors de la création de la sauvegarde.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,7 +81,16 @@ class CandidateController extends Controller
|
|||||||
|
|
||||||
return \Inertia\Inertia::render('Admin/Candidates/Show', [
|
return \Inertia\Inertia::render('Admin/Candidates/Show', [
|
||||||
'candidate' => $candidate,
|
'candidate' => $candidate,
|
||||||
'jobPositions' => \App\Models\JobPosition::all()
|
'jobPositions' => \App\Models\JobPosition::all(),
|
||||||
|
'ai_config' => [
|
||||||
|
'default' => env('AI_DEFAULT_PROVIDER', 'ollama'),
|
||||||
|
'enabled_providers' => array_filter([
|
||||||
|
'ollama' => true, // Toujours dispo car local ou simulé
|
||||||
|
'openai' => !empty(env('OPENAI_API_KEY')),
|
||||||
|
'anthropic' => !empty(env('ANTHROPIC_API_KEY')),
|
||||||
|
'gemini' => !empty(env('GEMINI_API_KEY')),
|
||||||
|
], function($v) { return $v; })
|
||||||
|
]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class AIAnalysisService
|
|||||||
/**
|
/**
|
||||||
* Analyze a candidate against their assigned Job Position.
|
* Analyze a candidate against their assigned Job Position.
|
||||||
*/
|
*/
|
||||||
public function analyze(Candidate $candidate)
|
public function analyze(Candidate $candidate, ?string $provider = null)
|
||||||
{
|
{
|
||||||
if (!$candidate->job_position_id) {
|
if (!$candidate->job_position_id) {
|
||||||
throw new \Exception("Le candidat n'est associé à aucune fiche de poste.");
|
throw new \Exception("Le candidat n'est associé à aucune fiche de poste.");
|
||||||
@@ -36,7 +36,7 @@ class AIAnalysisService
|
|||||||
throw new \Exception("Impossible d'extraire le texte du CV.");
|
throw new \Exception("Impossible d'extraire le texte du CV.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->callAI($candidate, $cvText, $letterText);
|
return $this->callAI($candidate, $cvText, $letterText, $provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,13 +75,15 @@ class AIAnalysisService
|
|||||||
/**
|
/**
|
||||||
* Call the AI API (using a placeholder for now, or direct Http call).
|
* Call the AI API (using a placeholder for now, or direct Http call).
|
||||||
*/
|
*/
|
||||||
protected function callAI(Candidate $candidate, string $cvText, ?string $letterText)
|
protected function callAI(Candidate $candidate, string $cvText, ?string $letterText, ?string $provider = null)
|
||||||
{
|
{
|
||||||
|
$provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama');
|
||||||
|
|
||||||
$jobTitle = $candidate->jobPosition->title;
|
$jobTitle = $candidate->jobPosition->title;
|
||||||
$jobDesc = $candidate->jobPosition->description;
|
$jobDesc = $candidate->jobPosition->description;
|
||||||
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []);
|
$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}'.
|
$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:
|
DESCRIPTION DU POSTE:
|
||||||
{$jobDesc}
|
{$jobDesc}
|
||||||
@@ -91,7 +93,6 @@ class AIAnalysisService
|
|||||||
|
|
||||||
CONTENU DU CV:
|
CONTENU DU CV:
|
||||||
{$cvText}
|
{$cvText}
|
||||||
|
|
||||||
CONTENU DE LA LETTRE DE MOTIVATION:
|
CONTENU DE LA LETTRE DE MOTIVATION:
|
||||||
" . ($letterText ?? "Non fournie") . "
|
" . ($letterText ?? "Non fournie") . "
|
||||||
|
|
||||||
@@ -100,16 +101,29 @@ class AIAnalysisService
|
|||||||
|
|
||||||
Fournis une analyse structurée en JSON avec les clés suivantes:
|
Fournis une analyse structurée en JSON avec les clés suivantes:
|
||||||
- match_score: note de 0 à 100
|
- match_score: note de 0 à 100
|
||||||
- summary: résumé de 3-4 phrases sur le profil
|
- 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
|
- strengths: liste des points forts par rapport au poste
|
||||||
- gaps: liste des compétences manquantes ou points de vigilance
|
- gaps: liste des compétences manquantes ou points de vigilance
|
||||||
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)
|
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)
|
||||||
|
|
||||||
Réponds UNIQUEMENT en JSON pur.";
|
Réponds UNIQUEMENT en JSON pur.";
|
||||||
|
|
||||||
// For now, I'll use a mocked response or try to use a generic endpoint if configured.
|
$analysis = match ($provider) {
|
||||||
// I'll check if the user has an Ollama endpoint.
|
'openai' => $this->callOpenAI($prompt),
|
||||||
|
'anthropic' => $this->callAnthropic($prompt),
|
||||||
|
'gemini' => $this->callGemini($prompt),
|
||||||
|
default => $this->callOllama($prompt),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inject metadata for display and tracking
|
||||||
|
$analysis['provider'] = $provider;
|
||||||
|
$analysis['analyzed_at'] = now()->toIso8601String();
|
||||||
|
|
||||||
|
return $analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function callOllama(string $prompt)
|
||||||
|
{
|
||||||
$ollamaUrl = env('OLLAMA_URL', 'http://localhost:11434/api/generate');
|
$ollamaUrl = env('OLLAMA_URL', 'http://localhost:11434/api/generate');
|
||||||
$ollamaModel = env('OLLAMA_MODEL', 'mistral');
|
$ollamaModel = env('OLLAMA_MODEL', 'mistral');
|
||||||
|
|
||||||
@@ -124,16 +138,98 @@ class AIAnalysisService
|
|||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
return json_decode($response->json('response'), true);
|
return json_decode($response->json('response'), true);
|
||||||
} else {
|
} else {
|
||||||
Log::warning("AI Provider Error: HTTP " . $response->status() . " - " . $response->body());
|
Log::warning("AI Provider Error (Ollama): HTTP " . $response->status() . " - " . $response->body());
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error("AI Connection Failed (Ollama): " . $e->getMessage());
|
Log::error("AI Connection Failed (Ollama): " . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for demo if Ollama is not running
|
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-2.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 [
|
return [
|
||||||
'match_score' => 75,
|
'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.",
|
'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"],
|
'strengths' => ["Expérience pertinente", "Bonne présentation"],
|
||||||
'gaps' => ["Compétences spécifiques à confirmer"],
|
'gaps' => ["Compétences spécifiques à confirmer"],
|
||||||
'verdict' => "Favorable"
|
'verdict' => "Favorable"
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ const isSidebarOpen = ref(true);
|
|||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<DropdownLink :href="route('profile.edit')">Profil</DropdownLink>
|
<DropdownLink :href="route('profile.edit')">Profil</DropdownLink>
|
||||||
|
<DropdownLink :href="route('admin.backup')" as="a">Sauvegarde App</DropdownLink>
|
||||||
<DropdownLink :href="route('logout')" method="post" as="button">Déconnexion</DropdownLink>
|
<DropdownLink :href="route('logout')" method="post" as="button">Déconnexion</DropdownLink>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
@@ -144,7 +144,14 @@ const sortedCandidates = computed(() => {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Adéquation IA</th>
|
<th @click="sortBy('ai_analysis.match_score')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
Adéquation IA
|
||||||
|
<svg v-show="sortKey === 'ai_analysis.match_score'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Documents</th>
|
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Documents</th>
|
||||||
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 text-right">Actions</th>
|
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
||||||
|
import axios from 'axios';
|
||||||
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3';
|
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
@@ -11,7 +12,8 @@ import InputError from '@/Components/InputError.vue';
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
candidate: Object,
|
candidate: Object,
|
||||||
jobPositions: Array
|
jobPositions: Array,
|
||||||
|
ai_config: Object
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
@@ -122,24 +124,29 @@ const updateAnswerScore = (answerId, score) => {
|
|||||||
|
|
||||||
const aiAnalysis = ref(props.candidate.ai_analysis || null);
|
const aiAnalysis = ref(props.candidate.ai_analysis || null);
|
||||||
const isAnalyzing = ref(false);
|
const isAnalyzing = ref(false);
|
||||||
|
const selectedProvider = ref(props.ai_config?.default || 'ollama');
|
||||||
|
|
||||||
|
// Error Modal state
|
||||||
|
const showErrorModal = ref(false);
|
||||||
|
const modalErrorMessage = ref("");
|
||||||
|
|
||||||
const runAI = async () => {
|
const runAI = async () => {
|
||||||
if (!props.candidate.job_position_id) {
|
if (!props.candidate.job_position_id) {
|
||||||
alert("Veuillez d'abord associer une fiche de poste à ce candidat.");
|
modalErrorMessage.value = "Veuillez d'abord associer une fiche de poste à ce candidat.";
|
||||||
|
showErrorModal.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isAnalyzing.value = true;
|
isAnalyzing.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(route('admin.candidates.analyze', props.candidate.id));
|
const response = await axios.post(route('admin.candidates.analyze', props.candidate.id), {
|
||||||
const data = await response.json();
|
provider: selectedProvider.value
|
||||||
if (data.error) {
|
});
|
||||||
alert(data.error);
|
aiAnalysis.value = response.data;
|
||||||
} else {
|
|
||||||
aiAnalysis.value = data;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Une erreur est survenue lors de l'analyse.");
|
console.error('AI Analysis Error:', error);
|
||||||
|
modalErrorMessage.value = error.response?.data?.error || "Une erreur est survenue lors de l'analyse.";
|
||||||
|
showErrorModal.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
isAnalyzing.value = false;
|
isAnalyzing.value = false;
|
||||||
}
|
}
|
||||||
@@ -363,37 +370,60 @@ const runAI = async () => {
|
|||||||
|
|
||||||
<!-- AI Analysis Section -->
|
<!-- 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="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 class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-xl font-bold flex items-center gap-2">
|
<h3 class="font-bold text-lg mb-4 flex items-center gap-3 flex-wrap">
|
||||||
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 21v-1m4.243-4.243l-.707-.707m2.828-9.9l-.707.707" />
|
||||||
</svg>
|
</svg>
|
||||||
Analyse d'Adéquation IA
|
Analyse IA complète
|
||||||
</h4>
|
<div class="flex items-center gap-2">
|
||||||
<p class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">Matching CV/Lettre vs Fiche de poste</p>
|
<span v-if="aiAnalysis?.provider" class="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-500 uppercase font-bold border border-slate-200">
|
||||||
|
{{ aiAnalysis.provider }}
|
||||||
|
</span>
|
||||||
|
<span v-if="aiAnalysis?.analyzed_at" class="text-[10px] text-slate-400 italic font-normal">
|
||||||
|
Effectuée le {{ new Date(aiAnalysis.analyzed_at).toLocaleDateString('fr-FR') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">Choisir l'IA pour l'analyse du matching</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PrimaryButton
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
@click="runAI"
|
<!-- Provider Selector -->
|
||||||
:disabled="isAnalyzing"
|
<div v-if="props.ai_config?.enabled_providers" class="flex items-center bg-slate-100 dark:bg-slate-900/50 p-1.5 rounded-2xl border border-slate-200 dark:border-slate-800">
|
||||||
class="!bg-indigo-600 hover:!bg-indigo-500 !border-none !rounded-xl group"
|
<button
|
||||||
>
|
v-for="provider in Object.keys(props.ai_config.enabled_providers)"
|
||||||
<span v-if="isAnalyzing" class="flex items-center gap-2">
|
:key="provider"
|
||||||
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
@click="selectedProvider = provider"
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
class="px-4 py-2 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
|
||||||
<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>
|
:class="selectedProvider === provider ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'"
|
||||||
</svg>
|
>
|
||||||
Analyse en cours...
|
{{ provider }}
|
||||||
</span>
|
</button>
|
||||||
<span v-else class="flex items-center gap-2">
|
</div>
|
||||||
<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" />
|
<PrimaryButton
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
@click="runAI"
|
||||||
</svg>
|
:disabled="isAnalyzing"
|
||||||
Lancer l'analyse intelligente
|
class="!bg-indigo-600 hover:!bg-indigo-500 !border-none !rounded-xl group"
|
||||||
</span>
|
>
|
||||||
</PrimaryButton>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI Results -->
|
<!-- AI Results -->
|
||||||
@@ -672,4 +702,28 @@ const runAI = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
|
|
||||||
|
<!-- Error Modal -->
|
||||||
|
<Modal :show="showErrorModal" @close="showErrorModal = false" maxWidth="md">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-4 mb-4 text-red-600">
|
||||||
|
<div class="flex-shrink-0 w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-bold">Attention</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
{{ modalErrorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<PrimaryButton @click="showErrorModal = false" class="!bg-red-600 hover:!bg-red-500 !border-none">
|
||||||
|
Fermer
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
|
||||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||||
|
|
||||||
|
|||||||
@@ -76,13 +76,14 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::patch('/candidates/{candidate}/notes', [\App\Http\Controllers\CandidateController::class, 'updateNotes'])->name('candidates.update-notes');
|
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}/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::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}/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::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');
|
Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show');
|
||||||
|
|
||||||
Route::resource('quizzes', \App\Http\Controllers\QuizController::class)->only(['index', 'store', 'show', 'update', 'destroy']);
|
Route::resource('quizzes', \App\Http\Controllers\QuizController::class)->only(['index', 'store', 'show', 'update', 'destroy']);
|
||||||
Route::resource('job-positions', \App\Http\Controllers\JobPositionController::class)->only(['index', 'store', 'update', 'destroy']);
|
Route::resource('job-positions', \App\Http\Controllers\JobPositionController::class)->only(['index', 'store', 'update', 'destroy']);
|
||||||
Route::resource('quizzes.questions', \App\Http\Controllers\QuestionController::class)->only(['store', 'update', 'destroy']);
|
Route::resource('quizzes.questions', \App\Http\Controllers\QuestionController::class)->only(['store', 'update', 'destroy']);
|
||||||
|
Route::get('/backup', [\App\Http\Controllers\BackupController::class, 'download'])->name('backup');
|
||||||
Route::delete('/attempts/{attempt}', [\App\Http\Controllers\AttemptController::class, 'destroy'])->name('attempts.destroy');
|
Route::delete('/attempts/{attempt}', [\App\Http\Controllers\AttemptController::class, 'destroy'])->name('attempts.destroy');
|
||||||
Route::patch('/answers/{answer}/score', [\App\Http\Controllers\AttemptController::class, 'updateAnswerScore'])->name('answers.update-score');
|
Route::patch('/answers/{answer}/score', [\App\Http\Controllers\AttemptController::class, 'updateAnswerScore'])->name('answers.update-score');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user