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

@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Candidate;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
class CandidateExportController extends Controller
{
public function exportDossier(Candidate $candidate)
{
$candidate->load([
'user',
'jobPosition',
'tenant',
'attempts.quiz.questions',
'attempts.answers.option',
'attempts.answers.question',
'documents'
]);
$filename = 'Dossier_' . str_replace(' ', '_', $candidate->user->name) . '_' . date('Ymd') . '.pdf';
// 1. Generate Main Report with DomPDF
$pdfReport = Pdf::loadView('pdfs.candidate-dossier', [
'candidate' => $candidate
]);
$reportBinary = $pdfReport->output();
// 2. Setup FPDI for merging
$mergedPdf = new \setasign\Fpdi\Fpdi();
// Add Main Report Pages
$reportTmp = tempnam(sys_get_temp_dir(), 'pdf_report_');
file_put_contents($reportTmp, $reportBinary);
try {
$pageCount = $mergedPdf->setSourceFile($reportTmp);
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
$templateId = $mergedPdf->importPage($pageNo);
$size = $mergedPdf->getTemplateSize($templateId);
$mergedPdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
$mergedPdf->useTemplate($templateId);
}
} catch (\Exception $e) {
\Log::error('FPDI Error on report: ' . $e->getMessage());
}
@unlink($reportTmp);
// 3. Append Candidate Documents (CV, Letter)
foreach ($candidate->documents as $doc) {
if (\Storage::disk('local')->exists($doc->file_path)) {
$filePath = \Storage::disk('local')->path($doc->file_path);
try {
$pageCount = $mergedPdf->setSourceFile($filePath);
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
$templateId = $mergedPdf->importPage($pageNo);
$size = $mergedPdf->getTemplateSize($templateId);
$mergedPdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
$mergedPdf->useTemplate($templateId);
}
} catch (\Exception $e) {
\Log::warning('Could not merge document ID ' . $doc->id . ': ' . $e->getMessage());
// Add a professional placeholder page for unmergable documents
$mergedPdf->AddPage('P', 'A4');
$mergedPdf->SetFont('Arial', 'B', 16);
$mergedPdf->SetTextColor(0, 79, 130); // Primary color
$mergedPdf->Ln(50);
$mergedPdf->Cell(0, 20, utf8_decode("DOCUMENT JOINT : " . strtoupper($doc->type)), 0, 1, 'C');
$mergedPdf->SetFont('Arial', 'I', 12);
$mergedPdf->Cell(0, 10, utf8_decode($doc->original_name), 0, 1, 'C');
$mergedPdf->Ln(30);
$mergedPdf->SetFont('Arial', '', 11);
$mergedPdf->SetTextColor(100, 100, 100);
$mergedPdf->MultiCell(0, 8, utf8_decode("Ce document n'a pas pu être fusionné automatiquement au dossier car son format est trop récent (PDF 1.5+).\n\nPour garantir l'intégrité de la mise en page, veuillez consulter ce document séparément via l'interface du tableau de bord candidat."), 0, 'C');
$mergedPdf->Ln(20);
$mergedPdf->SetDrawColor(224, 176, 76); // Highlight color
$mergedPdf->Line(60, $mergedPdf->GetY(), 150, $mergedPdf->GetY());
}
}
}
return response($mergedPdf->Output('S'), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Pragma' => 'no-cache',
'Expires' => '0',
]);
}
public function exportZip(Candidate $candidate)
{
$candidate->load(['user', 'jobPosition', 'tenant', 'attempts.quiz.questions', 'attempts.answers.option', 'attempts.answers.question', 'documents']);
$baseName = 'Dossier_' . str_replace(' ', '_', $candidate->user->name) . '_' . date('Ymd');
$zipPath = tempnam(sys_get_temp_dir(), 'candidate_zip_');
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
return back()->with('error', 'Impossible de créer le fichier ZIP.');
}
// 1. Add the main report (Rapport CABM)
$pdfReport = Pdf::loadView('pdfs.candidate-dossier', [
'candidate' => $candidate
]);
$zip->addFromString($baseName . '/Rapport_Synthese_CABM.pdf', $pdfReport->output());
// 2. Add original documents
foreach ($candidate->documents as $doc) {
if (\Storage::disk('local')->exists($doc->file_path)) {
$content = \Storage::disk('local')->get($doc->file_path);
// Sanitize original name or use type
$ext = pathinfo($doc->original_name, PATHINFO_EXTENSION);
$fileName = strtoupper($doc->type) . '_' . $doc->original_name;
$zip->addFromString($baseName . '/Documents_Originaux/' . $fileName, $content);
}
}
$zip->close();
return response()->download($zipPath, $baseName . '.zip')->deleteFileAfterSend(true);
}
}

View File

@@ -90,12 +90,23 @@ class AttemptController extends Controller
public function saveAnswer(Request $request, Attempt $attempt)
{
// Security: Verify the authenticated user owns this attempt
$candidate = auth()->user()->candidate;
if (!$candidate || $attempt->candidate_id !== $candidate->id) {
abort(403, 'You are not authorized to submit answers for this attempt.');
}
$request->validate([
'question_id' => 'required|exists:questions,id',
'option_id' => 'nullable|exists:options,id',
'text_content' => 'nullable|string',
]);
// Extra guard: prevent answering a finished attempt
if ($attempt->finished_at) {
return response()->json(['error' => 'This attempt is already finished.'], 403);
}
Answer::updateOrCreate(
[
'attempt_id' => $attempt->id,
@@ -112,6 +123,12 @@ class AttemptController extends Controller
public function finish(Attempt $attempt)
{
// Security: Verify the authenticated user owns this attempt
$candidate = auth()->user()->candidate;
if (!$candidate || $attempt->candidate_id !== $candidate->id) {
abort(403, 'You are not authorized to finish this attempt.');
}
if ($attempt->finished_at) {
return redirect()->route('dashboard');
}

View File

@@ -11,6 +11,11 @@ class BackupController extends Controller
{
public function download()
{
// Security: Only super admins can download backups containing all tenant data
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Seuls les super administrateurs peuvent télécharger des sauvegardes.');
}
$databaseName = env('DB_DATABASE');
$userName = env('DB_USERNAME');
$password = env('DB_PASSWORD');

View File

@@ -139,8 +139,20 @@ class CandidateController extends Controller
$request->validate([
'cv' => 'nullable|file|mimes:pdf|max:5120',
'cover_letter' => 'nullable|file|mimes:pdf|max:5120',
'name' => 'nullable|string|max:255',
'email' => 'nullable|string|email|max:255|unique:users,email,' . $candidate->user_id,
'phone' => 'nullable|string|max:255',
'linkedin_url' => 'nullable|url|max:255',
]);
// Update User info if name or email present
if ($request->has('name') || $request->has('email')) {
$candidate->user->update($request->only(['name', 'email']));
}
// Update Candidate info
$candidate->update($request->only(['phone', 'linkedin_url']));
if ($request->hasFile('cv')) {
$this->replaceDocument($candidate, $request->file('cv'), 'cv');
}
@@ -149,20 +161,24 @@ class CandidateController extends Controller
$this->replaceDocument($candidate, $request->file('cover_letter'), 'cover_letter');
}
return back()->with('success', 'Documents mis à jour avec succès.');
return back()->with('success', 'Profil mis à jour avec succès.');
}
public function updateNotes(Request $request, Candidate $candidate)
{
$request->validate([
'notes' => 'nullable|string',
'interview_details' => 'nullable|array',
'interview_score' => 'nullable|numeric|min:0|max:30',
]);
$candidate->update([
'notes' => $request->notes,
'interview_details' => $request->interview_details,
'interview_score' => $request->has('interview_score') ? $request->interview_score : $candidate->interview_score,
]);
return back()->with('success', 'Notes mises à jour avec succès.');
return back()->with('success', 'Entretien mis à jour avec succès.');
}
public function updateScores(Request $request, Candidate $candidate)

View File

@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use App\Traits\BelongsToTenant;
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis', 'tenant_id'])]
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'interview_details', 'ai_analysis', 'tenant_id'])]
class Candidate extends Model
{
use HasFactory, BelongsToTenant;
@@ -31,6 +31,7 @@ class Candidate extends Model
protected $casts = [
'ai_analysis' => 'array',
'is_selected' => 'boolean',
'interview_details' => 'array',
];
public function jobPosition(): BelongsTo

View File

@@ -9,13 +9,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use App\Traits\BelongsToTenant;
#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'tenant_id'])]
#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'gemini_cache_id', 'gemini_cache_expires_at', 'tenant_id'])]
class JobPosition extends Model
{
use HasFactory, BelongsToTenant;
protected $casts = [
'requirements' => 'array',
'gemini_cache_expires_at' => 'datetime',
];
public function candidates(): HasMany

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

View File

@@ -18,12 +18,9 @@ trait BelongsToTenant
return;
}
// Candidates don't have a tenant_id but must access
// quizzes/job positions linked to their position
if ($user->role === 'candidate') {
return;
}
// All other users (admins and candidates) are filtered by their tenant.
// This includes candidates, who must only see data from their own organization.
// Resources with a null tenant_id are considered global and always visible.
if ($user->tenant_id) {
$builder->where(function ($query) use ($user) {
$query->where('tenant_id', $user->tenant_id)