From 2a597f0468965cea414a0ba143a2058389e67ecf Mon Sep 17 00:00:00 2001 From: mibali Date: Mon, 4 May 2026 14:56:42 +0100 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20CV=20export=20formatting=20=E2=80=94?= =?UTF-8?q?=20strip=20job=20title=20labels=20and=20decorator=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip pure-decorator lines (no word chars: ────────, ————————, ----) that LLMs insert as horizontal rules; these were rendering as dark bars in the PDF - Strip 'Position:', 'Title:', 'Role:', 'Job title:' prefixes from job title lines — LLM output like 'Position: Senior Engineer' now renders as just 'Senior Engineer' in italic - Guard against emitting empty

section headers if cleaning the line leaves it blank Co-Authored-By: Claude Sonnet 4.6 --- extension-ready/cv-export.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/extension-ready/cv-export.js b/extension-ready/cv-export.js index 7c915e3..ea7537a 100644 --- a/extension-ready/cv-export.js +++ b/extension-ready/cv-export.js @@ -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 @@ -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))) { @@ -205,7 +214,8 @@ function formatCvToHtml(rawText) { inHeader = false; beforeFirstSection = false; inEntrySection = isEntrySectionHeader(line); - html += `

${esc(line.replace(/[:\-]\s*$/, ''))}

`; + const sectionText = line.replace(/[:\-]\s*$/, '').trim(); + if (sectionText) html += `

${esc(sectionText)}

`; continue; } @@ -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 += `

${esc(line)}

`; + const title = stripJobTitleLabel(line); + if (title) html += `

${esc(title)}

`; afterEntryRow = false; continue; } @@ -284,7 +295,8 @@ function formatCvToHtml(rawText) { if (pendingCompany !== null && line.length < 70 && !isContactLine(line) && !isDateLine(line)) { flushPendingCompany(null); afterEntryRow = false; - html += `

${esc(line)}

`; + const title = stripJobTitleLabel(line); + if (title) html += `

${esc(title)}

`; continue; } From 0cad4574341e2cf6f97a277fd429bbed7c326c80 Mon Sep 17 00:00:00 2001 From: mibali Date: Mon, 4 May 2026 15:03:58 +0100 Subject: [PATCH 2/2] Answer salary expectations directly --- render-proxy/recipe/index.js | 69 +++++++++++++++++++++++++++++++++--- tests/cv-export.test.js | 4 +-- tests/recipe.test.js | 46 ++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 tests/recipe.test.js diff --git a/render-proxy/recipe/index.js b/render-proxy/recipe/index.js index 0ec9b23..e4effeb 100644 --- a/render-proxy/recipe/index.js +++ b/render-proxy/recipe/index.js @@ -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) || @@ -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.) // --------------------------------------------------------------------------- @@ -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, @@ -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); @@ -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; diff --git a/tests/cv-export.test.js b/tests/cv-export.test.js index 28c3735..97dd026 100644 --- a/tests/cv-export.test.js +++ b/tests/cv-export.test.js @@ -114,7 +114,7 @@ Position: Senior Technical Support Engineer IC4`); expect(html).toContain('class="cv-company">Sourcegraph, UK'); expect(html).toContain('class="cv-entry-dates">February 2026 - Present'); - expect(html).toContain('class="cv-job-title">Position: Senior Technical Support Engineer IC4

'); + expect(html).toContain('class="cv-job-title">Senior Technical Support Engineer IC4

'); }); it('renders Focus lines distinctly without replacing the official job title', () => { @@ -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

'); + expect(html).toContain('class="cv-job-title">Senior Technical Support Engineer IC4

'); expect(html).toContain('class="cv-role-focus">Focus: MLOps, platform reliability, cloud infrastructure, automation, and production diagnostics

'); }); }); diff --git a/tests/recipe.test.js b/tests/recipe.test.js new file mode 100644 index 0000000..5b9a3b4 --- /dev/null +++ b/tests/recipe.test.js @@ -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/); + }); +});