Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,12 @@ app.post('/api/cv/tailor', async (req, res) => {
max_tokens: 4000
});

const tailoredCvText = tailor.removeTailoringMetaPhrases(
tailor.enforceTargetHeadline(result.answer, jdData.jobTitle),
jdData.company
const tailoredCvText = tailor.ensureConfirmedSkillsIncluded(
tailor.removeTailoringMetaPhrases(
tailor.enforceTargetHeadline(result.answer, jdData.jobTitle),
jdData.company
),
confirmedSkills
);
if (!tailoredCvText?.trim()) {
return res.status(502).json({ error: 'No output from provider' });
Expand Down
6 changes: 5 additions & 1 deletion extension-ready/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ class DraftApplyExtension {
modal.innerHTML = `
<div class="da-modal-content">
<div class="da-modal-header">
<img class="da-modal-logo" src="${chrome.runtime.getURL('icons/icon128.png')}" alt="" onerror="this.style.display='none'">
<img class="da-modal-logo" src="${chrome.runtime.getURL('icons/icon128.png')}" alt="">
<span class="da-header-name">DraftApply</span>
<span class="da-context-badge" id="da-context-badge">No context</span>
<button class="da-modal-close" aria-label="Close">&times;</button>
Expand Down Expand Up @@ -218,6 +218,10 @@ class DraftApplyExtension {
modal.style.display = 'none';
this.modal = modal;

// Hide logo if icon fails to load (CSP-safe alternative to onerror attribute)
const logoImg = modal.querySelector('.da-modal-logo');
if (logoImg) logoImg.addEventListener('error', () => { logoImg.style.display = 'none'; });

// Bind events first (they persist even if modal is detached from DOM)
modal.querySelector('.da-modal-close').onclick = () => this.hideModal();
modal.querySelector('#da-btn-insert').onclick = () => this.insertAnswer();
Expand Down
5 changes: 5 additions & 0 deletions extension-ready/cv-export.html
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@
}

/* ── Print ── */

/* Suppress browser-injected date / title / URL headers and footers.
Chrome only adds those when no @page margin is set via CSS. */
@page { margin: 0; }

@media print {
body { background: white; }
#toolbar { display: none !important; }
Expand Down
4 changes: 4 additions & 0 deletions extension-ready/cv-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@

await chrome.storage.local.remove('tailoredCvExport');

// Set page title (and therefore PDF filename) to "Full Name CV"
const candidateName = tailoredCvExport.split('\n').map(l => l.trim()).find(l => l.length > 0);
if (candidateName) document.title = `${candidateName} CV`;

content.innerHTML = formatCvToHtml(tailoredCvExport);
loading.hidden = true;
content.hidden = false;
Expand Down
4 changes: 2 additions & 2 deletions extension-ready/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@
<!-- Header -->
<div class="da-header">
<div class="da-header-logo">
<img src="icons/icon128.png" alt="" onerror="this.style.display='none'">
<img src="icons/icon128.png" alt="" class="da-popup-logo-img">
</div>
<div class="da-header-text">
<div class="da-header-title">DraftApply</div>
Expand Down Expand Up @@ -785,7 +785,7 @@
<!-- Reuse the same header gradient -->
<div class="da-header">
<div class="da-header-logo">
<img src="icons/icon128.png" alt="" onerror="this.style.display='none'">
<img src="icons/icon128.png" alt="" class="da-popup-logo-img">
</div>
<div class="da-header-text">
<div class="da-header-title">DraftApply</div>
Expand Down
4 changes: 4 additions & 0 deletions extension-ready/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
*/

document.addEventListener('DOMContentLoaded', async () => {
// Hide logo images that fail to load (CSP-safe — no inline onerror attribute)
document.querySelectorAll('.da-popup-logo-img').forEach(img => {
img.addEventListener('error', () => { img.style.display = 'none'; });
});
const TAILOR_DRAFT_KEY = 'tailorCvDraft';

const elements = {
Expand Down
9 changes: 6 additions & 3 deletions render-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,12 @@ app.post('/api/cv/tailor', authRequired, generateLimiter, async (req, res) => {
}

const data = await response.json();
const tailoredCvText = tailor.removeTailoringMetaPhrases(
tailor.enforceTargetHeadline(data?.choices?.[0]?.message?.content, jdData.jobTitle),
jdData.company
const tailoredCvText = tailor.ensureConfirmedSkillsIncluded(
tailor.removeTailoringMetaPhrases(
tailor.enforceTargetHeadline(data?.choices?.[0]?.message?.content, jdData.jobTitle),
jdData.company
),
confirmedSkills
);
if (!tailoredCvText?.trim()) {
return res.status(502).json({ error: 'No output from provider' });
Expand Down
75 changes: 68 additions & 7 deletions shared/cv-tailor.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ export class CVTailor {
buildMatchMap(cvData, jdData, confirmedSkills = []) {
const cvText = cvData.rawText || '';
const cvLower = cvText.toLowerCase();
const confirmedSet = new Set(
(Array.isArray(confirmedSkills) ? confirmedSkills : [])
.map(skill => this._normaliseText(skill))
.filter(Boolean)
);
const confirmedEntries = (Array.isArray(confirmedSkills) ? confirmedSkills : [])
.map(skill => String(skill || '').trim())
.filter(Boolean);
const confirmedByKey = new Map();
for (const skill of confirmedEntries) {
const key = this._normaliseText(skill);
if (key && !confirmedByKey.has(key)) confirmedByKey.set(key, skill);
}
const confirmedSet = new Set(confirmedByKey.keys());

// Flatten all CV text sources for searching
const cvSources = [
Expand Down Expand Up @@ -41,6 +45,13 @@ export class CVTailor {
return true;
});

for (const skill of confirmedByKey.values()) {
const key = this._normaliseText(skill);
if (!deduped.some(({ req }) => this._normaliseText(req) === key)) {
deduped.push({ req: skill, type: 'user_confirmed' });
}
}

return deduped.map(({ req, type }) => {
const confirmedByUser = confirmedSet.has(this._normaliseText(req));
const evidence = this._findEvidence(req, cvSources);
Expand Down Expand Up @@ -124,7 +135,7 @@ STRICT RULES:
3. Preserve every locked field exactly as written — same spelling, capitalisation, and format.
4. Do not add new employment entries, new education entries, or new certifications.
5. Do not remove any role or educational entry.
6. Exact product/tool names from the JD may be added to skills, summaries, or bullets only when supported by the original CV or match report.
6. Exact product/tool names from the JD or user-confirmed review may be added to skills, summaries, or bullets only when supported by the original CV or match report.
7. If the JD mentions an unsupported tool, you may emphasize adjacent supported experience instead, but do not name the unsupported tool as a candidate skill.
8. Do not write meta phrases such as "Tailored for", "customized for", "aligned to this job", or "for this application".
9. Do not mention the target company name in the CV body unless it already appears in the original CV as part of the candidate's history.
Expand All @@ -138,6 +149,7 @@ WHAT YOU MAY DO:
• Rephrase existing responsibility bullets using vocabulary from the job description, as long as the underlying meaning is unchanged.
• Reorder bullets within a role to put the most relevant ones first.
• Expand or compress bullet points within the bounds of what the original bullet states.
• Include every user-confirmed addition in the skills/core competencies section.
• Add truthful role-positioning lines in the form "Focus: ..." under existing role titles when supported by that role's original responsibilities.`;

const supported = matchMap.filter(m => m.allowedToMention).map(m => m.requirement);
Expand Down Expand Up @@ -182,7 +194,7 @@ INSTRUCTION
3. Reorder and rename skills/competencies so supported JD-relevant items appear first, especially supported technologies, methods, domain terms, and operational practices from the JD.
4. For each relevant role: preserve the official job title exactly, then add one short "Focus:" line below it when the original responsibilities support the target role. Example: "Focus: MLOps, platform reliability, cloud infrastructure, automation, and production diagnostics".
5. For each role: rewrite relevant bullets with JD vocabulary (same meaning, aligned language), reorder bullets so the strongest target-role evidence comes first, and make Infra/MLOps/platform evidence obvious when supported.
6. You may include user-confirmed additions as skills/tools, but do not attach them to a specific employer, project, metric, certification, or achievement unless that context exists in the original CV.
6. Include every user-confirmed addition in the skills/core competencies section. You may also use them in the summary when natural, but do not attach them to a specific employer, project, metric, certification, or achievement unless that context exists in the original CV.
7. Preserve all locked fields exactly — same spelling, capitalisation, and punctuation.
8. The final CV must read like a polished CV for "${jdData.jobTitle || 'the target role'}", not like a generic CV and not like generated marketing copy.

Expand Down Expand Up @@ -240,6 +252,42 @@ Output the complete tailored CV text with no preamble, no commentary, and no mar
.join('\n');
}

ensureConfirmedSkillsIncluded(tailoredText, confirmedSkills = []) {
if (!tailoredText) return tailoredText;

const skills = this._uniqueDisplaySkills(confirmedSkills);
if (skills.length === 0) return tailoredText;

const existingText = this._normaliseText(tailoredText);
const missing = skills.filter(skill => !existingText.includes(this._normaliseText(skill)));
if (missing.length === 0) return tailoredText;

const lines = String(tailoredText).split('\n');
const headingIdx = lines.findIndex(line =>
/^(core\s+competenc(?:y|ies)|technical\s+skills?|skills|technologies|tools|expertise)\s*[:\-]?$/i
.test(line.trim())
);

if (headingIdx === -1) {
return `${tailoredText.trim()}\n\nTechnical Skills\n${missing.join(', ')}`;
}

let insertIdx = headingIdx + 1;
while (insertIdx < lines.length && !lines[insertIdx].trim()) insertIdx++;

if (insertIdx >= lines.length || this._isLikelySectionHeader(lines[insertIdx])) {
lines.splice(headingIdx + 1, 0, missing.join(', '));
return lines.join('\n');
}

const current = lines[insertIdx].trim();
const prefix = /^[-•]\s+/.test(current) ? current.match(/^[-•]\s+/)[0] : '';
const body = prefix ? current.replace(/^[-•]\s+/, '') : current;
const separator = body && !/[,:;]\s*$/.test(body) ? ', ' : ' ';
lines[insertIdx] = `${prefix}${body}${separator}${missing.join(', ')}`.trimEnd();
return lines.join('\n');
}

/**
* Rule-based validation that locked fields were not altered.
* @returns {string[]} warnings
Expand Down Expand Up @@ -339,6 +387,19 @@ Output the complete tailored CV text with no preamble, no commentary, and no mar
.map(([label, value]) => `${label}: "${value.trim()}"`);
}

_uniqueDisplaySkills(skills = []) {
const seen = new Set();
const result = [];
for (const skill of Array.isArray(skills) ? skills : []) {
const clean = String(skill || '').trim().replace(/\s+/g, ' ');
const key = this._normaliseText(clean);
if (!key || seen.has(key)) continue;
seen.add(key);
result.push(clean);
}
return result;
}

_getCoreTokens(requirement) {
const NOISE = this._noiseWords();
const needle = this._normaliseText(requirement);
Expand Down
67 changes: 67 additions & 0 deletions tests/cv-tailor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,24 @@ describe('buildMatchMap', () => {
expect(graphql.confirmedByUser).toBe(true);
});

it('adds user-confirmed domain suggestions even when they are not in the JD', () => {
const map = tailor.buildMatchMap(CV, JD, ['Prometheus', 'Grafana']);
const prometheus = map.find(m => m.requirement === 'Prometheus');
const grafana = map.find(m => m.requirement === 'Grafana');
expect(prometheus).toMatchObject({
type: 'user_confirmed',
status: 'user_confirmed',
allowedToMention: true,
confirmedByUser: true,
});
expect(grafana).toMatchObject({
type: 'user_confirmed',
status: 'user_confirmed',
allowedToMention: true,
confirmedByUser: true,
});
});

it('user_confirmed evidence mentions user confirmation', () => {
const map = tailor.buildMatchMap(CV, JD, ['GraphQL']);
const graphql = map.find(m => m.requirement === 'GraphQL');
Expand Down Expand Up @@ -187,6 +205,12 @@ describe('buildMatchSummary', () => {
expect(summary.confirmedAdditions).toContain('GraphQL');
});

it('surfaces confirmed domain suggestions in the summary', () => {
const map = tailor.buildMatchMap(CV, JD, ['Prometheus']);
const summary = tailor.buildMatchSummary(map);
expect(summary.confirmedAdditions).toContain('Prometheus');
});

it('surfaces unsupported requirements in the summary', () => {
const map = tailor.buildMatchMap(CV, JD);
const summary = tailor.buildMatchSummary(map);
Expand Down Expand Up @@ -397,6 +421,40 @@ describe('detectChangedSections', () => {
});
});

// ── ensureConfirmedSkillsIncluded ────────────────────────────────────────────

describe('ensureConfirmedSkillsIncluded', () => {
it('adds omitted confirmed skills to an existing skills section', () => {
const tailored = `John Doe

Senior Software Engineer

SKILLS
React, TypeScript, Docker

EXPERIENCE
TechCorp`;

const result = tailor.ensureConfirmedSkillsIncluded(tailored, ['Prometheus', 'Grafana']);
expect(result).toContain('React, TypeScript, Docker, Prometheus, Grafana');
});

it('does not duplicate confirmed skills already present', () => {
const tailored = `John Doe

SKILLS
React, Grafana`;

const result = tailor.ensureConfirmedSkillsIncluded(tailored, ['Grafana', 'Prometheus']);
expect(result).toContain('React, Grafana, Prometheus');
expect(result.match(/Grafana/g)).toHaveLength(1);
});

it('creates a skills section when the tailored CV has none', () => {
const result = tailor.ensureConfirmedSkillsIncluded('John Doe\n\nEXPERIENCE\nTechCorp', ['Prometheus']);
expect(result).toContain('Technical Skills\nPrometheus');
});
});

// ── buildTailoringPrompt ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -450,6 +508,15 @@ describe('buildTailoringPrompt', () => {
expect(userPrompt).toContain('strongest target-role evidence comes first');
});

it('requires every user-confirmed addition to be included in skills', () => {
const map = tailor.buildMatchMap(CV, JD, ['Prometheus', 'Grafana']);
const { systemPrompt, userPrompt } = tailor.buildTailoringPrompt(CV, JD, map);
expect(systemPrompt).toContain('Include every user-confirmed addition in the skills/core competencies section');
expect(userPrompt).toContain('Include every user-confirmed addition in the skills/core competencies section');
expect(userPrompt).toContain('+ Prometheus');
expect(userPrompt).toContain('+ Grafana');
});

it('includes the original CV text in the user prompt', () => {
const map = tailor.buildMatchMap(CV, JD);
const { userPrompt } = tailor.buildTailoringPrompt(CV, JD, map);
Expand Down
Loading