Auto-deploy: 2026-08-05 16:46:02
This commit is contained in:
@@ -592,8 +592,10 @@
|
|||||||
// the server had to fall back because the model tried to fabricate.
|
// the server had to fall back because the model tried to fabricate.
|
||||||
const ats = response.data.ats || {};
|
const ats = response.data.ats || {};
|
||||||
let msg = '✅ PDF downloaded';
|
let msg = '✅ PDF downloaded';
|
||||||
|
if (ats.pages) msg += ` — ${ats.pages}p`;
|
||||||
if (ats.score) msg += ` — est. ATS match ${ats.score}%`;
|
if (ats.score) msg += ` — est. ATS match ${ats.score}%`;
|
||||||
if (ats.tailored === false) msg += ' (untailored fallback)';
|
if (ats.tailored === false) msg += ' (untailored fallback)';
|
||||||
|
if (ats.pages > 2) msg += ' ⚠️ over 2 pages';
|
||||||
if (ats.violations && ats.violations.length) {
|
if (ats.violations && ats.violations.length) {
|
||||||
msg += ` ⚠️ blocked fabricated claims: ${ats.violations.join(', ')}`;
|
msg += ` ⚠️ blocked fabricated claims: ${ats.violations.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -21,11 +21,13 @@
|
|||||||
font-feature-settings: "liga" 0, "clig" 0;
|
font-feature-settings: "liga" 0, "clig" 0;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
text-rendering: geometricPrecision;
|
text-rendering: geometricPrecision;
|
||||||
font-size: 10.5pt;
|
/* Density values are injected by generate_cv.php, which renders, measures
|
||||||
|
the real page count, and tightens these until the CV fits two pages. */
|
||||||
|
font-size: {{FONT_SIZE}};
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
line-height: 1.42;
|
line-height: {{LINE_HEIGHT}};
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 34px 40px;
|
padding: {{PAGE_PADDING}};
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -78,6 +80,11 @@
|
|||||||
|
|
||||||
.skill-line { margin: 0 0 4px 0; }
|
.skill-line { margin: 0 0 4px 0; }
|
||||||
.label { font-weight: bold; }
|
.label { font-weight: bold; }
|
||||||
|
|
||||||
|
/* Headline metrics on one line. As separate bullets they cost four lines to
|
||||||
|
say what one says, and the master CV's four-column band would extract as
|
||||||
|
scrambled text in an ATS. */
|
||||||
|
.metrics { margin: 0 0 5px 0; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+110
-67
@@ -286,18 +286,6 @@ PROMPT;
|
|||||||
. '<div>' . e($id['linkedin']) . ' | ' . e($id['portfolio'])
|
. '<div>' . e($id['linkedin']) . ' | ' . e($id['portfolio'])
|
||||||
. ' | ' . e($id['productSite']) . '</div>';
|
. ' | ' . 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 = '';
|
$skillsBlock = '';
|
||||||
foreach ($profile['skills'] as $group => $items) {
|
foreach ($profile['skills'] as $group => $items) {
|
||||||
$skillsBlock .= '<p class="skill-line"><span class="label">' . e($group) . ':</span> '
|
$skillsBlock .= '<p class="skill-line"><span class="label">' . e($group) . ':</span> '
|
||||||
@@ -314,74 +302,127 @@ PROMPT;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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'];
|
$edu = $profile['education'];
|
||||||
$educationBlock = '<p>' . e($edu['degree']) . ' — ' . e($edu['institution']) . '. ' . e($edu['note']) . '</p>';
|
$educationBlock = '<p>' . e($edu['degree']) . ' — ' . e($edu['institution']) . '. ' . e($edu['note']) . '</p>';
|
||||||
|
|
||||||
$certBlock = '<ul>';
|
$certBlock = '<p>' . e(implode(' · ', $profile['certifications'])) . '</p>';
|
||||||
foreach ($profile['certifications'] as $c) {
|
|
||||||
$certBlock .= '<li>' . e($c) . '</li>';
|
|
||||||
}
|
|
||||||
$certBlock .= '</ul>';
|
|
||||||
|
|
||||||
$availability = $market === 'amman'
|
$availability = $market === 'amman'
|
||||||
? 'Based in Amman, Jordan. Available immediately for on-site, hybrid and remote roles.'
|
? 'Based in Amman, Jordan. Available immediately for on-site, hybrid and remote roles.'
|
||||||
: $id['availability'];
|
: $id['availability'];
|
||||||
|
|
||||||
$html = file_get_contents(__DIR__ . '/cv_template.html');
|
// ── Adaptive compaction ─────────────────────────────────────────────────
|
||||||
$html = strtr($html, [
|
// A three-page CV for a mobile role reads as an inability to prioritise,
|
||||||
'{{FULL_NAME}}' => e($id['fullName']),
|
// and recruiters routinely stop at page two. Rather than guess at font
|
||||||
'{{JOB_HEADLINE}}' => e($headline),
|
// sizes, render the document, ask Dompdf how many pages it actually
|
||||||
'{{CONTACT_BLOCK}}' => $contactBlock,
|
// produced, and tighten until it fits. Each level drops the least valuable
|
||||||
'{{TAILORED_SUMMARY}}' => e($summary),
|
// content first — never a metric, never a role, never a date.
|
||||||
'{{ACHIEVEMENTS_BLOCK}}' => $achievements,
|
$DENSITY = [
|
||||||
'{{DYNAMIC_SKILLS}}' => e($coreSkills),
|
// bulletCaps are per role, oldest role last.
|
||||||
'{{SKILLS_BLOCK}}' => $skillsBlock,
|
0 => ['font' => '10.5pt', 'lh' => '1.42', 'pad' => '34px 40px', 'caps' => [4, 4, 3], 'glance' => true],
|
||||||
'{{EXPERIENCE_BLOCK}}' => $experienceBlock,
|
1 => ['font' => '10pt', 'lh' => '1.34', 'pad' => '26px 34px', 'caps' => [4, 4, 2], 'glance' => false],
|
||||||
'{{EDUCATION_BLOCK}}' => $educationBlock,
|
2 => ['font' => '9.5pt', 'lh' => '1.28', 'pad' => '22px 30px', 'caps' => [3, 3, 2], 'glance' => false],
|
||||||
'{{CERTIFICATIONS_BLOCK}}' => $certBlock,
|
];
|
||||||
'{{LANGUAGES}}' => e($id['languages']),
|
$TARGET_PAGES = 2;
|
||||||
'{{LOCATION}}' => e($id['location']),
|
|
||||||
'{{AVAILABILITY}}' => e($availability),
|
$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
|
||||||
|
) {
|
||||||
|
// 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) {
|
||||||
|
$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>';
|
||||||
|
|
||||||
|
$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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
$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,
|
||||||
|
'{{EDUCATION_BLOCK}}' => $educationBlock,
|
||||||
|
'{{CERTIFICATIONS_BLOCK}}' => $certBlock,
|
||||||
|
'{{LANGUAGES}}' => e($id['languages']),
|
||||||
|
'{{LOCATION}}' => e($id['location']),
|
||||||
|
'{{AVAILABILITY}}' => e($availability),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$options = new Options();
|
$options = new Options();
|
||||||
$options->set('isHtml5ParserEnabled', true);
|
$options->set('isHtml5ParserEnabled', true);
|
||||||
$options->set('defaultFont', 'Helvetica');
|
$options->set('defaultFont', 'Helvetica');
|
||||||
$dompdf = new Dompdf($options);
|
|
||||||
$dompdf->loadHtml($html, 'UTF-8');
|
$pdfOutput = null;
|
||||||
$dompdf->setPaper('A4', 'portrait');
|
$pageCount = null;
|
||||||
$dompdf->render();
|
$densityUsed = 0;
|
||||||
$pdfOutput = $dompdf->output();
|
|
||||||
|
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.
|
// ATS parsers key on the filename too — "Name - Role.pdf" reads cleanly.
|
||||||
$safeName = preg_replace('/[^a-zA-Z0-9\-_ ]/', '', $id['fullName']);
|
$safeName = preg_replace('/[^a-zA-Z0-9\-_ ]/', '', $id['fullName']);
|
||||||
@@ -399,6 +440,8 @@ PROMPT;
|
|||||||
"missing" => $ai['ats_keywords_missing'] ?? [],
|
"missing" => $ai['ats_keywords_missing'] ?? [],
|
||||||
"tailored" => $integrityClean && !empty($aiRoles),
|
"tailored" => $integrityClean && !empty($aiRoles),
|
||||||
"violations" => $violations,
|
"violations" => $violations,
|
||||||
|
"pages" => $pageCount,
|
||||||
|
"density" => $densityUsed,
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
} catch (Exception $ex) {
|
} catch (Exception $ex) {
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user