Files
cv/server/generate_cv.php
T

664 lines
29 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-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 = <<<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.
PEOPLE-MANAGEMENT RULE: both platforms were built solo. NEVER write "led a team",
"managed engineers", "mentored juniors", "grew the team" or any headcount claim —
there were no direct reports. Express leadership as sole ownership instead:
"solely architected", "owned end-to-end", "sole technical decision-maker",
"aligned architecture with business stakeholders". If the posting is an
Engineering Manager role requiring people management, say so in
"ats_keywords_missing" rather than manufacturing the experience.
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']) . ' &nbsp;|&nbsp; ' . e($id['phone'])
. ' &nbsp;|&nbsp; ' . e($id['email']) . '</div>'
. '<div>' . e($id['linkedin']) . ' &nbsp;|&nbsp; ' . e($id['github'])
. ' &nbsp;|&nbsp; ' . e($id['portfolio'])
. ' &nbsp;|&nbsp; ' . 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']);
// "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 .= '<p class="skill-line"><span class="label">' . e($group) . ':</span> '
. e(implode(', ', $remaining)) . '</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],
// 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[] = '<strong>' . e($m['lead']) . '</strong> ' . e($m['rest']);
}
$achievements = '<p class="metrics">' . implode(' &nbsp;·&nbsp; ', $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,
// 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
);
if ($rawTitle === '' || $looksLikeUiText) {
$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]);