Auto-deploy: 2026-08-05 16:17:53
This commit is contained in:
+395
-194
@@ -1,6 +1,10 @@
|
||||
<?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: *');
|
||||
@@ -24,282 +28,479 @@ use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Dotenv\Dotenv;
|
||||
|
||||
// Path to the .env file located outside the document root for security
|
||||
// Assuming script is in /home/user/htdocs/domain.com/cv/server/
|
||||
// and .env is in /home/user/
|
||||
// .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);
|
||||
|
||||
$data = json_decode($rawData, true) ?: [];
|
||||
$action = $data['action'] ?? 'generateText';
|
||||
|
||||
// Prioritize API key from .env over frontend payload
|
||||
$apiKey = $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: ($data['apiKey'] ?? '');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Missing apiKey. Please set GEMINI_API_KEY in .env or pass it in request."]);
|
||||
echo json_encode(["error" => "Missing apiKey. Set GEMINI_API_KEY in .env or pass it in the request."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Standardized on gemini-flash-lite-latest due to quota limits
|
||||
$model = "gemini-flash-lite-latest";
|
||||
$geminiUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key=" . $apiKey;
|
||||
// ── 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',
|
||||
];
|
||||
|
||||
// ==========================================
|
||||
// ACTION 1: Generate ATS PDF CV
|
||||
// ==========================================
|
||||
if ($action === 'generatePdf') {
|
||||
$jobDescription = $data['jobDescription'] ?? '';
|
||||
$template = $data['template'] ?? 'default'; // 'amman' or 'default'
|
||||
function geminiUrl(string $model, string $apiKey): string {
|
||||
return "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key=" . $apiKey;
|
||||
}
|
||||
|
||||
// --- Amman Market Prompt (Senior Backend Engineer & Technical Lead) ---
|
||||
if ($template === 'amman') {
|
||||
$prompt = "You are an expert ATS CV tailor for the Jordan/Amman local tech market. Read the following job description and generate tailored content.
|
||||
|
||||
STRICT INTEGRITY RULES:
|
||||
1. NEVER invent skills I do not have. My TRUE technical stack is: PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), PostgreSQL/PostGIS, Docker, Flutter, GetX, BLoC, WebSockets, OpenStreetMap.
|
||||
2. Do NOT add data science skills like TensorFlow, PyTorch, Scikit-learn, Hadoop, or MLOps.
|
||||
3. My title MUST be aligned with 'Senior Backend Engineer', 'Technical Lead', or 'Lead Backend Engineer'. NEVER use 'Solutions Architect', 'AI Developer', or 'CTO'.
|
||||
|
||||
Return ONLY a valid JSON object with EXACTLY three keys: 'headline', 'summary', and 'skills'.
|
||||
The 'headline' should be a clean, confident title based on my TRUE skills — matching Amman market expectations (Senior/Lead Backend Engineer).
|
||||
The 'summary' should open with a hook about building and scaling production systems in the MENA region, emphasizing delivery, cost optimization, and hands-on engineering. Connect my real achievements to the job requirements. Keep it professional and direct — NO academic fluff, NO 'I leverage my expertise' phrases.
|
||||
The 'skills' should be a comma-separated list of 10 highly relevant ATS keywords from MY ACTUAL SKILLS that match the job. Prioritize backend, API, database, and infrastructure skills over GIS.
|
||||
Do NOT use markdown blocks like ```json, just return raw JSON text.
|
||||
|
||||
Job Description:
|
||||
" . substr($jobDescription, 0, 4000);
|
||||
}
|
||||
// --- Default Prompt (Current — Enterprise/GCC) ---
|
||||
else {
|
||||
$prompt = "You are an expert ATS CV tailor. Read the following job description and generate tailored content for my CV to maximize my chances of getting an interview.
|
||||
|
||||
STRICT INTEGRITY RULES:
|
||||
1. NEVER invent skills I do not have. My TRUE technical stack is: PHP (Workerman), Node.js, NestJS, Python (FastAPI/Flask), PostgreSQL/PostGIS, Docker, Flutter, GetX, BLoC, WebSockets, OpenStreetMap.
|
||||
2. Do NOT add data science skills like TensorFlow, PyTorch, Scikit-learn, Hadoop, or MLOps.
|
||||
3. My title MUST be aligned with 'Solutions Architect', 'Senior Backend Engineer', or 'Senior Mobile Engineer'. NEVER title me 'Senior AI Developer'.
|
||||
|
||||
Return ONLY a valid JSON object with EXACTLY three keys: 'headline', 'summary', and 'skills'.
|
||||
The 'headline' should be a clean, confident title based on my TRUE skills.
|
||||
The 'summary' MUST open with exactly this hook: 'Built two production ride-hailing platforms from zero to thousands of users, on proprietary infrastructure, in high-complexity and emerging markets.' Then use the next 2 sentences to seamlessly tie my relevant TRUE skills to the job description requirements.
|
||||
The 'skills' should be a comma-separated list of 10 highly relevant ATS keywords from MY ACTUAL SKILLS that match the job.
|
||||
Do NOT use markdown blocks like ```json, just return raw JSON text.
|
||||
|
||||
Job Description:
|
||||
" . substr($jobDescription, 0, 4000);
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
"contents" => [["parts" => [["text" => $prompt]]]],
|
||||
"generationConfig" => ["temperature" => 0.2, "responseMimeType" => "application/json"]
|
||||
]);
|
||||
|
||||
$ch = curl_init($geminiUrl);
|
||||
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, $payload);
|
||||
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];
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
function loadProfile(): array {
|
||||
$path = __DIR__ . '/profile.json';
|
||||
if (!file_exists($path)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Gemini API Error", "details" => json_decode($response)]);
|
||||
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;
|
||||
}
|
||||
|
||||
$responseData = json_decode($response, true);
|
||||
$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 = str_replace(['```json', '```'], '', $aiText);
|
||||
$parsedJson = json_decode(trim($aiText), true);
|
||||
$aiText = trim(str_replace(['```json', '```'], '', $aiText));
|
||||
$ai = json_decode($aiText, true) ?: [];
|
||||
|
||||
$defaultHeadline = ($template === 'amman')
|
||||
? "Senior Backend Engineer & Technical Lead"
|
||||
: "Solutions Architect & Technical Leader";
|
||||
$headline = $parsedJson['headline'] ?? $defaultHeadline;
|
||||
$summary = $parsedJson['summary'] ?? "Experienced professional with a strong background in software engineering.";
|
||||
$skills = $parsedJson['skills'] ?? "Architecture, APIs, Cloud, Backend Systems, System Design";
|
||||
// ── 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);
|
||||
|
||||
$templateFile = ($template === 'amman')
|
||||
? __DIR__ . '/cv_template_amman.html'
|
||||
: __DIR__ . '/cv_template.html';
|
||||
$html = file_get_contents($templateFile);
|
||||
$html = str_replace('{{JOB_HEADLINE}}', htmlspecialchars($headline), $html);
|
||||
$html = str_replace('{{TAILORED_SUMMARY}}', htmlspecialchars($summary), $html);
|
||||
$html = str_replace('{{DYNAMIC_SKILLS}}', htmlspecialchars($skills), $html);
|
||||
$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']) . ' | ' . e($id['phone'])
|
||||
. ' | ' . e($id['email']) . '</div>'
|
||||
. '<div>' . e($id['linkedin']) . ' | ' . e($id['portfolio'])
|
||||
. ' | ' . 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);
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
|
||||
$pdfOutput = $dompdf->output();
|
||||
$rawJobTitle = $data['jobTitle'] ?? 'Job';
|
||||
$safeJobTitle = preg_replace('/[^a-zA-Z0-9\-_]/', '_', $rawJobTitle);
|
||||
$safeJobTitle = trim($safeJobTitle, '_');
|
||||
$fileName = "Hamza_Ayed - {$safeJobTitle}.pdf";
|
||||
|
||||
// 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
|
||||
"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 $e) {
|
||||
} catch (Exception $ex) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "PDF Generation Failed", "details" => $e->getMessage()]);
|
||||
echo json_encode(["error" => "PDF Generation Failed", "details" => $ex->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ACTION 2: Standard Proxy (Text generation)
|
||||
// ==========================================
|
||||
// ============================================================================
|
||||
// ACTION 2: Standard Proxy (text generation)
|
||||
// ============================================================================
|
||||
if ($action === 'generateText') {
|
||||
$prompt = $data['prompt'] ?? '';
|
||||
|
||||
$payload = json_encode([
|
||||
"contents" => [["parts" => [["text" => $prompt]]]],
|
||||
"generationConfig" => ["temperature" => 0.7, "maxOutputTokens" => 8192]
|
||||
]);
|
||||
|
||||
$ch = curl_init($geminiUrl);
|
||||
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, $payload);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
http_response_code($httpCode);
|
||||
echo $response;
|
||||
if (trim($prompt) === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "prompt is required."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Pass the exact Gemini response back to the extension
|
||||
echo $response;
|
||||
$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: Smart Comment Generator
|
||||
// ==========================================
|
||||
if ($action === 'generateComment') {
|
||||
$postText = substr($data['postText'] ?? '', 0, 3000);
|
||||
// ============================================================================
|
||||
// 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 (empty($postText)) {
|
||||
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/comment_prompt.txt';
|
||||
$promptFile = __DIR__ . '/prompts/' . $cfg['file'];
|
||||
if (!file_exists($promptFile)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Comment prompt file not found on server."]);
|
||||
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 = str_replace('{{POST_TEXT}}', $postText, $promptTemplate);
|
||||
|
||||
$payload = json_encode([
|
||||
"contents" => [["parts" => [["text" => $prompt]]]],
|
||||
"generationConfig" => ["temperature" => 0.75, "maxOutputTokens" => 256]
|
||||
$prompt = strtr($promptTemplate, [
|
||||
'{{POST_TEXT}}' => $postText,
|
||||
'{{PROFILE}}' => $profile['profileText'] ?? '',
|
||||
'{{VOICE}}' => $profile['voice']['tone'] ?? '',
|
||||
'{{BANNED}}' => implode(' / ', $profile['voice']['banned'] ?? []),
|
||||
]);
|
||||
|
||||
$ch = curl_init($geminiUrl);
|
||||
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, $payload);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Gemini API Error", "details" => json_decode($response)]);
|
||||
exit;
|
||||
$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';
|
||||
}
|
||||
|
||||
$responseData = json_decode($response, true);
|
||||
$commentText = trim($responseData['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||
|
||||
if (empty($commentText)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Empty comment from AI."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(["success" => true, "comment" => $commentText]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ACTION 4: Repurpose Post Generator
|
||||
// ==========================================
|
||||
if ($action === 'repurposePost') {
|
||||
$postText = substr($data['postText'] ?? '', 0, 3000);
|
||||
|
||||
if (empty($postText)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "postText is required."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$promptFile = __DIR__ . '/prompts/repurpose_prompt.txt';
|
||||
if (!file_exists($promptFile)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Repurpose prompt file not found on server."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$promptTemplate = file_get_contents($promptFile);
|
||||
$prompt = str_replace('{{POST_TEXT}}', $postText, $promptTemplate);
|
||||
|
||||
$payload = json_encode([
|
||||
$res = callGemini(geminiUrl($MODELS[$action], $apiKey), [
|
||||
"contents" => [["parts" => [["text" => $prompt]]]],
|
||||
"generationConfig" => ["temperature" => 0.75, "maxOutputTokens" => 800]
|
||||
"generationConfig" => $genConfig
|
||||
]);
|
||||
|
||||
$ch = curl_init($geminiUrl);
|
||||
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, $payload);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Gemini API Error", "details" => json_decode($response)]);
|
||||
if ($res['code'] !== 200) {
|
||||
http_response_code(502);
|
||||
echo json_encode(["error" => "Gemini API Error", "details" => json_decode($res['body'])]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$responseData = json_decode($response, true);
|
||||
$resultText = trim($responseData['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||
$responseData = json_decode($res['body'], true);
|
||||
$text = trim($responseData['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||
|
||||
if (empty($resultText)) {
|
||||
http_response_code(500);
|
||||
if ($text === '') {
|
||||
http_response_code(502);
|
||||
echo json_encode(["error" => "Empty result from AI."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(["success" => true, "result" => $resultText]);
|
||||
echo json_encode(["success" => true, $cfg['key'] => $text]);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Unknown action: " . $action]);
|
||||
|
||||
Reference in New Issue
Block a user