616 lines
26 KiB
PHP
616 lines
26 KiB
PHP
<?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,
|
||
"is_founding_or_startup_role": false
|
||
}
|
||
|
||
"is_founding_or_startup_role": true ONLY when the hiring company is an early-stage
|
||
startup, or the posting explicitly seeks a founding engineer / first engineer /
|
||
0-to-1 builder / someone to own a product end-to-end. false for established
|
||
companies, enterprises, banks, agencies and government. This controls whether my
|
||
CV presents a past role as "Founding Mobile Architect" or "Lead Mobile Engineer" —
|
||
identical work, framed for the reader.
|
||
|
||
CONSISTENCY RULE: when "is_founding_or_startup_role" is false, the words
|
||
"Founding" and "Founder" must NOT appear anywhere in "headline" or "summary"
|
||
either. Describe the same 0-to-1 ownership as "built and launched from zero" or
|
||
"owned end-to-end" instead. A CV that avoids "Founding" in the job title but
|
||
keeps it in the summary reads as careless.
|
||
|
||
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']) . ' | ' . e($id['phone'])
|
||
. ' | ' . e($id['email']) . '</div>'
|
||
. '<div>' . e($id['linkedin']) . ' | ' . e($id['github'])
|
||
. ' | ' . e($id['portfolio'])
|
||
. ' | ' . e($id['productSite']) . '</div>';
|
||
|
||
// "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']);
|
||
|
||
$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;
|
||
}
|
||
}
|
||
}
|
||
|
||
$edu = $profile['education'];
|
||
$educationBlock = '<p>' . e($edu['degree']) . ' — ' . e($edu['institution']) . '. ' . e($edu['note']) . '</p>';
|
||
|
||
$certBlock = '<p class="skill-line"><span class="label">Certifications:</span> '
|
||
. e(implode(' · ', $profile['certifications'])) . '</p>';
|
||
|
||
// 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 .= '<p class="skill-line"><span class="label">' . e($pkg['name']) . '</span> — '
|
||
. e($pkg['platform']) . ' · ' . e($pkg['url']) . '. ' . e($pkg['description']) . '</p>';
|
||
}
|
||
|
||
$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],
|
||
1 => ['font' => '10pt', 'lh' => '1.34', 'pad' => '26px 34px', 'caps' => [4, 4, 2], '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[] = '<strong>' . e($m['value']) . '</strong> ' . e($m['label']);
|
||
}
|
||
$achievements = '<p class="metrics">' . implode(' · ', $metricParts) . '</p>';
|
||
|
||
// 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 .= '<ul>';
|
||
foreach ($profile['achievementsAtAGlance'] as $a) {
|
||
$achievements .= '<li>' . e($a) . '</li>';
|
||
}
|
||
$achievements .= '</ul>';
|
||
}
|
||
|
||
$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 .= '<div class="job">';
|
||
$experienceBlock .= '<p class="job-title">' . e($roleTitle) . '</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>';
|
||
|
||
$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 .= '<ul>';
|
||
foreach ($bullets as $b) {
|
||
$experienceBlock .= '<li>' . e($b) . '</li>';
|
||
}
|
||
$experienceBlock .= '</ul></div>';
|
||
}
|
||
|
||
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.
|
||
$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,
|
||
"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]);
|