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.
|
||||
const ats = response.data.ats || {};
|
||||
let msg = '✅ PDF downloaded';
|
||||
if (ats.pages) msg += ` — ${ats.pages}p`;
|
||||
if (ats.score) msg += ` — est. ATS match ${ats.score}%`;
|
||||
if (ats.tailored === false) msg += ' (untailored fallback)';
|
||||
if (ats.pages > 2) msg += ' ⚠️ over 2 pages';
|
||||
if (ats.violations && ats.violations.length) {
|
||||
msg += ` ⚠️ blocked fabricated claims: ${ats.violations.join(', ')}`;
|
||||
}
|
||||
|
||||
+10
-3
@@ -21,11 +21,13 @@
|
||||
font-feature-settings: "liga" 0, "clig" 0;
|
||||
letter-spacing: 0;
|
||||
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;
|
||||
line-height: 1.42;
|
||||
line-height: {{LINE_HEIGHT}};
|
||||
margin: 0;
|
||||
padding: 34px 40px;
|
||||
padding: {{PAGE_PADDING}};
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -78,6 +80,11 @@
|
||||
|
||||
.skill-line { margin: 0 0 4px 0; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+110
-67
@@ -286,18 +286,6 @@ PROMPT;
|
||||
. '<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> '
|
||||
@@ -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'];
|
||||
$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>';
|
||||
$certBlock = '<p>' . e(implode(' · ', $profile['certifications'])) . '</p>';
|
||||
|
||||
$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),
|
||||
]);
|
||||
// ── 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
|
||||
) {
|
||||
// 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 {
|
||||
$options = new Options();
|
||||
$options->set('isHtml5ParserEnabled', true);
|
||||
$options->set('defaultFont', 'Helvetica');
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
$pdfOutput = $dompdf->output();
|
||||
|
||||
$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']);
|
||||
@@ -399,6 +440,8 @@ PROMPT;
|
||||
"missing" => $ai['ats_keywords_missing'] ?? [],
|
||||
"tailored" => $integrityClean && !empty($aiRoles),
|
||||
"violations" => $violations,
|
||||
"pages" => $pageCount,
|
||||
"density" => $densityUsed,
|
||||
]
|
||||
]);
|
||||
} catch (Exception $ex) {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user