Auto-deploy: 2026-08-05 16:46:02

This commit is contained in:
Hamza-Ayed
2026-08-05 16:46:02 +03:00
parent ba1eff212c
commit 2ca5a74fa4
4 changed files with 123 additions and 71 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -286,18 +286,6 @@ PROMPT;
. '<div>' . e($id['linkedin']) . ' &nbsp;|&nbsp; ' . e($id['portfolio'])
. ' &nbsp;|&nbsp; ' . 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(' &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) {
$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
View File
File diff suppressed because one or more lines are too long