File manager - Edit - /var/www/ratemypay_dev/app/Services/JDWizardAIService.php
Back
<?php namespace App\Services; use App\Models\JobDescription; use App\Models\JobDescriptionSection; use App\Models\JobDescriptionValidationCheck; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use RuntimeException; class JDWizardAIService { // OpenAI endpoints private const RESPONSES_API = 'https://api.openai.com/v1/responses'; private const COMPLETIONS_API = 'https://api.openai.com/v1/chat/completions'; // Driver options const DRIVER_PROMPT_ID = 'prompt_id'; // Stored prompt via Responses API const DRIVER_ASSISTANT = 'assistant'; // Chat Completions with inline system prompt /** * Active driver: switch between 'prompt_id' and 'assistant'. */ private const DRIVER = self::DRIVER_ASSISTANT; // Prompt IDs (used when DRIVER = prompt_id) private const PROMPT_GENERATE_JD = 'pmpt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; private const PROMPT_REGENERATE_SECTION = 'pmpt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; private const PROMPT_VALIDATE_JD = 'pmpt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; private const PROMPT_SUGGEST_COMPETENCIES = 'pmpt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; // Model (used when DRIVER = assistant) private const ASSISTANT_MODEL = 'gpt-4o'; // System prompts (used when DRIVER = assistant) private const SYSTEM_PROMPT_GENERATE_JD = <<<PROMPT You are an expert HR professional and technical writer. Generate a complete, professional job description broken into the following five sections: role_summary, key_responsibilities, required_skills, qualifications, core_competencies. Return ONLY valid JSON with these exact keys: { "role_summary": "A single paragraph of plain prose (no bullets).", "key_responsibilities": "- Responsibility one\n- Responsibility two\n- Responsibility three", "required_skills": "- Skill one\n- Skill two\n- Skill three", "qualifications": "- Qualification one\n- Qualification two\n- Qualification three", "core_competencies": "- Competency one\n- Competency two\n- Competency three" } IMPORTANT FORMATTING RULES: - role_summary: plain paragraph text only, no bullets. - key_responsibilities, required_skills, qualifications, core_competencies: each item on its own line, prefixed with "- " (hyphen space). No nested bullets. - Every value must be a plain JSON string (no arrays, no markdown fences). - Do NOT include markdown fences or any text outside the JSON object. PROMPT; private const SYSTEM_PROMPT_REGENERATE_SECTION = <<<PROMPT You are an expert HR professional and technical writer. You will receive a job context, the target section to regenerate, existing sections for context, and an optional instruction from the user. Regenerate ONLY the target section and return its raw text content. IMPORTANT FORMATTING RULES: - If the section is "role_summary": return plain paragraph prose only. No bullets. - For all other sections (key_responsibilities, required_skills, qualifications, core_competencies): return each item on its own line, prefixed with "- " (hyphen space). No nested bullets. - Do NOT wrap in JSON, markdown fences, or add any heading or label. - Return plain text only. PROMPT; private const SYSTEM_PROMPT_VALIDATE_JD = <<<PROMPT You are an expert HR professional reviewing a job description for completeness, quality and compliance. You will receive the full job context and a list of checks to run. Each check includes a check_key and section_type. Evaluate every check and return ONLY valid JSON in this exact shape: { "passed": true|false, "score": <integer 0-100>, "checks": [ { "check_key": "<key>", "section_type": "<section>", "status": "pass"|"fail"|"warning", "score": <integer 0-100>, "passed": true|false, "message": "<explanation>" } ], "suggestions": ["...", "..."] } "passed" at the top level is true only when ALL individual checks have passed. Score 80+ means publish-ready. Do NOT include markdown fences or any text outside the JSON object. PROMPT; private const SYSTEM_PROMPT_SUGGEST_COMPETENCIES = <<<PROMPT You are an expert HR professional. Suggest a list of core competencies for the given job title, department and level. Return ONLY valid JSON in this exact format: { "competencies": ["Competency 1", "Competency 2", ...] } Provide between 6 and 12 competencies. Be specific and professional. Do NOT include markdown fences or any text outside the JSON object. PROMPT; private int $timeoutSeconds = 60; /** * Generate all five core JD sections. * * Returns an array keyed by section_type: * ['role_summary' => '...', 'key_responsibilities' => '...', ...] * * @throws RuntimeException */ public function generateJD(JobDescription $jobDescription): array { $payload = $this->buildJDPayload($jobDescription); Log::info('[JDWizardAIService] generateJD called', ['job_description_id' => $jobDescription->id]); $response = $this->call( promptId: self::PROMPT_GENERATE_JD, systemPrompt: self::SYSTEM_PROMPT_GENERATE_JD, payload: $payload, ); $sections = $this->parseJsonResponse($response); foreach (JobDescriptionSection::SECTION_TYPES as $type) { if (empty($sections[$type])) { throw new RuntimeException("AI response missing section: {$type}"); } // Guarantee every section value is a plain string. // The AI sometimes returns bullet-point arrays instead of strings. if (is_array($sections[$type])) { $sections[$type] = implode("\n", array_map('strval', $sections[$type])); } else { $sections[$type] = (string) $sections[$type]; } } Log::info('[JDWizardAIService] generateJD completed', ['job_description_id' => $jobDescription->id]); return $sections; } /** * Regenerate a single section with an optional user instruction. * * @throws RuntimeException */ public function regenerateSection( JobDescription $jobDescription, string $sectionType, string $instruction = '' ): string { $this->assertValidSectionType($sectionType); $jobDescription->loadMissing('sections'); $payload = $this->buildJDPayload($jobDescription); $payload['target_section'] = $sectionType; $payload['instruction'] = $instruction; $payload['existing_sections'] = $jobDescription->sections ->where('is_core', true) ->mapWithKeys(fn ($s) => [$s->section_type => $s->content]) ->toArray(); Log::info('[JDWizardAIService] regenerateSection called', [ 'job_description_id' => $jobDescription->id, 'section_type' => $sectionType, ]); $content = $this->call( promptId: self::PROMPT_REGENERATE_SECTION, systemPrompt: self::SYSTEM_PROMPT_REGENERATE_SECTION, payload: $payload, ); Log::info('[JDWizardAIService] regenerateSection completed', [ 'job_description_id' => $jobDescription->id, 'section_type' => $sectionType, ]); // Normalise: the AI could return an array even for a single-section response if (is_array($content)) { $content = implode("\n", array_map('strval', $content)); } return trim((string) $content); } /** * Run all 14 validation and alignment checks against the JD. * * @throws RuntimeException */ public function validateJD(JobDescription $jobDescription): array { $jobDescription->loadMissing('sections'); $payload = [ 'job_title' => $jobDescription->job_title, 'department' => $jobDescription->department, 'level' => $jobDescription->level, 'employment_type' => $jobDescription->employment_type, 'location' => $jobDescription->location, 'sections' => $jobDescription->sections ->where('is_core', true) ->mapWithKeys(fn ($s) => [$s->section_type => $s->content]) ->toArray(), 'checks_required' => array_map( fn ($checkKey) => [ 'check_key' => $checkKey, 'section_type' => JobDescriptionSection::sectionTypeForCheck($checkKey), ], JobDescriptionValidationCheck::CHECK_KEYS ), ]; Log::info('[JDWizardAIService] validateJD called', ['job_description_id' => $jobDescription->id]); $response = $this->call( promptId: self::PROMPT_VALIDATE_JD, systemPrompt: self::SYSTEM_PROMPT_VALIDATE_JD, payload: $payload, ); $result = $this->parseJsonResponse($response); if (!isset($result['checks']) || !is_array($result['checks'])) { throw new RuntimeException('AI validation response missing "checks" array.'); } Log::info('[JDWizardAIService] validateJD completed', [ 'job_description_id' => $jobDescription->id, 'score' => $result['score'] ?? null, 'passed' => $result['passed'] ?? null, ]); return $result; } /** * Suggest competencies for a given job title / level. * Returns a flat array of competency strings. * * @throws RuntimeException */ public function suggestCompetencies(JobDescription $jobDescription): array { $payload = [ 'job_title' => $jobDescription->job_title, 'department' => $jobDescription->department, 'level' => $jobDescription->level, 'employment_type' => $jobDescription->employment_type, ]; Log::info('[JDWizardAIService] suggestCompetencies called', ['job_description_id' => $jobDescription->id]); $response = $this->call( promptId: self::PROMPT_SUGGEST_COMPETENCIES, systemPrompt: self::SYSTEM_PROMPT_SUGGEST_COMPETENCIES, payload: $payload, ); $parsed = $this->parseJsonResponse($response); $competencies = $parsed['competencies'] ?? []; Log::info('[JDWizardAIService] suggestCompetencies completed', [ 'job_description_id' => $jobDescription->id, 'count' => count($competencies), ]); return $competencies; } /** * Route the AI call to the correct driver based on DRIVER constant. * * @param string $promptId Used by the prompt_id driver (Responses API) * @param string $systemPrompt Used by the assistant driver (Chat Completions) * @param array $payload Dynamic data sent as input / user message * @throws RuntimeException */ private function call(string $promptId, string $systemPrompt, array $payload): string { return match (self::DRIVER) { self::DRIVER_PROMPT_ID => $this->callWithPromptId($promptId, $payload), self::DRIVER_ASSISTANT => $this->callWithAssistant($systemPrompt, $payload), default => throw new RuntimeException('Unknown JDWizardAIService driver: ' . self::DRIVER), }; } /** * Call the OpenAI Responses API using a stored prompt ID. * Mirrors the pattern used in SalaryMarketComparisonService. * * @throws RuntimeException */ private function callWithPromptId(string $promptId, array $payload): string { return $this->withRetry(function () use ($promptId, $payload) { $response = Http::withToken(config('services.openai.key')) ->timeout($this->timeoutSeconds) ->post(self::RESPONSES_API, [ 'prompt' => ['id' => $promptId], 'input' => json_encode($payload), ]); if ($response->successful()) { $content = collect($response->json('output')) ->flatMap(fn ($item) => $item['content'] ?? []) ->firstWhere('type', 'output_text')['text'] ?? null; if (empty($content)) { throw new RuntimeException('AI returned an empty output_text block.'); } return $content; } throw new RuntimeException( "AI provider returned HTTP {$response->status()}: {$response->body()}" ); }, context: ['driver' => 'prompt_id', 'prompt_id' => $promptId]); } /** * Call the OpenAI Chat Completions API with an inline system prompt. * The payload is JSON-encoded and sent as the user message. * * @throws RuntimeException */ private function callWithAssistant(string $systemPrompt, array $payload): string { return $this->withRetry(function () use ($systemPrompt, $payload) { $response = Http::withToken(config('services.openai.key')) ->timeout($this->timeoutSeconds) ->post(self::COMPLETIONS_API, [ 'model' => self::ASSISTANT_MODEL, 'temperature' => 0.7, 'messages' => [ ['role' => 'system', 'content' => $systemPrompt], ['role' => 'user', 'content' => json_encode($payload)], ], ]); if ($response->successful()) { $content = $response->json('choices.0.message.content'); if (empty($content)) { throw new RuntimeException('AI returned an empty message content.'); } return $content; } throw new RuntimeException( "AI provider returned HTTP {$response->status()}: {$response->body()}" ); }, context: ['driver' => 'assistant', 'model' => self::ASSISTANT_MODEL]); } /** * Execute a callable with up to 3 attempts and exponential back-off. * Both drivers go through this so retry logic stays in one place. * * @throws RuntimeException */ private function withRetry(callable $fn, array $context = []): string { $maxRetries = 3; $lastException = null; for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { try { return $fn(); } catch (RuntimeException $e) { $lastException = $e; Log::warning("[JDWizardAIService] Attempt {$attempt} failed", array_merge($context, [ 'error' => $e->getMessage(), ])); if ($attempt < $maxRetries) { $sleepSeconds = $attempt * 2; // 2s, then 4s Log::info("[JDWizardAIService] Retrying in {$sleepSeconds}s (attempt {$attempt}/{$maxRetries})"); sleep($sleepSeconds); } } } Log::error('[JDWizardAIService] All retry attempts exhausted', array_merge($context, [ 'error' => $lastException?->getMessage(), ])); throw new RuntimeException( "AI service failed after {$maxRetries} attempts: " . $lastException?->getMessage() ); } /** * Parse a JSON string from the AI, stripping any accidental markdown fences. * * @throws RuntimeException */ private function parseJsonResponse(string $raw): array { // Strip markdown code fences the model sometimes wraps around JSON $clean = preg_replace('/^```(?:json)?\s*/i', '', trim($raw)); $clean = preg_replace('/\s*```$/', '', $clean); $clean = trim($clean); // Sanitise literal newlines inside JSON string values $sanitised = ''; $inString = false; $escaped = false; $len = strlen($clean); for ($i = 0; $i < $len; $i++) { $ch = $clean[$i]; if ($escaped) { $sanitised .= $ch; $escaped = false; continue; } if ($ch === '\\') { $sanitised .= $ch; $escaped = true; continue; } if ($ch === '"') { $inString = !$inString; $sanitised .= $ch; continue; } // Inside a string: replace bare CR/LF with the \n escape sequence if ($inString && ($ch === "\n" || $ch === "\r")) { $sanitised .= '\n'; // If it is a CRLF pair, swallow the \r so we don't double-escape if ($ch === "\r" && isset($clean[$i + 1]) && $clean[$i + 1] === "\n") { $i++; } continue; } $sanitised .= $ch; } $decoded = json_decode($sanitised, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::error('[JDWizardAIService] JSON parse failure', [ 'raw' => $raw, 'error' => json_last_error_msg(), ]); throw new RuntimeException('Failed to parse AI response as JSON: ' . json_last_error_msg()); } return $decoded; } /** * Common job payload for generate / regenerate calls. */ private function buildJDPayload(JobDescription $jobDescription): array { return [ 'job_title' => $jobDescription->job_title, 'department' => $jobDescription->department, 'level' => $jobDescription->level, 'location' => $jobDescription->location, 'employment_type' => $jobDescription->employment_type, ]; } /** * Guard against unsupported section types. * * @throws RuntimeException */ private function assertValidSectionType(string $type): void { if (!in_array($type, JobDescriptionSection::SECTION_TYPES, true)) { throw new RuntimeException( "Invalid section type: {$type}. Must be one of: " . implode(', ', JobDescriptionSection::SECTION_TYPES) ); } } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.30 | Generation time: 0.11 |
proxy
|
phpinfo
|
Settings