Files
cv/server/generate_cv.php
T

507 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ============================================================================
// Dynamic ATS-Optimized CV Generator & AI Proxy (Backend)
// ----------------------------------------------------------------------------
// All CV facts come from profile.json, which is generated from profile_data.js
// (run `node sync_profile.js` after editing the profile). Nothing in this file
// hardcodes a job title, a metric, a date or a contact detail.
// ============================================================================
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
$autoloadPath = __DIR__ . '/vendor/autoload.php';
if (!file_exists($autoloadPath)) {
http_response_code(500);
echo json_encode(["error" => "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 = <<<PROMPT
You are an expert ATS résumé tailor. Rewrite my CV content so it scores as highly
as possible against the job description below WITHOUT stating anything untrue.
{$marketGuidance}
=== ATS METHODOLOGY — APPLY ALL OF IT ===
1. EXACT-TERM MIRRORING: ATS matching is literal. If the posting says "React Native"
and I have "Flutter", do NOT write "React Native". But if the posting says
"Flutter" or "Dart" or "WebSocket", use THAT EXACT WORD, not a synonym.
2. DUAL FORM FOR ACRONYMS: e.g. "Continuous Integration / Continuous Deployment (CI/CD)".
3. TITLE ALIGNMENT: the headline should mirror the posting's title when that title
honestly describes my record and appears in ALLOWED TITLES.
4. KEYWORD FREQUENCY: a critical keyword should appear 2-3 times across summary,
skills and at least one experience bullet — always inside a real sentence.
5. BULLET FORMULA: "Accomplished [X] as measured by [Y] by doing [Z]." Strong
past-tense verb first. Outcome before task. A number beats an adjective.
6. HARD SKILLS OVER SOFT SKILLS.
=== STRICT INTEGRITY RULES — VIOLATION INVALIDATES THE OUTPUT ===
A. NEVER invent a skill, employer, metric, date or certification.
B. ALLOWED TITLES ONLY: {allowed}
C. FORBIDDEN TITLES: {forbidden}
D. NEVER CLAIM (no production experience): {never}
E. Every number must be copied exactly from MY REAL DATA. No rounding, no new numbers.
F. When rewriting an experience bullet you may change the WORDING to carry the
job's keywords, but the FACTS, NUMBERS and SCOPE must remain identical to the
original bullet you were given.
=== MY REAL DATA ===
NAME: {$id['fullName']}
CURRENT POSITIONING: {$id['primaryTitle']} — {$id['positioningLine']}
MASTER SUMMARY: {$profile['summary']}
SKILLS (you may only select from these):
{$skillsRef}
EXPERIENCE (you may only rewrite these exact bullets):
{$experienceRef}
HONEST LIMITS: {limits}
=== TARGET JOB ===
Title: {$jobTitle}
Description:
{jobdesc}
=== RETURN FORMAT ===
Return ONLY raw JSON (no markdown fences) with EXACTLY these keys:
{
"headline": "One line. Title mirroring the job + 3-4 exact hard skills I truly have.",
"summary": "3-4 sentences. Opens with what I built and the numbers. Mirrors the job's exact terminology.",
"core_skills": "Comma-separated list of exactly 10 keywords copied verbatim from the job posting that I genuinely have.",
"roles": [
{
"role_index": 0,
"bullet_ids": ["0.0", "0.2"],
"bullets": ["Rewritten text of bullet 0.0", "Rewritten text of bullet 0.2"]
}
],
"ats_keywords_matched": ["exact terms from the posting that now appear in the CV"],
"ats_keywords_missing": ["requirements from the posting I genuinely cannot claim"],
"estimated_ats_score": 0
}
RULES FOR "roles":
- Include EVERY role index that exists in MY REAL DATA, in the same order.
- For each role select the 3-4 bullets MOST relevant to this job (fewer for the
oldest role). "bullets" must align 1:1 with "bullet_ids".
- Each rewritten bullet must stay factually identical to its original.
PROMPT;
$prompt = str_replace(
['{allowed}', '{forbidden}', '{never}', '{limits}', '{jobdesc}'],
[
implode(', ', $profile['allowedTitles']),
implode(', ', $profile['forbiddenTitles']),
implode(', ', $bounds['neverClaim']),
implode(' | ', $bounds['limitedIn']),
mb_substr($jobDescription, 0, 6000)
],
$prompt
);
$res = callGemini(geminiUrl($MODELS['generatePdf'], $apiKey), [
"contents" => [["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 <br> can collapse during text extraction and glue the
// email to the LinkedIn URL, which loses the recruiter both.
$contactBlock =
'<div>' . e($id['location']) . ' &nbsp;|&nbsp; ' . e($id['phone'])
. ' &nbsp;|&nbsp; ' . e($id['email']) . '</div>'
. '<div>' . e($id['linkedin']) . ' &nbsp;|&nbsp; ' . e($id['portfolio'])
. ' &nbsp;|&nbsp; ' . e($id['productSite']) . '</div>';
// 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 = '<ul>';
foreach ($profile['headlineMetrics'] as $m) {
$achievements .= '<li><strong>' . e($m['value']) . '</strong> — ' . e($m['label']) . '</li>';
}
foreach ($profile['achievementsAtAGlance'] as $a) {
$achievements .= '<li>' . e($a) . '</li>';
}
$achievements .= '</ul>';
$skillsBlock = '';
foreach ($profile['skills'] as $group => $items) {
$skillsBlock .= '<p class="skill-line"><span class="label">' . e($group) . ':</span> '
. e(implode(', ', $items)) . '</p>';
}
// 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 .= '<div class="job">';
$experienceBlock .= '<p class="job-title">' . e($role['title']) . '</p>';
$experienceBlock .= '<p class="job-company">' . e($role['company']) . ' — ' . e($role['location']) . '</p>';
$experienceBlock .= '<p class="job-dates">' . e($role['start']) . ' – ' . e($role['end']) . '</p>';
// 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 .= '<ul>';
foreach ($bullets as $b) {
$experienceBlock .= '<li>' . e($b) . '</li>';
}
$experienceBlock .= '</ul></div>';
}
$edu = $profile['education'];
$educationBlock = '<p>' . e($edu['degree']) . ' — ' . e($edu['institution']) . '. ' . e($edu['note']) . '</p>';
$certBlock = '<ul>';
foreach ($profile['certifications'] as $c) {
$certBlock .= '<li>' . e($c) . '</li>';
}
$certBlock .= '</ul>';
$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]);