"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-lite-latest', 'generateText' => $_ENV['GEMINI_MODEL_TEXT'] ?? 'gemini-flash-lite-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['github']) . '  |  ' . e($id['portfolio']) . '  |  ' . e($id['productSite']) . '
'; // "Founding" reads as an asset at a startup and as flight risk at an // established company. The model classifies the employer; the facts under // the title are identical either way. $isFoundingContext = $integrityClean && !empty($ai['is_founding_or_startup_role']); // "Core Focus" carries the job's exact wording and earns its place at the // top. Repeating those same terms verbatim in the group lines below does // NOT help ATS scoring — parsers score the document, and within-section // repetition adds nothing — while it visibly reads as padding. So the group // lines render as the remaining inventory, with the promoted terms removed. $coreTokens = array_filter(array_map( fn($s) => strtolower(trim($s)), explode(',', $coreSkills) )); $skillsBlock = ''; foreach ($profile['skills'] as $group => $items) { $remaining = array_values(array_filter( $items, fn($item) => !in_array(strtolower(trim($item)), $coreTokens, true) )); // A group emptied entirely by the promotion is already fully represented // in Core Focus; printing an empty heading would just look broken. if (empty($remaining)) continue; $skillsBlock .= '

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

'; } // 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; } } } $edu = $profile['education']; $educationBlock = '

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

'; $certBlock = '

Certifications: ' . e(implode(' · ', $profile['certifications'])) . '

'; // Package URLs are printed as plain text, not anchor labels — many parsers // read the visible text rather than the href, and a recruiter needs to be // able to retype these. $openSourceBlock = ''; foreach (($profile['openSource'] ?? []) as $pkg) { $openSourceBlock .= '

' . e($pkg['name']) . ' — ' . e($pkg['platform']) . ' · ' . e($pkg['url']) . '. ' . e($pkg['description']) . '

'; } $availability = $market === 'amman' ? 'Based in Amman, Jordan. Available immediately for on-site, hybrid and remote roles.' : $id['availability']; // ── Adaptive compaction ───────────────────────────────────────────────── // A three-page CV for a mobile role reads as an inability to prioritise, // and recruiters routinely stop at page two. Rather than guess at font // sizes, render the document, ask Dompdf how many pages it actually // produced, and tighten until it fits. Each level drops the least valuable // content first — never a metric, never a role, never a date. $DENSITY = [ // bulletCaps are per role, oldest role last. 0 => ['font' => '10.5pt', 'lh' => '1.42', 'pad' => '34px 40px', 'caps' => [4, 4, 3], 'glance' => true], // Level 1 buys its space from the "at a glance" block and from tighter // type — NOT from a third bullet on the oldest role. Dropping that // bullet was measured to be unnecessary, and it strands the release // management skill in Technical Skills with no evidence behind it. 1 => ['font' => '10pt', 'lh' => '1.34', 'pad' => '26px 34px', 'caps' => [4, 4, 3], 'glance' => false], 2 => ['font' => '9.5pt', 'lh' => '1.28', 'pad' => '22px 30px', 'caps' => [3, 3, 2], 'glance' => false], ]; $TARGET_PAGES = 2; $template = file_get_contents(__DIR__ . '/cv_template.html'); $buildHtml = function (array $d) use ( $profile, $id, $template, $aiRoles, $headline, $summary, $coreSkills, $contactBlock, $skillsBlock, $educationBlock, $certBlock, $availability, $openSourceBlock, $isFoundingContext ) { // Headline metrics collapse to a single dense line. As four separate // bullets they cost four lines to say what one line says. $metricParts = []; foreach ($profile['headlineMetrics'] as $m) { $metricParts[] = '' . e($m['lead']) . ' ' . e($m['rest']); } $achievements = '

' . implode('  ·  ', $metricParts) . '

'; // The "at a glance" paragraphs restate numbers that already appear in // the experience bullets, so they are the first thing to go. if ($d['glance']) { $achievements .= ''; } $experienceBlock = ''; foreach ($profile['experience'] as $ri => $role) { $roleTitle = $role['title']; if (!empty($role['titleVariants'])) { $roleTitle = $isFoundingContext ? ($role['titleVariants']['founding'] ?? $roleTitle) : ($role['titleVariants']['standard'] ?? $roleTitle); } $experienceBlock .= '
'; $experienceBlock .= '

' . e($roleTitle) . '

'; $experienceBlock .= '

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

'; $experienceBlock .= '

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

'; $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; } } $fromAi = !empty($bullets); if (!$fromAi) { foreach ($role['bullets'] as $b) { $bullets[] = $b['text']; } } // The prompt asks for 3-4 bullets per role, but instruction-following // is not a guarantee — the model frequently returns every bullet it // was given. Enforce the cap here so page count never depends on it. $cap = $d['caps'][$ri] ?? 2; $bullets = array_slice($bullets, 0, $cap); // The model also errs the other way, returning fewer bullets than // the layout has room for. An empty slot is wasted page space, and // worse, it can strand a skill in the Technical Skills line with no // supporting evidence anywhere in the experience. Top up from the // master bullets the model did not pick, keeping its ordering first. // Which master bullets did the model already use? Match on the // "roleIndex.bulletIndex" ids it returns, not on the text: it // rewrites wording to carry the job's keywords, so comparing strings // would treat a rewritten bullet as a new one and print the same // fact twice. Without usable ids we cannot tell what was used, so we // leave the role as-is rather than risk a duplicate. $usedIdx = []; foreach (($aiRoles[$ri]['bullet_ids'] ?? []) as $bid) { $parts = explode('.', (string)$bid); if (count($parts) === 2) $usedIdx[] = (int)$parts[1]; } if ($fromAi && $usedIdx && count($bullets) < $cap) { foreach ($role['bullets'] as $bi => $master) { if (count($bullets) >= $cap) break; if (!in_array($bi, $usedIdx, true)) { $bullets[] = $master['text']; } } } $experienceBlock .= '
'; } return strtr($template, [ '{{FONT_SIZE}}' => $d['font'], '{{LINE_HEIGHT}}' => $d['lh'], '{{PAGE_PADDING}}' => $d['pad'], '{{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, '{{OPENSOURCE_BLOCK}}' => $openSourceBlock, '{{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'); $pdfOutput = null; $pageCount = null; $densityUsed = 0; foreach ($DENSITY as $level => $d) { $dompdf = new Dompdf($options); $dompdf->loadHtml($buildHtml($d), 'UTF-8'); $dompdf->setPaper('A4', 'portrait'); $dompdf->render(); $pdfOutput = $dompdf->output(); $pageCount = $dompdf->getCanvas()->get_page_count(); $densityUsed = $level; if ($pageCount <= $TARGET_PAGES) break; } // ATS parsers key on the filename too — "Name - Role.pdf" reads cleanly, // and a recruiter sees it before opening anything. // // Scraping a job title out of LinkedIn's DOM is fragile, and a bad // scrape has shipped filenames like "Hamza Ayed - 0 notifications.pdf" // and "Hamza Ayed - Are these results helpful.pdf". The client is fixed, // but the server must not depend on the client being right: reject // anything that does not look like a job title and fall back to the // profile's own positioning instead. $rawTitle = trim($jobTitle); $looksLikeUiText = preg_match( '/helpful|result|feedback|notification|message|your profile|match|tips|about the job|^\d/i', $rawTitle ); // Real job titles carry a role noun. Requiring one rejects stray UI // strings without hardcoding a list of every possible title. $hasRoleNoun = preg_match( '/engineer|developer|architect|lead|manager|consultant|specialist|analyst|designer|' . 'programmer|scientist|director|head of|principal|staff|senior|founding|cto|technologist/i', $rawTitle ); if ($rawTitle === '' || $looksLikeUiText || !$hasRoleNoun) { $rawTitle = $id['primaryTitle']; } $safeName = preg_replace('/[^\p{L}0-9\-_ ]/u', '', $id['fullName']); $safeTitle = preg_replace('/[^\p{L}0-9\-_ ()\/]/u', '', $rawTitle); $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, "pages" => $pageCount, "density" => $densityUsed, ] ]); } 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]);