Compare commits

..

7 Commits

Author SHA1 Message Date
jeremy bayse
937857a842 AI Analysis: add custom AI prompt to job positions 2026-03-22 23:24:20 +01:00
jeremy bayse
949423b1ae AI Analysis: clean extracted text to prevent JSON encoding errors 2026-03-22 23:20:42 +01:00
jeremy bayse
e6df75c1ff AI Analysis: configurable model and increased timeout 2026-03-22 23:19:07 +01:00
jeremy bayse
de0392bbe7 AI Analysis: increase timeout to 120s and add logging 2026-03-22 23:18:32 +01:00
jeremy bayse
4459cbde69 AI Analysis: show match score on index and dashboard 2026-03-22 22:38:59 +01:00
jeremy bayse
26d723f239 AI Analysis: move section before notes 2026-03-22 22:34:44 +01:00
jeremy bayse
33fcdcac3d AI Analysis: persist analysis on candidate profile 2026-03-22 22:32:10 +01:00
12 changed files with 228 additions and 89 deletions

View File

@@ -23,6 +23,12 @@ class AIAnalysisController extends Controller
try {
$analysis = $this->aiService->analyze($candidate);
// Persist the analysis on the candidate profile
$candidate->update([
'ai_analysis' => $analysis
]);
return response()->json($analysis);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);

View File

@@ -25,12 +25,14 @@ class JobPositionController extends Controller
'title' => 'required|string|max:255',
'description' => 'required|string',
'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string',
]);
JobPosition::create([
'title' => $request->title,
'description' => $request->description,
'requirements' => $request->requirements,
'ai_prompt' => $request->ai_prompt,
]);
return back()->with('success', 'Fiche de poste créée avec succès.');
@@ -44,12 +46,14 @@ class JobPositionController extends Controller
'title' => 'required|string|max:255',
'description' => 'required|string',
'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string',
]);
$jobPosition->update([
'title' => $request->title,
'description' => $request->description,
'requirements' => $request->requirements,
'ai_prompt' => $request->ai_prompt,
]);
return back()->with('success', 'Fiche de poste mise à jour.');

View File

@@ -9,11 +9,15 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'notes', 'cv_score', 'motivation_score', 'interview_score'])]
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis'])]
class Candidate extends Model
{
use HasFactory;
protected $casts = [
'ai_analysis' => 'array',
];
public function jobPosition(): BelongsTo
{
return $this->belongsTo(JobPosition::class);

View File

@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
#[Fillable(['title', 'description', 'requirements'])]
#[Fillable(['title', 'description', 'requirements', 'ai_prompt'])]
class JobPosition extends Model
{
use HasFactory;

View File

@@ -50,13 +50,28 @@ class AIAnalysisService
try {
$pdf = $this->parser->parseFile(Storage::disk('local')->path($document->file_path));
return $pdf->getText();
$text = $pdf->getText();
return $this->cleanText($text);
} catch (\Exception $e) {
Log::error("PDF Extraction Error: " . $e->getMessage());
return null;
}
}
/**
* Clean text to ensure it's valid UTF-8 and fits well in JSON.
*/
protected function cleanText(string $text): string
{
// Remove non-UTF8 characters
$text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
// Remove control characters (except newlines and tabs)
$text = preg_replace('/[^\x20-\x7E\xA0-\xFF\x0A\x0D\x09]/u', '', $text);
return trim($text);
}
/**
* Call the AI API (using a placeholder for now, or direct Http call).
*/
@@ -80,6 +95,9 @@ class AIAnalysisService
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
@@ -92,11 +110,12 @@ class AIAnalysisService
// 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');
$ollamaUrl = env('OLLAMA_URL', 'http://localhost:11434/api/generate');
$ollamaModel = env('OLLAMA_MODEL', 'mistral');
try {
$response = Http::timeout(60)->post($ollamaUrl, [
'model' => 'mistral', // or llama3
$response = Http::timeout(120)->post($ollamaUrl, [
'model' => $ollamaModel,
'prompt' => $prompt,
'stream' => false,
'format' => 'json'
@@ -104,9 +123,11 @@ class AIAnalysisService
if ($response->successful()) {
return json_decode($response->json('response'), true);
} else {
Log::warning("AI Provider Error: HTTP " . $response->status() . " - " . $response->body());
}
} catch (\Exception $e) {
Log::error("AI Analysis Call Failed: " . $e->getMessage());
Log::error("AI Connection Failed (Ollama): " . $e->getMessage());
}
// Fallback for demo if Ollama is not running

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->json('ai_analysis')->nullable()->after('interview_score');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->dropColumn('ai_analysis');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('job_positions', function (Blueprint $table) {
$table->text('ai_prompt')->nullable()->after('description');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('job_positions', function (Blueprint $table) {
$table->dropColumn('ai_prompt');
});
}
};

View File

@@ -144,6 +144,7 @@ const sortedCandidates = computed(() => {
</svg>
</div>
</th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Adéquation IA</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>
</tr>
@@ -191,6 +192,22 @@ const sortedCandidates = computed(() => {
</span>
</div>
</td>
<td class="px-6 py-4">
<div v-if="candidate.ai_analysis" class="flex items-center gap-2">
<div
class="px-2 py-0.5 rounded text-[10px] font-black"
:class="[
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
candidate.ai_analysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
]"
>
{{ candidate.ai_analysis.match_score }}%
</div>
<span class="text-[10px] font-bold text-slate-400 uppercase truncate max-w-[60px]">{{ candidate.ai_analysis.verdict }}</span>
</div>
<span v-else class="text-[10px] text-slate-300 italic">Non analysé</span>
</td>
<td class="px-6 py-4">
<div class="flex gap-2">
<button

View File

@@ -120,7 +120,7 @@ const updateAnswerScore = (answerId, score) => {
});
};
const aiAnalysis = ref(null);
const aiAnalysis = ref(props.candidate.ai_analysis || null);
const isAnalyzing = ref(false);
const runAI = async () => {
@@ -361,85 +361,6 @@ const runAI = async () => {
</div>
</div>
<!-- Notes Section -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
<div class="flex items-center justify-between mb-6">
<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-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Notes d'entretien & Préparation
</h4>
<div class="flex items-center gap-4">
<div class="flex items-center bg-slate-100 dark:bg-slate-900 p-1 rounded-xl">
<button
@click="isPreview = false"
class="px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all"
:class="!isPreview ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-500'"
>
Édition
</button>
<button
@click="isPreview = true"
class="px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all"
:class="isPreview ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-500'"
>
Aperçu
</button>
</div>
<div v-if="notesForm.isDirty" class="flex items-center gap-2 animate-pulse">
<PrimaryButton @click="saveNotes" class="!px-4 !py-1 text-[10px]" :disabled="notesForm.processing">
Enregistrer
</PrimaryButton>
</div>
<div v-else class="flex items-center gap-2 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="M5 13l4 4L19 7" />
</svg>
<span class="text-[10px] font-black uppercase tracking-widest">Enregistré</span>
</div>
</div>
</div>
<!-- Markdown Help Guide -->
<div v-if="!isPreview" class="mb-6 grid grid-cols-2 md:grid-cols-4 gap-2">
<div class="p-2 border border-slate-100 dark:border-slate-700/50 rounded-lg text-[10px] text-slate-500 dark:text-slate-400">
<code class="text-indigo-500 font-bold"># Titre</code>, <code class="text-indigo-500 font-bold">## Sous-titre</code>
</div>
<div class="p-2 border border-slate-100 dark:border-slate-700/50 rounded-lg text-[10px] text-slate-500 dark:text-slate-400">
<code class="text-indigo-500 font-bold">**Gras**</code>, <code class="text-indigo-500 font-bold">*Italique*</code>
</div>
<div class="p-2 border border-slate-100 dark:border-slate-700/50 rounded-lg text-[10px] text-slate-500 dark:text-slate-400">
<code class="text-indigo-500 font-bold">* Liste</code>, <code class="text-indigo-500 font-bold">1. Liste num.</code>
</div>
<div class="p-2 border border-slate-100 dark:border-slate-700/50 rounded-lg text-[10px] text-slate-500 dark:text-slate-400">
<code class="text-indigo-500 font-bold">> Citation</code>, <code class="text-indigo-500 font-bold">--- (Ligne)</code>
</div>
</div>
<div class="relative group">
<div v-if="isPreview"
class="prose dark:prose-invert prose-slate max-w-none w-full bg-slate-50 dark:bg-slate-900 rounded-2xl p-8 min-h-[300px] text-sm leading-relaxed"
v-html="renderedNotes">
</div>
<textarea
v-else
v-model="notesForm.notes"
rows="12"
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-2xl p-6 text-sm selection:bg-indigo-100 dark:selection:bg-indigo-900 focus:ring-2 focus:ring-indigo-500/20 transition-all placeholder:text-slate-300 dark:placeholder:text-slate-600 leading-relaxed font-mono"
placeholder="Rédigez ici vos questions (utilisez # pour les titres, * pour les listes...)"
></textarea>
<div v-if="!isPreview" class="absolute bottom-4 right-4 text-[9px] font-bold text-slate-400 opacity-60 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Markdown Supporté
</div>
</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">
@@ -540,6 +461,85 @@ const runAI = async () => {
</div>
</div>
<!-- Notes Section -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
<div class="flex items-center justify-between mb-6">
<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-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Notes d'entretien & Préparation
</h4>
<div class="flex items-center gap-4">
<div class="flex items-center bg-slate-100 dark:bg-slate-900 p-1 rounded-xl">
<button
@click="isPreview = false"
class="px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all"
:class="!isPreview ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-500'"
>
Édition
</button>
<button
@click="isPreview = true"
class="px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all"
:class="isPreview ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-500'"
>
Aperçu
</button>
</div>
<div v-if="notesForm.isDirty" class="flex items-center gap-2 animate-pulse">
<PrimaryButton @click="saveNotes" class="!px-4 !py-1 text-[10px]" :disabled="notesForm.processing">
Enregistrer
</PrimaryButton>
</div>
<div v-else class="flex items-center gap-2 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="M5 13l4 4L19 7" />
</svg>
<span class="text-[10px] font-black uppercase tracking-widest">Enregistré</span>
</div>
</div>
</div>
<!-- Markdown Help Guide -->
<div v-if="!isPreview" class="mb-6 grid grid-cols-2 md:grid-cols-4 gap-2">
<div class="p-2 border border-slate-100 dark:border-slate-700/50 rounded-lg text-[10px] text-slate-500 dark:text-slate-400">
<code class="text-indigo-500 font-bold"># Titre</code>, <code class="text-indigo-500 font-bold">## Sous-titre</code>
</div>
<div class="p-2 border border-slate-100 dark:border-slate-700/50 rounded-lg text-[10px] text-slate-500 dark:text-slate-400">
<code class="text-indigo-500 font-bold">**Gras**</code>, <code class="text-indigo-500 font-bold">*Italique*</code>
</div>
<div class="p-2 border border-slate-100 dark:border-slate-700/50 rounded-lg text-[10px] text-slate-500 dark:text-slate-400">
<code class="text-indigo-500 font-bold">* Liste</code>, <code class="text-indigo-500 font-bold">1. Liste num.</code>
</div>
<div class="p-2 border border-slate-100 dark:border-slate-700/50 rounded-lg text-[10px] text-slate-500 dark:text-slate-400">
<code class="text-indigo-500 font-bold">> Citation</code>, <code class="text-indigo-500 font-bold">--- (Ligne)</code>
</div>
</div>
<div class="relative group">
<div v-if="isPreview"
class="prose dark:prose-invert prose-slate max-w-none w-full bg-slate-50 dark:bg-slate-900 rounded-2xl p-8 min-h-[300px] text-sm leading-relaxed"
v-html="renderedNotes">
</div>
<textarea
v-else
v-model="notesForm.notes"
rows="12"
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-2xl p-6 text-sm selection:bg-indigo-100 dark:selection:bg-indigo-900 focus:ring-2 focus:ring-indigo-500/20 transition-all placeholder:text-slate-300 dark:placeholder:text-slate-600 leading-relaxed font-mono"
placeholder="Rédigez ici vos questions (utilisez # pour les titres, * pour les listes...)"
></textarea>
<div v-if="!isPreview" class="absolute bottom-4 right-4 text-[9px] font-bold text-slate-400 opacity-60 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Markdown Supporté
</div>
</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

@@ -18,7 +18,8 @@ const editingPosition = ref(null);
const form = useForm({
title: '',
description: '',
requirements: []
requirements: [],
ai_prompt: ''
});
const openModal = (position = null) => {
@@ -27,6 +28,7 @@ const openModal = (position = null) => {
form.title = position.title;
form.description = position.description;
form.requirements = position.requirements || [];
form.ai_prompt = position.ai_prompt || '';
} else {
form.reset();
}
@@ -165,6 +167,18 @@ const removeRequirement = (index) => {
<InputError :message="form.errors.description" />
</div>
<div class="bg-indigo-50/50 dark:bg-indigo-900/10 p-6 rounded-3xl border border-indigo-100 dark:border-indigo-800/50">
<label class="block text-xs font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-400 mb-2">IA Context & Prompt Personnalisé</label>
<p class="text-[10px] text-indigo-400 mb-4 font-bold uppercase tracking-tight">Utilisez cette zone pour donner des instructions spécifiques à l'IA (priorités, contexte entreprise, ton de l'analyse...)</p>
<textarea
v-model="form.ai_prompt"
rows="5"
class="w-full bg-white dark:bg-slate-900 border-none rounded-2xl p-4 focus:ring-2 focus:ring-indigo-500/20 transition-all text-sm leading-relaxed"
placeholder="Ex: Sois particulièrement attentif à l'expérience sur des projets SaaS à forte charge. Favorise les candidats ayant travaillé en environnement Agile."
></textarea>
<InputError :message="form.errors.ai_prompt" />
</div>
<div>
<div class="flex justify-between items-center mb-4">
<label class="text-xs font-black uppercase tracking-widest text-slate-400">Compétences clés / Pré-requis</label>

View File

@@ -70,6 +70,7 @@ const getStatusColor = (status) => {
<tr class="bg-slate-50/50 dark:bg-slate-900/30">
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Candidat</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Score Pondéré</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Adéquation IA</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Statut</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 text-right">Actions</th>
</tr>
@@ -85,6 +86,21 @@ const getStatusColor = (status) => {
{{ candidate.weighted_score }} / 20
</div>
</td>
<td class="px-8 py-5">
<div v-if="candidate.ai_analysis" class="flex items-center gap-2">
<div
class="px-2 py-0.5 rounded text-[10px] font-black"
:class="[
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
candidate.ai_analysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
]"
>
{{ candidate.ai_analysis.match_score }}%
</div>
</div>
<span v-else class="text-[10px] text-slate-300 italic font-medium">Non analysé</span>
</td>
<td class="px-8 py-5">
<span
class="px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-full"

View File

@@ -41,7 +41,8 @@ Route::get('/dashboard', function () {
'name' => $candidate->user->name,
'email' => $candidate->user->email,
'status' => $candidate->status,
'weighted_score' => $candidate->weighted_score
'weighted_score' => $candidate->weighted_score,
'ai_analysis' => $candidate->ai_analysis
];
})
->values()