JAA-ATS-Tool / extension /background.js
saitejatirunagari's picture
feat(phase-10): V2 natural sentence resume mode β€” Kimi LLM generates sentences, same V1 waterfall
f057ca2
Raw
History Blame
20.7 kB
// background.js β€” ATS Resume Generator service worker
// No ATS or model-inference logic here. Only: read storage, call API, trigger download.
// R20: bundled default resume (self.DEFAULT_RESUME_LATEX) so a brand-new user can
// Run before configuring anything. importScripts works in a classic MV3 worker.
try { importScripts('default_resume.js'); } catch (_) { /* default optional */ }
function safeRespond(sendResponse, data) {
try { sendResponse(data); } catch (_) { /* popup closed before reply */ }
}
// ─── Storage source-of-truth (resilient Run; survives popup close + worker suspend) ──
// The background service worker OWNS generation. It persists the run lifecycle to
// chrome.storage.local keyed by the normalized job URL, so a reopened popup (or a
// recycled MV3 worker) can always surface the running marker or the final result.
// Mirrors the popup's `ats_results` map + `normalizeUrl` contract exactly.
const RESULTS_KEY = 'ats_results'; // map: { [urlKey]: {status, result, job_title, company, startedAt, savedAt} }
const MAX_SAVED = 40; // retained history entries (R19); quota-guarded below
const BYTE_BUDGET = 4500000; // ~4.5MB headroom under the ~5MB local quota
function normalizeUrl(u) {
// Drop the #fragment so an in-page refresh still matches; keep the query.
return (u || '').split('#')[0];
}
// Approximate serialized size of the results map (bytes ~= JSON length).
function approxSize(map) {
try { return JSON.stringify(map).length; } catch (_) { return 0; }
}
// Strip base64 artifacts from the OLDEST entries (keeping the newest `keepNewest`
// full) so the history row still renders (metadata + scores) but the heavy
// docx/pdf/tex payloads are dropped. Flags pruned entries as metadata-only.
function stripOldestArtifacts(map, keepNewest) {
const ks = Object.keys(map).sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0));
for (const k of ks.slice(0, Math.max(0, ks.length - keepNewest))) {
const r = map[k] && map[k].result;
if (r && (r.docx_b64 || r.pdf_b64 || r.tex_b64)) {
delete r.docx_b64; delete r.pdf_b64; delete r.tex_b64;
r.artifacts_pruned = true;
}
}
}
async function resolveActiveTabUrl() {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab ? tab.url : '';
} catch (_) {
return '';
}
}
function normalizeText(s) {
return String(s || '')
.toLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function parseProfileJson(raw) {
if (!raw || !String(raw).trim()) return {};
try {
const data = JSON.parse(raw);
return data && typeof data === 'object' ? data : {};
} catch (_) {
return {};
}
}
function profileIndex(profile) {
const out = { ...(profile || {}) };
const full = String(out.full_name || '').trim();
if (full) {
const parts = full.split(/\s+/);
if (!out.first_name && parts.length) out.first_name = parts[0];
if (!out.last_name && parts.length > 1) out.last_name = parts.slice(1).join(' ');
}
return out;
}
function valueOf(profile, key) {
const v = profile ? profile[key] : '';
return typeof v === 'string' || typeof v === 'number' ? String(v).trim() : '';
}
function fieldText(field) {
return normalizeText([
field.label, field.name, field.placeholder, field.help_text, field.type,
].filter(Boolean).join(' '));
}
function isLongAnswerField(field) {
return field.tag === 'textarea'
|| field.type === 'textarea'
|| (field.type === 'text' && /cover letter|why|tell us|describe|summary|experience|motivation|fit/.test(fieldText(field)));
}
function localAutofillValue(field, profile) {
const text = fieldText(field);
const fullName = valueOf(profile, 'full_name');
const firstName = valueOf(profile, 'first_name');
const lastName = valueOf(profile, 'last_name');
if (!text) return '';
if (/first name|given name/.test(text)) return firstName;
if (/last name|family name|surname/.test(text)) return lastName;
if (/full name|your name|applicant name|candidate name/.test(text)) return fullName;
if (/email|e mail/.test(text)) return valueOf(profile, 'email');
if (/phone|mobile|contact number|telephone/.test(text)) return valueOf(profile, 'phone');
if (/linkedin/.test(text)) return valueOf(profile, 'linkedin_url');
if (/github/.test(text)) return valueOf(profile, 'github_url');
if (/portfolio|website|personal site/.test(text)) return valueOf(profile, 'portfolio_url');
if (/current company|current employer|employer|organization/.test(text)) return valueOf(profile, 'current_company');
if (/current title|job title|designation|headline/.test(text)) return valueOf(profile, 'current_title');
if (/location|current city|city state|address/.test(text)) return valueOf(profile, 'location');
if (/years of experience|total experience|experience in years|how many years/.test(text)) {
return valueOf(profile, 'years_experience');
}
if (/notice period|joining period|available to join|when can you start|start date/.test(text)) {
return valueOf(profile, 'notice_period');
}
if (/authorized to work|work authorization/.test(text)) return valueOf(profile, 'work_authorization');
if (/require sponsorship|need sponsorship|visa sponsorship/.test(text)) {
return valueOf(profile, 'requires_sponsorship');
}
if (/visa status/.test(text)) return valueOf(profile, 'visa_status');
if (/current salary|current ctc/.test(text)) return valueOf(profile, 'current_salary');
if (/expected salary|salary expectation|expected ctc|compensation expectation/.test(text)) {
return valueOf(profile, 'expected_salary');
}
if (/notice|relocation|work mode|remote|hybrid/.test(text)) return valueOf(profile, 'additional_notes');
return '';
}
function pickFieldsForApi(fields, localAnswers) {
const answered = new Set((localAnswers || []).map((a) => a.id));
return (fields || []).filter((field) => {
if (!field || !field.id || answered.has(field.id)) return false;
const text = fieldText(field);
if (!text) return false;
if (/search|filter|sort/.test(text)) return false;
return isLongAnswerField(field)
|| (field.tag === 'select' || field.type === 'radio')
|| /why|motivation|about you|introduce yourself|cover letter|summary|fit|salary|authorization|sponsorship|relocate/.test(text);
});
}
async function runFormAssist({ data, resumeLatex, profile, fields, jd_text, job_title, company }) {
const formData = new FormData();
formData.append('resume_latex', resumeLatex || '');
formData.append('profile_json', JSON.stringify(profile || {}));
formData.append('fields_json', JSON.stringify(fields || []));
formData.append('jd_text', jd_text || '');
formData.append('job_title', job_title || '');
formData.append('company', company || '');
let resp;
try {
resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/form-assist`, {
method: 'POST',
headers: { 'X-Api-Token': data.api_token },
body: formData,
});
} catch (networkErr) {
return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` };
}
let result;
try {
result = await resp.json();
} catch {
return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` };
}
if (!resp.ok) {
return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` };
}
return result;
}
async function handleAutofillForm({ fields, jd_text, job_title, company }) {
const data = await new Promise(res => chrome.storage.local.get(
['api_url', 'api_token', 'resume_latex', 'autofill_profile_json'], res
));
let resumeLatex = data.resume_latex;
if ((!resumeLatex || !resumeLatex.trim()) && self.DEFAULT_RESUME_LATEX) {
resumeLatex = self.DEFAULT_RESUME_LATEX;
}
const profile = profileIndex(parseProfileJson(data.autofill_profile_json));
const localAnswers = [];
for (const field of (fields || [])) {
const value = localAutofillValue(field, profile);
if (value) {
localAnswers.push({
id: field.id,
value,
confidence: 'high',
source: 'profile',
});
}
}
const needsApi = pickFieldsForApi(fields, localAnswers);
let apiAnswers = [];
let warning = '';
if (needsApi.length) {
if (!data.api_url || !data.api_token) {
warning = 'API not configured. Filled only the common profile fields.';
} else if (!resumeLatex || !resumeLatex.trim()) {
warning = 'Resume LaTeX is not configured. Filled only the common profile fields.';
} else {
const apiResult = await runFormAssist({
data, resumeLatex, profile, fields: needsApi, jd_text, job_title, company,
});
if (apiResult && apiResult.error) {
warning = apiResult.detail || apiResult.error;
} else {
apiAnswers = Array.isArray(apiResult?.answers) ? apiResult.answers : [];
}
}
}
const merged = new Map();
for (const answer of [...apiAnswers, ...localAnswers]) {
if (!answer || !answer.id || !String(answer.value || '').trim()) continue;
merged.set(answer.id, answer);
}
return {
answers: Array.from(merged.values()),
local_count: localAnswers.length,
api_count: apiAnswers.filter((a) => String(a?.value || '').trim()).length,
field_count: (fields || []).length,
warning,
};
}
// Shallow-merge a patch into the entry for `urlKey`, preserving existing fields,
// stamping savedAt, and pruning to the most-recent MAX_SAVED entries.
async function writeEntry(urlKey, patch) {
if (!urlKey) return;
try {
const store = await new Promise(res => chrome.storage.local.get([RESULTS_KEY], res));
const map = store[RESULTS_KEY] || {};
map[urlKey] = { ...(map[urlKey] || {}), ...patch, savedAt: Date.now() };
// 1. Prune to the MAX_SAVED most-recent entries by recency.
let ks = Object.keys(map);
if (ks.length > MAX_SAVED) {
ks.sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0));
for (const k of ks.slice(0, ks.length - MAX_SAVED)) delete map[k];
}
// 2. Byte-budget guard: progressively strip artifacts from the oldest
// entries (keeping fewer full each pass), then drop whole oldest entries,
// until under budget. The just-written (newest) entry stays full.
let keep = MAX_SAVED;
while (approxSize(map) > BYTE_BUDGET && keep > 1) {
keep = Math.max(1, Math.floor(keep / 2));
stripOldestArtifacts(map, keep);
}
while (approxSize(map) > BYTE_BUDGET && Object.keys(map).length > 1) {
const oldest = Object.keys(map)
.sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0))[0];
delete map[oldest];
}
// 3. Write; on a QUOTA_BYTES error, degrade gracefully (strip oldest
// artifacts, else drop oldest entry) and retry β€” never lose the newest.
let attempts = 0;
while (attempts++ < MAX_SAVED + 2) {
try {
await new Promise((res, rej) => chrome.storage.local.set({ [RESULTS_KEY]: map }, () => {
const e = chrome.runtime.lastError;
if (e) rej(new Error(e.message)); else res();
}));
return; // success
} catch (_) {
const ksOld = Object.keys(map)
.sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0));
if (ksOld.length <= 1) return; // can't reduce further without losing newest
const oldest = ksOld[0];
const r = map[oldest] && map[oldest].result;
if (r && (r.docx_b64 || r.pdf_b64 || r.tex_b64)) {
delete r.docx_b64; delete r.pdf_b64; delete r.tex_b64; r.artifacts_pruned = true;
} else {
delete map[oldest];
}
}
}
} catch (_) { /* quota or transient storage error β€” non-fatal */ }
}
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'GENERATE') {
handleGenerate(msg)
.then((data) => safeRespond(sendResponse, data))
.catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message }));
return true;
}
if (msg.type === 'REPAIR') {
handleRepair(msg)
.then((data) => safeRespond(sendResponse, data))
.catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message }));
return true;
}
if (msg.type === 'DOWNLOAD') {
handleDownload(msg).catch(console.error);
return false;
}
if (msg.type === 'AUTOFILL_FORM') {
handleAutofillForm(msg)
.then((data) => safeRespond(sendResponse, data))
.catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message }));
return true;
}
});
// ─── REPAIR (External ATS Feedback Repair Mode) ───────────────────────────────
async function handleRepair({ jd_text, job_title, company, feedback, maximum_ats_mode, confirmed_terms, url, version }) {
const data = await new Promise(res => chrome.storage.local.get(
['resume_b64', 'resume_latex', 'api_url', 'api_token'], res
));
if (!data.api_url || !data.api_token) {
return { error: 'not_configured', detail: 'Please set API URL and token in Options.' };
}
// R20: fall back to the bundled default resume when nothing is stored.
let resumeLatex = data.resume_latex;
if ((!resumeLatex || !resumeLatex.trim()) && !data.resume_b64 && self.DEFAULT_RESUME_LATEX) {
resumeLatex = self.DEFAULT_RESUME_LATEX;
}
const hasLatex = !!(resumeLatex && resumeLatex.trim());
if (!hasLatex && !data.resume_b64) {
return { error: 'no_resume', detail: 'Please paste your resume LaTeX or upload a PDF in Options first.' };
}
const formData = new FormData();
formData.append('jd_text', jd_text);
formData.append('job_title', job_title || '');
formData.append('company', company || '');
formData.append('feedback', feedback || '');
formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0');
if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms);
formData.append('version', version || 'v1');
// LaTeX takes priority; only attach the PDF when no LaTeX is saved.
if (hasLatex) {
formData.append('resume_latex', resumeLatex);
} else {
const binaryStr = atob(data.resume_b64);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
const resumeBlob = new Blob([bytes], { type: 'application/pdf' });
formData.append('resume', resumeBlob, 'resume.pdf');
}
let resp;
try {
resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/repair-with-feedback`, {
method: 'POST',
headers: { 'X-Api-Token': data.api_token },
body: formData,
});
} catch (networkErr) {
return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` };
}
let result;
try {
result = await resp.json();
} catch {
return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` };
}
if (!resp.ok) {
return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` };
}
// An improved result is also persisted so reopening surfaces it (source of truth).
const urlKey = normalizeUrl(url || await resolveActiveTabUrl());
await writeEntry(urlKey, { status: 'done', result, job_title: job_title || '', company: company || '' });
return result;
}
// ─── GENERATE ────────────────────────────────────────────────────────────────
async function handleGenerate({ jd_text, job_title, company, maximum_ats_mode, confirmed_terms, url, version }) {
// 1. Read settings from storage
const data = await new Promise(res => chrome.storage.local.get(
['resume_b64', 'resume_latex', 'api_url', 'api_token'], res
));
if (!data.api_url || !data.api_token) {
return { error: 'not_configured', detail: 'Please set API URL and token in Options.' };
}
// R20: fall back to the bundled default resume when nothing is stored.
let resumeLatex = data.resume_latex;
if ((!resumeLatex || !resumeLatex.trim()) && !data.resume_b64 && self.DEFAULT_RESUME_LATEX) {
resumeLatex = self.DEFAULT_RESUME_LATEX;
}
const hasLatex = !!(resumeLatex && resumeLatex.trim());
if (!hasLatex && !data.resume_b64) {
return { error: 'no_resume', detail: 'Please paste your resume LaTeX or upload a PDF in Options first.' };
}
// The popup sends the job URL so persistence is keyed consistently; if it's
// missing (e.g. a recycled worker handling a queued message), fall back to the
// active tab. Storage β€” not in-memory worker state β€” is the source of truth.
const urlKey = normalizeUrl(url || await resolveActiveTabUrl());
// 2. Write the running marker BEFORE awaiting the API so a closed/reopened
// popup restores a spinner instead of resetting to idle "Run".
await writeEntry(urlKey, {
status: 'running',
startedAt: Date.now(),
job_title: job_title || '',
company: company || '',
});
// 3. Perform the network round-trip (no popup dependency).
const result = await runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version });
// 4. Overwrite the marker with the terminal state β€” done OR error β€” so the
// popup never shows a false/forever spinner. Done regardless of popup state.
if (result && result.error) {
await writeEntry(urlKey, { status: 'error', result });
} else {
await writeEntry(urlKey, { status: 'done', result, job_title: job_title || '', company: company || '' });
}
return result; // still returned to the popup so an open popup updates immediately
}
// Network/parse layer for GENERATE. Returns a result object (success or {error}).
async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version }) {
// Build multipart/form-data. LaTeX takes priority over the PDF.
const formData = new FormData();
formData.append('jd_text', jd_text);
formData.append('job_title', job_title || '');
formData.append('company', company || '');
formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0');
if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms);
formData.append('version', version || 'v1');
if (hasLatex) {
formData.append('resume_latex', resumeLatex || data.resume_latex);
} else {
const binaryStr = atob(data.resume_b64);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
const resumeBlob = new Blob([bytes], { type: 'application/pdf' });
formData.append('resume', resumeBlob, 'resume.pdf');
}
let resp;
try {
resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/generate`, {
method: 'POST',
headers: { 'X-Api-Token': data.api_token },
body: formData,
});
} catch (networkErr) {
return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` };
}
let result;
try {
result = await resp.json();
} catch {
return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` };
}
if (!resp.ok) {
return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` };
}
return result;
}
// ─── DOWNLOAD ────────────────────────────────────────────────────────────────
async function handleDownload({ format, b64, filename }) {
if (!b64) return;
// MV3 service workers do NOT support URL.createObjectURL β€” it's undefined here,
// which silently broke the download. Use a data: URL instead (chrome.downloads
// accepts data: URLs directly from the service worker).
let mimeType;
if (format === 'pdf') {
mimeType = 'application/pdf';
} else if (format === 'tex') {
mimeType = 'application/x-tex';
} else {
mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
}
const url = `data:${mimeType};base64,${b64}`;
try {
await chrome.downloads.download({
url,
filename: filename || `resume.${format}`,
saveAs: false,
});
} catch (err) {
console.error('[download] failed:', err);
}
}