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
18 changes: 15 additions & 3 deletions extension-ready/cv-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const SECTION_RE = /^(professional\s+summary|core\s+competenc(?:y|ies)|professio

const DATE_RE = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|\d{4}|present|current|to\s*date|now)\b/i;

// Strip LLM-inserted label prefixes from job title lines, e.g. "Position: Senior Engineer"
function stripJobTitleLabel(line) {
return line.replace(/^(position|title|role|job\s*title)\s*:\s*/i, '').trim();
}

function isSectionHeader(line) {
if (SECTION_RE.test(line)) return true;
// ALL CAPS line that reads as a heading
Expand Down Expand Up @@ -181,6 +186,10 @@ function formatCvToHtml(rawText) {
continue;
}

// Skip pure-decorator lines — LLMs often insert ────────, ————————, ----, ====
// as horizontal rules between sections. They contain no word characters.
if (line.length >= 3 && !/\w/.test(line)) continue;

// Some generated CVs inherit a bad "website" from the parser when an
// email domain is mistaken for a URL, e.g. mtbdesigns01@gmail.com → http://gmail.com.
if (/^https?:\/\//i.test(line) && emailDomains.has(urlHost(line))) {
Expand All @@ -205,7 +214,8 @@ function formatCvToHtml(rawText) {
inHeader = false;
beforeFirstSection = false;
inEntrySection = isEntrySectionHeader(line);
html += `<h2 class="cv-section-header">${esc(line.replace(/[:\-]\s*$/, ''))}</h2>`;
const sectionText = line.replace(/[:\-]\s*$/, '').trim();
if (sectionText) html += `<h2 class="cv-section-header">${esc(sectionText)}</h2>`;
continue;
}

Expand Down Expand Up @@ -269,7 +279,8 @@ function formatCvToHtml(rawText) {

// Line immediately after an entry row → job title (italic)
if (afterEntryRow && line.length < 70 && !isContactLine(line) && !isDateLine(line)) {
html += `<p class="cv-job-title">${esc(line)}</p>`;
const title = stripJobTitleLabel(line);
if (title) html += `<p class="cv-job-title">${esc(title)}</p>`;
afterEntryRow = false;
continue;
}
Expand All @@ -284,7 +295,8 @@ function formatCvToHtml(rawText) {
if (pendingCompany !== null && line.length < 70 && !isContactLine(line) && !isDateLine(line)) {
flushPendingCompany(null);
afterEntryRow = false;
html += `<p class="cv-job-title">${esc(line)}</p>`;
const title = stripJobTitleLabel(line);
if (title) html += `<p class="cv-job-title">${esc(title)}</p>`;
continue;
}

Expand Down
69 changes: 65 additions & 4 deletions render-proxy/recipe/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,15 +211,16 @@ function detectQuestionType(question) {
)
) return 'why_company';

// Salary / compensation questions need a concrete number or range. They are
// not CV-story questions, even when the modal has job context available.
if (isSalaryQuestion(q)) return 'salary';

// Short factual — about candidate's current situation
if (
/notice\s*period/.test(q) ||
/\bstart\s+date\b/.test(q) ||
/when\s+(can|could|are)\s+you\s+(start|available|join)/.test(q) ||
/\bavailability\b/.test(q) ||
/salary\s*(expectation|requirement|expect)/.test(q) ||
/expected\s+salary/.test(q) ||
/current\s+salary/.test(q) ||
/right\s+to\s+work/.test(q) ||
/work\s*authori[sz]ation/.test(q) ||
/\bvisa\s+(status|type|sponsorship)\b/.test(q) ||
Expand Down Expand Up @@ -272,6 +273,19 @@ function detectQuestionType(question) {
return 'general';
}

function isSalaryQuestion(question) {
const q = String(question || '').toLowerCase();
return (
/\b(salary|compensation|pay|remuneration)\b/.test(q) ||
/\b(hourly|daily|monthly|annual|yearly)\s+(rate|pay|salary)\b/.test(q) ||
/\brate\s+(expectation|requirement|range|desired|expected)s?\b/.test(q)
) && (
/\b(expectation|expectations|requirement|requirements|expected|desired|target|current|range|minimum|min|max|maximum)\b/.test(q) ||
/\bwhat\s+(salary|compensation|pay|rate)\b/.test(q) ||
/\bhow\s+much\b/.test(q)
);
}

// ---------------------------------------------------------------------------
// Plain-field data extraction (name, email, LinkedIn, phone, etc.)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -310,7 +324,6 @@ function isDataExtractionQuestion(question) {
/^nationality$/i,
/^visa\s*status$/i,
/^work\s*authori[sz]ation$/i,
/^salary/i,
/^notice\s*period$/i,
/^availability$/i,
/^start\s*date$/i,
Expand Down Expand Up @@ -360,6 +373,51 @@ Rules:
return { systemPrompt, userPrompt, temperature: 0.1, maxTokens: 120 };
}

function buildSalaryPrompt(cvText, question, jobCtx, jobTitle) {
const q = String(question || '').toLowerCase();
const asksMonthly = /\b(monthly|per\s+month|\/\s*month|pcm)\b/.test(q);
const asksAnnual = /\b(annual|annually|yearly|per\s+year|\/\s*year|base\s+salary)\b/.test(q);
const asksHourly = /\b(hourly|per\s+hour|\/\s*hour|hourly\s+rate)\b/.test(q);
const asksDaily = /\b(daily|day\s+rate|per\s+day|\/\s*day)\b/.test(q);
const currency = /\b(usd|us\$|\$)\b/.test(q) ? 'USD'
: /\b(gbp|gb£|£)\b/.test(q) ? 'GBP'
: /\b(eur|€)\b/.test(q) ? 'EUR'
: 'the local currency';

const period = asksMonthly ? 'monthly'
: asksHourly ? 'hourly'
: asksDaily ? 'daily'
: asksAnnual ? 'annual'
: 'the period requested by the form';

const systemPrompt = `You are this candidate completing a salary expectation field in a job application.

This is a SALARY / COMPENSATION question. The answer must be a direct number or range, not a career summary.

Rules:
- Start with the salary expectation in the first words.
- Use ${currency}; use a ${period} figure if the question asks for one.
- If the question asks for monthly USD, answer as "$X-$Y per month".
- Keep it to 1 sentence unless a very short note about negotiability is useful.
- Do NOT mention previous employers, role history, projects, skills, or CV details.
- Do NOT say "As a seasoned professional", "based on my experience", or any similar preamble.
- Do NOT avoid the question with "open to discussion" alone; give a concrete range.
- If exact market data is uncertain, give a reasonable professional range and say it is negotiable.`;

const cvContext = getCvContext(cvText, 12000);
let userPrompt = `MY CV, for seniority context only. Do not summarize it in the answer:\n${cvContext}\n\n`;
if (jobCtx) userPrompt += `Role I'm applying for:\n${jobCtx}\n`;
userPrompt += `Question: ${question}

Write only the answer to paste into the form. It must contain a concrete ${currency} ${period} salary range and no CV narrative.`;

if (jobTitle) {
userPrompt += ` Use the ${jobTitle} role as the market anchor.`;
}

return { systemPrompt, userPrompt, temperature: 0.2, maxTokens: 90 };
}

function buildYesNoPrompt(cvText, question, jobCtx, candidateName, tone) {
const writingGuidance = getWritingGuidance(tone);

Expand Down Expand Up @@ -653,6 +711,9 @@ export function buildPrompts(input) {

let result;
switch (qType) {
case 'salary':
result = buildSalaryPrompt(cvText, question, jobCtx, jobTitle);
break;
case 'short_factual':
result = buildShortFactualPrompt(cvText, question);
break;
Expand Down
4 changes: 2 additions & 2 deletions tests/cv-export.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Position: Senior Technical Support Engineer IC4`);

expect(html).toContain('class="cv-company">Sourcegraph, UK</span>');
expect(html).toContain('class="cv-entry-dates">February 2026 - Present</span>');
expect(html).toContain('class="cv-job-title">Position: Senior Technical Support Engineer IC4</p>');
expect(html).toContain('class="cv-job-title">Senior Technical Support Engineer IC4</p>');
});

it('renders Focus lines distinctly without replacing the official job title', () => {
Expand All @@ -131,7 +131,7 @@ Focus: MLOps, platform reliability, cloud infrastructure, automation, and produc

- Built Python automation tools`);

expect(html).toContain('class="cv-job-title">Position: Senior Technical Support Engineer IC4</p>');
expect(html).toContain('class="cv-job-title">Senior Technical Support Engineer IC4</p>');
expect(html).toContain('class="cv-role-focus">Focus: MLOps, platform reliability, cloud infrastructure, automation, and production diagnostics</p>');
});
});
46 changes: 46 additions & 0 deletions tests/recipe.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { buildPrompts } from '../render-proxy/recipe/index.js';

const CV = `Michael T Bali
Infra & MLOps Engineer
Birmingham, UK
mtbdesigns01@gmail.com

Professional Experience
Sourcegraph
Senior Technical Support Engineer

Semgrep
Senior Technical Support Engineer`;

describe('render proxy recipe', () => {
it('routes monthly salary questions to a direct salary prompt', () => {
const prompt = buildPrompts({
question: 'What are your monthly salary expectations? (in USD)*',
length: 'short',
tone: 'natural',
cvText: CV,
jobTitle: 'Machine Learning Operations Engineer (MLOps)',
jobDescription: 'We need an MLOps engineer for production ML systems.',
requirements: ['MLOps', 'Kubernetes', 'Python', 'Cloud'],
maxChars: 255,
});

expect(prompt.systemPrompt).toMatch(/SALARY \/ COMPENSATION question/);
expect(prompt.systemPrompt).toMatch(/\$X-\$Y per month/);
expect(prompt.systemPrompt).toMatch(/Do NOT mention previous employers/);
expect(prompt.userPrompt).toMatch(/concrete USD monthly salary range/);
expect(prompt.userPrompt).toMatch(/255 characters or fewer/);
expect(prompt.maxTokens).toBeLessThanOrEqual(90);
});

it('does not treat a bare salary field as CV data extraction', () => {
const prompt = buildPrompts({
question: 'Salary expectations',
cvText: CV,
});

expect(prompt.systemPrompt).not.toMatch(/data extraction assistant/i);
expect(prompt.systemPrompt).toMatch(/SALARY \/ COMPENSATION question/);
});
});
Loading