feat(ai): refactor AI prompts and add bypass option to Job Positions

This commit is contained in:
jeremy bayse
2026-04-19 21:24:56 +02:00
parent 205c24182d
commit d924765b94
6 changed files with 111 additions and 30 deletions

View File

@@ -28,6 +28,7 @@ class JobPositionController extends Controller
'description' => 'required|string',
'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string',
'ai_bypass_base_prompt' => 'boolean',
'tenant_id' => 'nullable|exists:tenants,id',
'quiz_ids' => 'nullable|array',
'quiz_ids.*' => 'exists:quizzes,id',
@@ -38,6 +39,7 @@ class JobPositionController extends Controller
'description' => $request->description,
'requirements' => $request->requirements,
'ai_prompt' => $request->ai_prompt,
'ai_bypass_base_prompt' => $request->boolean('ai_bypass_base_prompt'),
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
]);
@@ -55,6 +57,7 @@ class JobPositionController extends Controller
'description' => 'required|string',
'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string',
'ai_bypass_base_prompt' => 'boolean',
'tenant_id' => 'nullable|exists:tenants,id',
'quiz_ids' => 'nullable|array',
'quiz_ids.*' => 'exists:quizzes,id',
@@ -65,6 +68,7 @@ class JobPositionController extends Controller
'description' => $request->description,
'requirements' => $request->requirements,
'ai_prompt' => $request->ai_prompt,
'ai_bypass_base_prompt' => $request->boolean('ai_bypass_base_prompt'),
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
]);

View File

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

View File

@@ -80,42 +80,42 @@ class AIAnalysisService
$provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama');
$job = $candidate->jobPosition;
$jobTitle = $job->title;
$jobDesc = $job->description;
$requirements = implode(", ", $job->requirements ?? []);
// 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 (!$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}
COMPÉTENCES REQUISES:
{$requirements}
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)";
// --- BYPASS LOGIC ---
if ($job->ai_bypass_base_prompt && !empty($job->ai_prompt)) {
$staticPrompt = $job->ai_prompt;
// We still append the JSON requirement to ensure the frontend doesn't crash,
// unless the user specifically asked for "pure" takeover.
// Most users want to control the "logic" not the "serialization format".
if (!str_contains(strtolower($staticPrompt), 'json')) {
$staticPrompt .= "\n\nRéponds UNIQUEMENT en JSON pur. Format attendu:\n" . config('ai.defaults.json_format');
}
} else {
$staticPrompt .= "
// --- STANDARD LOGIC ---
// Base instructions from config
$baseInstruction = config('ai.defaults.base_instruction');
$jsonFormat = config('ai.defaults.json_format');
$staticPrompt = "{$baseInstruction} Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$job->title}'.\n\n";
CONTEXTE DU POSTE:
{$jobDesc}
$staticPrompt .= "DESCRIPTION DU POSTE:\n{$job->description}\n\n";
COMPÉTENCES REQUISES:
{$requirements}
if (!empty($job->requirements)) {
$staticPrompt .= "COMPÉTENCES REQUISES:\n" . implode(", ", $job->requirements) . "\n\n";
}
if (!$job->ai_prompt) {
// Default generalist analysis instructions
$staticPrompt .= "CONSIGNES D'ANALYSE:\n" . config('ai.defaults.analysis_instructions') . "\n\n";
} else {
// Specific instructions from the job position
$staticPrompt .= "CONSIGNES D'ANALYSE SPÉCIFIQUES:\n" . $job->ai_prompt . "\n\n";
}
CONSIGNES D'ANALYSE SPÉCIFIQUES:
" . $job->ai_prompt;
$staticPrompt .= "FORMAT DE RÉPONSE ATTENDU:\n{$jsonFormat}\n";
}
$staticPrompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide.";
$staticPrompt .= "\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");

34
config/ai.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| AI Service Configuration
|--------------------------------------------------------------------------
|
| This file contains the default prompts and settings for the AI analysis.
|
*/
'defaults' => [
'base_instruction' => "Tu es un expert en recrutement expérimenté. Ton rôle est d'analyser le profil d'un candidat avec impartialité et précision.",
'analysis_instructions' => "Attache une grande importance à l'adéquation entre le parcours du candidat et les besoins du poste, tant sur le plan technique que sur les savoir-être.",
'json_format' => "Fournis une analyse structurée en JSON avec les clés suivantes impérativement:
- match_score: note de 0 à 100 (nombre entier)
- 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)",
],
'providers' => [
'default' => env('AI_DEFAULT_PROVIDER', 'ollama'),
'ollama' => [
'url' => env('OLLAMA_URL', 'http://localhost:11434/api/generate'),
'model' => env('OLLAMA_MODEL', 'mistral'),
],
// ...
]
];

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->boolean('ai_bypass_base_prompt')->default(false)->after('ai_prompt');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('job_positions', function (Blueprint $table) {
$table->dropColumn('ai_bypass_base_prompt');
});
}
};

View File

@@ -22,6 +22,7 @@ const form = useForm({
description: '',
requirements: [],
ai_prompt: '',
ai_bypass_base_prompt: false,
tenant_id: '',
quiz_ids: [],
});
@@ -33,6 +34,7 @@ const openModal = (position = null) => {
form.description = position.description;
form.requirements = position.requirements || [];
form.ai_prompt = position.ai_prompt || '';
form.ai_bypass_base_prompt = !!position.ai_bypass_base_prompt;
form.tenant_id = position.tenant_id || '';
form.quiz_ids = position.quizzes ? position.quizzes.map(q => q.id) : [];
} else {
@@ -204,6 +206,18 @@ const removeRequirement = (index) => {
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 class="mt-4 flex items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
v-model="form.ai_bypass_base_prompt"
class="sr-only peer"
>
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
<span class="ml-3 text-xs font-black uppercase tracking-widest text-indigo-900 dark:text-indigo-100">Ignorer le prompt de base (Utiliser exclusivement ce texte)</span>
</label>
</div>
</div>
<div v-if="quizzes && quizzes.length > 0">