feat(ai): refactor AI prompts and add bypass option to Job Positions
This commit is contained in:
@@ -28,6 +28,7 @@ class JobPositionController extends Controller
|
|||||||
'description' => 'required|string',
|
'description' => 'required|string',
|
||||||
'requirements' => 'nullable|array',
|
'requirements' => 'nullable|array',
|
||||||
'ai_prompt' => 'nullable|string',
|
'ai_prompt' => 'nullable|string',
|
||||||
|
'ai_bypass_base_prompt' => 'boolean',
|
||||||
'tenant_id' => 'nullable|exists:tenants,id',
|
'tenant_id' => 'nullable|exists:tenants,id',
|
||||||
'quiz_ids' => 'nullable|array',
|
'quiz_ids' => 'nullable|array',
|
||||||
'quiz_ids.*' => 'exists:quizzes,id',
|
'quiz_ids.*' => 'exists:quizzes,id',
|
||||||
@@ -38,6 +39,7 @@ class JobPositionController extends Controller
|
|||||||
'description' => $request->description,
|
'description' => $request->description,
|
||||||
'requirements' => $request->requirements,
|
'requirements' => $request->requirements,
|
||||||
'ai_prompt' => $request->ai_prompt,
|
'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,
|
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ class JobPositionController extends Controller
|
|||||||
'description' => 'required|string',
|
'description' => 'required|string',
|
||||||
'requirements' => 'nullable|array',
|
'requirements' => 'nullable|array',
|
||||||
'ai_prompt' => 'nullable|string',
|
'ai_prompt' => 'nullable|string',
|
||||||
|
'ai_bypass_base_prompt' => 'boolean',
|
||||||
'tenant_id' => 'nullable|exists:tenants,id',
|
'tenant_id' => 'nullable|exists:tenants,id',
|
||||||
'quiz_ids' => 'nullable|array',
|
'quiz_ids' => 'nullable|array',
|
||||||
'quiz_ids.*' => 'exists:quizzes,id',
|
'quiz_ids.*' => 'exists:quizzes,id',
|
||||||
@@ -65,6 +68,7 @@ class JobPositionController extends Controller
|
|||||||
'description' => $request->description,
|
'description' => $request->description,
|
||||||
'requirements' => $request->requirements,
|
'requirements' => $request->requirements,
|
||||||
'ai_prompt' => $request->ai_prompt,
|
'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,
|
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
|
|
||||||
use App\Traits\BelongsToTenant;
|
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
|
class JobPosition extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, BelongsToTenant;
|
use HasFactory, BelongsToTenant;
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'requirements' => 'array',
|
'requirements' => 'array',
|
||||||
|
'ai_bypass_base_prompt' => 'boolean',
|
||||||
'gemini_cache_expires_at' => 'datetime',
|
'gemini_cache_expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -80,42 +80,42 @@ class AIAnalysisService
|
|||||||
$provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama');
|
$provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama');
|
||||||
|
|
||||||
$job = $candidate->jobPosition;
|
$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) {
|
// --- BYPASS LOGIC ---
|
||||||
$staticPrompt .= " 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_bypass_base_prompt && !empty($job->ai_prompt)) {
|
||||||
|
$staticPrompt = $job->ai_prompt;
|
||||||
DESCRIPTION DU POSTE:
|
// We still append the JSON requirement to ensure the frontend doesn't crash,
|
||||||
{$jobDesc}
|
// unless the user specifically asked for "pure" takeover.
|
||||||
|
// Most users want to control the "logic" not the "serialization format".
|
||||||
COMPÉTENCES REQUISES:
|
if (!str_contains(strtolower($staticPrompt), 'json')) {
|
||||||
{$requirements}
|
$staticPrompt .= "\n\nRéponds UNIQUEMENT en JSON pur. Format attendu:\n" . config('ai.defaults.json_format');
|
||||||
|
}
|
||||||
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)";
|
|
||||||
} else {
|
} 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:
|
$staticPrompt .= "DESCRIPTION DU POSTE:\n{$job->description}\n\n";
|
||||||
{$jobDesc}
|
|
||||||
|
|
||||||
COMPÉTENCES REQUISES:
|
if (!empty($job->requirements)) {
|
||||||
{$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:
|
$staticPrompt .= "FORMAT DE RÉPONSE ATTENDU:\n{$jsonFormat}\n";
|
||||||
" . $job->ai_prompt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$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)
|
// 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");
|
$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
34
config/ai.php
Normal 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'),
|
||||||
|
],
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ const form = useForm({
|
|||||||
description: '',
|
description: '',
|
||||||
requirements: [],
|
requirements: [],
|
||||||
ai_prompt: '',
|
ai_prompt: '',
|
||||||
|
ai_bypass_base_prompt: false,
|
||||||
tenant_id: '',
|
tenant_id: '',
|
||||||
quiz_ids: [],
|
quiz_ids: [],
|
||||||
});
|
});
|
||||||
@@ -33,6 +34,7 @@ const openModal = (position = null) => {
|
|||||||
form.description = position.description;
|
form.description = position.description;
|
||||||
form.requirements = position.requirements || [];
|
form.requirements = position.requirements || [];
|
||||||
form.ai_prompt = position.ai_prompt || '';
|
form.ai_prompt = position.ai_prompt || '';
|
||||||
|
form.ai_bypass_base_prompt = !!position.ai_bypass_base_prompt;
|
||||||
form.tenant_id = position.tenant_id || '';
|
form.tenant_id = position.tenant_id || '';
|
||||||
form.quiz_ids = position.quizzes ? position.quizzes.map(q => q.id) : [];
|
form.quiz_ids = position.quizzes ? position.quizzes.map(q => q.id) : [];
|
||||||
} else {
|
} 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."
|
placeholder="Ex: Sois particulièrement attentif à l'expérience sur des projets SaaS à forte charge. Favorise les candidats ayant travaillé en environnement Agile."
|
||||||
></textarea>
|
></textarea>
|
||||||
<InputError :message="form.errors.ai_prompt" />
|
<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>
|
||||||
|
|
||||||
<div v-if="quizzes && quizzes.length > 0">
|
<div v-if="quizzes && quizzes.length > 0">
|
||||||
|
|||||||
Reference in New Issue
Block a user