diff --git a/app/Http/Controllers/Admin/JobPositionAiHelperController.php b/app/Http/Controllers/Admin/JobPositionAiHelperController.php new file mode 100644 index 0000000..b8238f3 --- /dev/null +++ b/app/Http/Controllers/Admin/JobPositionAiHelperController.php @@ -0,0 +1,78 @@ +user()->isAdmin()) { + abort(403); + } + + $request->validate([ + 'title' => 'required|string', + 'description' => 'required|string', + ]); + + $prompt = "Tu es un expert en ingénierie logicielle spécialisé dans la Fonction Publique Territoriale (FPT) et le Code Général de la Fonction Publique (CGFP). +Mon application permet de créer des offres d'emploi. Je n'ai pas encore de backend RH métier. Ton rôle est de transformer une saisie utilisateur (souvent incomplète) en un objet JSON structuré respectant strictement les obligations réglementaires françaises (DVE, publicité légale). + +### INPUT UTILISATEUR : +Titre : {$request->title} +Description : {$request->description} + +### TES MISSIONS : +1. ANALYSE STATUTAIRE : Identifie automatiquement la catégorie (A, B, C), le cadre d'emplois et les grades possibles selon l'intitulé du poste. +2. COMPLÉTION RÉGLEMENTAIRE : Génère les mentions obligatoires manquantes (fondements juridiques pour les contractuels, références au CGFP). +3. STRUCTURATION DES DONNÉES : Retourne uniquement un objet JSON contenant : + - \"infos_poste\" : {intitule, categorie, cadre_emplois, grade_mini, grade_maxi} + - \"conformite\" : {fondement_juridique_recrutement, mentions_legales_obligatoires: []} + - \"publication\" : {support_obligatoire: \"Choisir le service public / Emploi Territorial\", delai_affichage_minimal: \"30 jours\"} + - \"fiche_synthese\" : Un texte complet de l'annonce, richement formaté en Markdown (avec des titres ###, des puces -, et du texte en **gras** pour optimiser l'attractivité et le SEO), tout en restant conforme au droit. + +### CONTRAINTES : +- Ne propose que des cadres d'emplois existants dans la FPT (ex: Adjoint technique, Rédacteur, Attaché). +- Si le poste semble ouvert aux contractuels, précise l'article L332-8 ou L332-14 du CGFP approprié. +- L'ensemble de ta réponse doit être un objet JSON brut. Ne mets PAS de blocs de code markdown (comme ```json) autour de la réponse, retourne juste le JSON. Le contenu de la clé `fiche_synthese` DOIT cependant contenir du formatage Markdown interne."; + + $apiKey = env('GEMINI_API_KEY'); + if (!$apiKey) { + return response()->json(['error' => 'API Key non configurée'], 500); + } + + try { + $response = Http::timeout(60)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent?key={$apiKey}", [ + 'generationConfig' => [ + 'temperature' => 0.2, + 'responseMimeType' => 'application/json' + ], + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => $prompt]]] + ] + ]); + + if ($response->successful()) { + $text = $response->json('candidates.0.content.parts.0.text'); + // Extract JSON if it contains markdown formatting + preg_match('/\{.*\}/s', $text, $matches); + $json = $matches[0] ?? $text; + + return response()->json(json_decode($json, true)); + } + + Log::error("Gemini API Error: " . $response->body()); + return response()->json(['error' => 'Erreur de génération IA'], 500); + + } catch (\Exception $e) { + Log::error("Gemini Connection Failed: " . $e->getMessage()); + return response()->json(['error' => 'Erreur de connexion à l\'IA'], 500); + } + } +} diff --git a/app/Http/Controllers/JobPositionController.php b/app/Http/Controllers/JobPositionController.php index 4ec1dd7..4d237c8 100644 --- a/app/Http/Controllers/JobPositionController.php +++ b/app/Http/Controllers/JobPositionController.php @@ -29,6 +29,7 @@ class JobPositionController extends Controller 'requirements' => 'nullable|array', 'ai_prompt' => 'nullable|string', 'ai_bypass_base_prompt' => 'boolean', + 'fpt_metadata' => 'nullable|array', 'tenant_id' => 'nullable|exists:tenants,id', 'quiz_ids' => 'nullable|array', 'quiz_ids.*' => 'exists:quizzes,id', @@ -40,6 +41,7 @@ class JobPositionController extends Controller 'requirements' => $request->requirements, 'ai_prompt' => $request->ai_prompt, 'ai_bypass_base_prompt' => $request->boolean('ai_bypass_base_prompt'), + 'fpt_metadata' => $request->fpt_metadata, 'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id, ]); @@ -58,6 +60,7 @@ class JobPositionController extends Controller 'requirements' => 'nullable|array', 'ai_prompt' => 'nullable|string', 'ai_bypass_base_prompt' => 'boolean', + 'fpt_metadata' => 'nullable|array', 'tenant_id' => 'nullable|exists:tenants,id', 'quiz_ids' => 'nullable|array', 'quiz_ids.*' => 'exists:quizzes,id', @@ -69,6 +72,7 @@ class JobPositionController extends Controller 'requirements' => $request->requirements, 'ai_prompt' => $request->ai_prompt, 'ai_bypass_base_prompt' => $request->boolean('ai_bypass_base_prompt'), + 'fpt_metadata' => $request->fpt_metadata, 'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id, ]); diff --git a/app/Http/Controllers/PublicJobApplicationController.php b/app/Http/Controllers/PublicJobApplicationController.php index 46593c2..8c0e065 100644 --- a/app/Http/Controllers/PublicJobApplicationController.php +++ b/app/Http/Controllers/PublicJobApplicationController.php @@ -16,7 +16,11 @@ class PublicJobApplicationController extends Controller { public function index() { - $jobs = JobPosition::with('tenant')->orderBy('created_at', 'desc')->get(); + $jobs = JobPosition::with('tenant')->orderBy('created_at', 'desc')->get()->map(function($job) { + $job->description = strip_tags(\Illuminate\Support\Str::markdown($job->description)); + return $job; + }); + return Inertia::render('Public/Jobs/Index', [ 'jobs' => $jobs ]); @@ -24,8 +28,11 @@ class PublicJobApplicationController extends Controller public function show(JobPosition $jobPosition) { + $data = $jobPosition->toArray(); + $data['description_html'] = \Illuminate\Support\Str::markdown($jobPosition->description); + return Inertia::render('Public/Jobs/Show', [ - 'jobPosition' => $jobPosition + 'jobPosition' => $data ]); } diff --git a/app/Models/JobPosition.php b/app/Models/JobPosition.php index 2fa8913..3445f64 100644 --- a/app/Models/JobPosition.php +++ b/app/Models/JobPosition.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use App\Traits\BelongsToTenant; -#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'ai_bypass_base_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', 'fpt_metadata'])] class JobPosition extends Model { use HasFactory, BelongsToTenant; @@ -18,6 +18,7 @@ class JobPosition extends Model 'requirements' => 'array', 'ai_bypass_base_prompt' => 'boolean', 'gemini_cache_expires_at' => 'datetime', + 'fpt_metadata' => 'array', ]; public function candidates(): HasMany diff --git a/database/migrations/2026_05_08_103904_add_fpt_metadata_to_job_positions_table.php b/database/migrations/2026_05_08_103904_add_fpt_metadata_to_job_positions_table.php new file mode 100644 index 0000000..859c17d --- /dev/null +++ b/database/migrations/2026_05_08_103904_add_fpt_metadata_to_job_positions_table.php @@ -0,0 +1,28 @@ +json('fpt_metadata')->nullable()->after('ai_bypass_base_prompt'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('job_positions', function (Blueprint $table) { + $table->dropColumn('fpt_metadata'); + }); + } +}; diff --git a/resources/js/Pages/Admin/JobPositions/Index.vue b/resources/js/Pages/Admin/JobPositions/Index.vue index 99f21a4..edf47bc 100644 --- a/resources/js/Pages/Admin/JobPositions/Index.vue +++ b/resources/js/Pages/Admin/JobPositions/Index.vue @@ -25,8 +25,11 @@ const form = useForm({ ai_bypass_base_prompt: false, tenant_id: '', quiz_ids: [], + fpt_metadata: null, }); +const isGeneratingFpt = ref(false); + const openModal = (position = null) => { editingPosition.value = position; if (position) { @@ -37,12 +40,36 @@ const openModal = (position = null) => { 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) : []; + form.fpt_metadata = position.fpt_metadata || null; } else { form.reset(); } showingModal.value = true; }; +const generateFpt = async () => { + if (!form.title || !form.description) { + alert("Veuillez remplir le titre et la description avant de générer."); + return; + } + isGeneratingFpt.value = true; + try { + const response = await axios.post(route('admin.job-positions.ai-fpt'), { + title: form.title, + description: form.description + }); + form.fpt_metadata = response.data; + if (response.data.fiche_synthese) { + form.description = response.data.fiche_synthese; + } + } catch (error) { + console.error(error); + alert("Une erreur est survenue lors de la génération IA."); + } finally { + isGeneratingFpt.value = false; + } +}; + const closeModal = () => { showingModal.value = false; form.reset(); @@ -215,6 +242,59 @@ const copyLink = (position) => { +
+
+

Assistant RH FPT (IA)

+

Génère automatiquement les mentions réglementaires et catégorise le poste (CGFP).

+
+ + + + + + + + + {{ isGeneratingFpt ? 'Génération...' : 'Structurer l\'offre' }} + +
+ +
+
+
Informations Statutaires
+
+
+ Catégorie + {{ form.fpt_metadata.infos_poste?.categorie }} +
+
+ Cadre d'emplois + {{ form.fpt_metadata.infos_poste?.cadre_emplois }} +
+
+ Grade Mini + {{ form.fpt_metadata.infos_poste?.grade_mini }} +
+
+ Grade Maxi + {{ form.fpt_metadata.infos_poste?.grade_maxi }} +
+
+
+ +
+
Conformité CGFP
+
+

Fondement : {{ form.fpt_metadata.conformite?.fondement_juridique_recrutement }}

+
    +
  • + {{ mention }} +
  • +
+
+
+
+

Utilisez cette zone pour donner des instructions spécifiques à l'IA (priorités, contexte entreprise, ton de l'analyse...)

diff --git a/resources/js/Pages/Public/Jobs/Index.vue b/resources/js/Pages/Public/Jobs/Index.vue index 5787833..a4e48c8 100644 --- a/resources/js/Pages/Public/Jobs/Index.vue +++ b/resources/js/Pages/Public/Jobs/Index.vue @@ -48,6 +48,9 @@ defineProps({ {{ job.tenant.name }} + + Catégorie {{ job.fpt_metadata.infos_poste.categorie }} + Temps plein

diff --git a/resources/js/Pages/Public/Jobs/Show.vue b/resources/js/Pages/Public/Jobs/Show.vue index 24299db..581905d 100644 --- a/resources/js/Pages/Public/Jobs/Show.vue +++ b/resources/js/Pages/Public/Jobs/Show.vue @@ -61,6 +61,9 @@ const submit = () => { Offre d'emploi + + Catégorie {{ jobPosition.fpt_metadata.infos_poste.categorie }} - {{ jobPosition.fpt_metadata.infos_poste.cadre_emplois }} + @@ -69,7 +72,8 @@ const submit = () => {

Description du poste

-
{{ jobPosition.description }}
+
+
{{ jobPosition.description }}
@@ -78,6 +82,26 @@ const submit = () => {
  • {{ req }}
  • + +
    +

    + + + + Informations Statutaires (FPT) +

    +
    +

    Grade(s) recherché(s) : {{ jobPosition.fpt_metadata.infos_poste?.grade_mini }} à {{ jobPosition.fpt_metadata.infos_poste?.grade_maxi }}

    +

    Fondement : {{ jobPosition.fpt_metadata.conformite?.fondement_juridique_recrutement }}

    + +
    + Mentions Légales : +
      +
    • {{ mention }}
    • +
    +
    +
    +
    diff --git a/routes/web.php b/routes/web.php index e2e4a87..a5fdcb6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,7 +6,10 @@ use Illuminate\Support\Facades\Route; use Inertia\Inertia; Route::get('/', function () { - $latestJobs = \App\Models\JobPosition::with('tenant')->orderBy('created_at', 'desc')->take(3)->get(); + $latestJobs = \App\Models\JobPosition::with('tenant')->orderBy('created_at', 'desc')->take(3)->get()->map(function($job) { + $job->description = strip_tags(\Illuminate\Support\Str::markdown($job->description)); + return $job; + }); return Inertia::render('Welcome', [ 'canLogin' => Route::has('login'), 'latestJobs' => $latestJobs, @@ -103,6 +106,7 @@ Route::middleware('auth')->group(function () { 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::post('/job-positions/ai-fpt', [\App\Http\Controllers\Admin\JobPositionAiHelperController::class, 'generate'])->name('job-positions.ai-fpt'); Route::resource('quizzes.questions', \App\Http\Controllers\QuestionController::class)->only(['store', 'update', 'destroy']); Route::resource('tenants', \App\Http\Controllers\TenantController::class)->only(['index', 'store', 'update', 'destroy']); Route::resource('users', \App\Http\Controllers\UserController::class)->except(['show', 'create', 'edit']);