"Vendor folder not found. Please run 'composer install'."]); exit; } require_once $autoloadPath; use Dompdf\Dompdf; use Dompdf\Options; use Dotenv\Dotenv; // .env lives outside the document root. $envPath = realpath(__DIR__ . '/../../../..'); if ($envPath && file_exists($envPath . '/.env')) { $dotenv = Dotenv::createImmutable($envPath); $dotenv->load(); } $rawData = file_get_contents('php://input'); $data = json_decode($rawData, true) ?: []; $action = $data['action'] ?? 'generateText'; $apiKey = $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: ($data['apiKey'] ?? ''); if (empty($apiKey)) { http_response_code(400); echo json_encode(["error" => "Missing apiKey. Set GEMINI_API_KEY in .env or pass it in the request."]); exit; } // ── Model selection ───────────────────────────────────────────────────────── // CV tailoring is the one call where output quality directly determines whether // an application lands, so it gets the stronger model. Comments and post // rewrites are high-volume and low-stakes, so they stay on the cheap model. $MODELS = [ 'generatePdf' => $_ENV['GEMINI_MODEL_CV'] ?? 'gemini-flash-latest', 'generateText' => $_ENV['GEMINI_MODEL_TEXT'] ?? 'gemini-flash-latest', 'generateComment' => $_ENV['GEMINI_MODEL_LIGHT'] ?? 'gemini-flash-lite-latest', 'repurposePost' => $_ENV['GEMINI_MODEL_LIGHT'] ?? 'gemini-flash-lite-latest', ]; function geminiUrl(string $model, string $apiKey): string { return "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key=" . $apiKey; } function callGemini(string $url, array $payload): array { $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); curl_setopt($ch, CURLOPT_TIMEOUT, 120); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); return ['body' => $response, 'code' => $httpCode, 'error' => $curlErr]; } function loadProfile(): array { $path = __DIR__ . '/profile.json'; if (!file_exists($path)) { http_response_code(500); echo json_encode(["error" => "profile.json missing. Run 'node sync_profile.js' and redeploy."]); exit; } $profile = json_decode(file_get_contents($path), true); if (!is_array($profile)) { http_response_code(500); echo json_encode(["error" => "profile.json is not valid JSON."]); exit; } return $profile; } function e(?string $s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } // ============================================================================ // ACTION 1: Generate ATS PDF CV // ============================================================================ if ($action === 'generatePdf') { $jobDescription = trim($data['jobDescription'] ?? ''); $jobTitle = trim($data['jobTitle'] ?? ''); // 'amman' biases toward the local Jordanian market; 'default' toward // GCC / international / remote. It changes EMPHASIS ONLY — never facts. $market = ($data['template'] ?? 'default') === 'amman' ? 'amman' : 'default'; if ($jobDescription === '') { http_response_code(400); echo json_encode(["error" => "jobDescription is required to tailor the CV."]); exit; } $profile = loadProfile(); $id = $profile['identity']; $bounds = $profile['boundaries']; // ── Build the reference blocks the model must work from ──────────────── $skillsRef = ''; foreach ($profile['skills'] as $group => $items) { $skillsRef .= "- {$group}: " . implode(', ', $items) . "\n"; } $experienceRef = ''; foreach ($profile['experience'] as $ri => $role) { $experienceRef .= "ROLE INDEX {$ri}: {$role['title']} — {$role['company']} ({$role['start']} – {$role['end']}, {$role['location']})\n"; foreach ($role['bullets'] as $bi => $bullet) { $experienceRef .= " [{$ri}.{$bi}] {$bullet['text']}\n"; } } $marketGuidance = $market === 'amman' ? "TARGET MARKET: Jordan / Amman local tech market. Titles here are conservative — " . "prefer 'Senior Mobile Engineer', 'Lead Mobile Engineer' or 'Mobile Technical Lead' " . "over 'Architect' unless the posting itself uses 'Architect'. Emphasise hands-on " . "delivery, cost control and breadth. State availability for on-site/hybrid in Amman." : "TARGET MARKET: GCC / international / remote. 'Senior Mobile Architect' and " . "'Founding Engineer' land well here. Emphasise 0-to-1 platform ownership, scale " . "metrics, real-time systems and infrastructure cost reduction. Note openness to " . "relocation and remote work."; $prompt = << [["parts" => [["text" => $prompt]]]], "generationConfig" => [ "temperature" => 0.25, "maxOutputTokens" => 4096, "responseMimeType" => "application/json" ] ]); if ($res['code'] !== 200) { http_response_code(502); echo json_encode(["error" => "Gemini API Error", "details" => json_decode($res['body'])]); exit; } $responseData = json_decode($res['body'], true); $aiText = $responseData['candidates'][0]['content']['parts'][0]['text'] ?? '{}'; $aiText = trim(str_replace(['```json', '```'], '', $aiText)); $ai = json_decode($aiText, true) ?: []; // ── Server-side integrity guard ───────────────────────────────────────── // The model is instructed not to fabricate, but instruction-following is not // a guarantee. Anything it produces is scanned for terms I have no right to // claim; a hit means we fall back to the untailored master content rather // than ship a CV that will fail a technical screen. $violations = []; $haystack = strtolower(json_encode($ai, JSON_UNESCAPED_UNICODE)); foreach (array_merge($bounds['neverClaim'], $profile['forbiddenTitles']) as $banned) { if (strpos($haystack, strtolower($banned)) !== false) { $violations[] = $banned; } } $integrityClean = empty($violations); $headline = ($integrityClean && !empty($ai['headline'])) ? $ai['headline'] : $id['primaryTitle'] . ' — ' . $id['positioningLine']; $summary = ($integrityClean && !empty($ai['summary'])) ? $ai['summary'] : $profile['summary']; if ($integrityClean && !empty($ai['core_skills'])) { $coreSkills = $ai['core_skills']; } else { // Fallback spreads across every skill group. Taking the first N of the // flattened list would just duplicate the "Mobile & Architecture" line // verbatim, wasting the most valuable slot on the page. $picked = []; foreach ($profile['skills'] as $items) { $picked = array_merge($picked, array_slice($items, 0, 3)); } $coreSkills = implode(', ', array_slice($picked, 0, 10)); } // ── Render blocks ─────────────────────────────────────────────────────── // Contact details sit on separate block-level lines with explicit // separators. A bare
can collapse during text extraction and glue the // email to the LinkedIn URL, which loses the recruiter both. $contactBlock = '
' . e($id['location']) . '  |  ' . e($id['phone']) . '  |  ' . e($id['email']) . '
' . '
' . e($id['linkedin']) . '  |  ' . e($id['portfolio']) . '  |  ' . e($id['productSite']) . '
'; // Metrics are rendered as one linear list. The master CV shows them as a // four-column band, which looks good to a human and extracts as garbage in // an ATS — this is the parser-safe equivalent of the same information. $achievements = ''; $skillsBlock = ''; foreach ($profile['skills'] as $group => $items) { $skillsBlock .= '

' . e($group) . ': ' . e(implode(', ', $items)) . '

'; } // Index the AI's per-role selections so we can fall back role-by-role. $aiRoles = []; if ($integrityClean && !empty($ai['roles']) && is_array($ai['roles'])) { foreach ($ai['roles'] as $r) { if (isset($r['role_index'])) { $aiRoles[(int)$r['role_index']] = $r; } } } $experienceBlock = ''; foreach ($profile['experience'] as $ri => $role) { $experienceBlock .= '
'; $experienceBlock .= '

' . e($role['title']) . '

'; $experienceBlock .= '

' . e($role['company']) . ' — ' . e($role['location']) . '

'; $experienceBlock .= '

' . e($role['start']) . ' – ' . e($role['end']) . '

'; // Prefer the tailored rewrite; fall back to the master bullets whenever // the model returned nothing usable for this role. $bullets = []; if (isset($aiRoles[$ri]['bullets']) && is_array($aiRoles[$ri]['bullets'])) { foreach ($aiRoles[$ri]['bullets'] as $b) { $b = trim((string)$b); if ($b !== '') $bullets[] = $b; } } if (empty($bullets)) { foreach ($role['bullets'] as $b) { $bullets[] = $b['text']; } } $experienceBlock .= '
'; } $edu = $profile['education']; $educationBlock = '

' . e($edu['degree']) . ' — ' . e($edu['institution']) . '. ' . e($edu['note']) . '

'; $certBlock = ''; $availability = $market === 'amman' ? 'Based in Amman, Jordan. Available immediately for on-site, hybrid and remote roles.' : $id['availability']; $html = file_get_contents(__DIR__ . '/cv_template.html'); $html = strtr($html, [ '{{FULL_NAME}}' => e($id['fullName']), '{{JOB_HEADLINE}}' => e($headline), '{{CONTACT_BLOCK}}' => $contactBlock, '{{TAILORED_SUMMARY}}' => e($summary), '{{ACHIEVEMENTS_BLOCK}}' => $achievements, '{{DYNAMIC_SKILLS}}' => e($coreSkills), '{{SKILLS_BLOCK}}' => $skillsBlock, '{{EXPERIENCE_BLOCK}}' => $experienceBlock, '{{EDUCATION_BLOCK}}' => $educationBlock, '{{CERTIFICATIONS_BLOCK}}' => $certBlock, '{{LANGUAGES}}' => e($id['languages']), '{{LOCATION}}' => e($id['location']), '{{AVAILABILITY}}' => e($availability), ]); try { $options = new Options(); $options->set('isHtml5ParserEnabled', true); $options->set('defaultFont', 'Helvetica'); $dompdf = new Dompdf($options); $dompdf->loadHtml($html, 'UTF-8'); $dompdf->setPaper('A4', 'portrait'); $dompdf->render(); $pdfOutput = $dompdf->output(); // ATS parsers key on the filename too — "Name - Role.pdf" reads cleanly. $safeName = preg_replace('/[^a-zA-Z0-9\-_ ]/', '', $id['fullName']); $safeTitle = preg_replace('/[^a-zA-Z0-9\-_ ]/', '', $jobTitle ?: 'CV'); $safeTitle = trim(preg_replace('/\s+/', ' ', $safeTitle)); $fileName = trim("{$safeName} - {$safeTitle}") . '.pdf'; echo json_encode([ "success" => true, "pdf" => base64_encode($pdfOutput), "filename" => $fileName, "ats" => [ "score" => $ai['estimated_ats_score'] ?? null, "matched" => $ai['ats_keywords_matched'] ?? [], "missing" => $ai['ats_keywords_missing'] ?? [], "tailored" => $integrityClean && !empty($aiRoles), "violations" => $violations, ] ]); } catch (Exception $ex) { http_response_code(500); echo json_encode(["error" => "PDF Generation Failed", "details" => $ex->getMessage()]); } exit; } // ============================================================================ // ACTION 2: Standard Proxy (text generation) // ============================================================================ if ($action === 'generateText') { $prompt = $data['prompt'] ?? ''; if (trim($prompt) === '') { http_response_code(400); echo json_encode(["error" => "prompt is required."]); exit; } $res = callGemini(geminiUrl($MODELS['generateText'], $apiKey), [ "contents" => [["parts" => [["text" => $prompt]]]], "generationConfig" => ["temperature" => 0.6, "maxOutputTokens" => 8192] ]); if ($res['code'] !== 200) { http_response_code($res['code'] ?: 502); echo $res['body'] ?: json_encode(["error" => $res['error'] ?: 'Upstream failure']); exit; } echo $res['body']; exit; } // ============================================================================ // ACTION 3 & 4: Prompt-file backed generators (comment / repurpose) // ============================================================================ $fileBacked = [ 'generateComment' => ['file' => 'comment_prompt.txt', 'key' => 'comment', 'tokens' => 700, 'temp' => 0.8], 'repurposePost' => ['file' => 'repurpose_prompt.txt', 'key' => 'result', 'tokens' => 1200, 'temp' => 0.85], ]; if (isset($fileBacked[$action])) { $cfg = $fileBacked[$action]; $postText = mb_substr($data['postText'] ?? '', 0, 3000); if (trim($postText) === '') { http_response_code(400); echo json_encode(["error" => "postText is required."]); exit; } $promptFile = __DIR__ . '/prompts/' . $cfg['file']; if (!file_exists($promptFile)) { http_response_code(500); echo json_encode(["error" => "Prompt file {$cfg['file']} not found on server."]); exit; } // Give the writer prompts the real identity instead of a stale copy baked // into the text file. {{PROFILE}} is optional in the template. $profile = loadProfile(); $promptTemplate = file_get_contents($promptFile); $prompt = strtr($promptTemplate, [ '{{POST_TEXT}}' => $postText, '{{PROFILE}}' => $profile['profileText'] ?? '', '{{VOICE}}' => $profile['voice']['tone'] ?? '', '{{BANNED}}' => implode(' / ', $profile['voice']['banned'] ?? []), ]); $genConfig = [ "temperature" => $cfg['temp'], "maxOutputTokens" => $cfg['tokens'] ]; // The comment generator must return strict JSON; the repurposer returns prose. if ($action === 'generateComment') { $genConfig['responseMimeType'] = 'application/json'; } $res = callGemini(geminiUrl($MODELS[$action], $apiKey), [ "contents" => [["parts" => [["text" => $prompt]]]], "generationConfig" => $genConfig ]); if ($res['code'] !== 200) { http_response_code(502); echo json_encode(["error" => "Gemini API Error", "details" => json_decode($res['body'])]); exit; } $responseData = json_decode($res['body'], true); $text = trim($responseData['candidates'][0]['content']['parts'][0]['text'] ?? ''); if ($text === '') { http_response_code(502); echo json_encode(["error" => "Empty result from AI."]); exit; } echo json_encode(["success" => true, $cfg['key'] => $text]); exit; } http_response_code(400); echo json_encode(["error" => "Unknown action: " . $action]);