@pranavraut033/ats-checker 1.3.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -50
- package/dist/chunk-JAFFS7YZ.mjs +1219 -0
- package/dist/chunk-JAFFS7YZ.mjs.map +1 -0
- package/dist/{scoring-BCShrnki.d.mts → config-CVmXtmvk.d.mts} +208 -88
- package/dist/{scoring-BCShrnki.d.ts → config-CVmXtmvk.d.ts} +208 -88
- package/dist/index.cjs +1777 -298
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +840 -133
- package/dist/index.mjs.map +1 -1
- package/dist/lang/de/index.cjs +424 -29
- package/dist/lang/de/index.cjs.map +1 -1
- package/dist/lang/de/index.d.mts +1 -1
- package/dist/lang/de/index.d.ts +1 -1
- package/dist/lang/de/index.mjs +424 -29
- package/dist/lang/de/index.mjs.map +1 -1
- package/dist/lang/en/index.cjs +613 -41
- package/dist/lang/en/index.cjs.map +1 -1
- package/dist/lang/en/index.d.mts +1 -1
- package/dist/lang/en/index.d.ts +1 -1
- package/dist/lang/en/index.mjs +1 -1
- package/dist/lang/en/index.mjs.map +1 -1
- package/dist/pdf/index.cjs.map +1 -1
- package/dist/pdf/index.mjs.map +1 -1
- package/package.json +11 -2
- package/dist/chunk-ZJ5E4H7Z.mjs +0 -446
- package/dist/chunk-ZJ5E4H7Z.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { clamp, mergeKeywordRegistries, defaultKeywordRegistry, softwareEngineerProfile, buildCategoryIndex, deriveSkillAliases, normalizeWhitespace, splitLines, normalizeSkills, tokenize,
|
|
2
|
-
export { defaultKeywordRegistry, defaultProfiles, defaultSkillAliases } from './chunk-
|
|
1
|
+
import { ROLE_NOUNS, unique, clamp, mergeKeywordRegistries, defaultKeywordRegistry, softwareEngineerProfile, buildCategoryIndex, deriveSkillAliases, normalizeWhitespace, splitLines, detectFormatting, normalizeSkills, tokenize, normalizeForComparison, STOP_WORDS, normalizeSkill, countFrequencies, escapeRegExp, fuzzyEqual, stem } from './chunk-JAFFS7YZ.mjs';
|
|
2
|
+
export { defaultKeywordRegistry, defaultProfiles, defaultSkillAliases } from './chunk-JAFFS7YZ.mjs';
|
|
3
3
|
|
|
4
4
|
// src/utils/languages.ts
|
|
5
5
|
var KNOWN_LANGUAGES = [
|
|
@@ -64,11 +64,11 @@ var LEVEL_RANK = {
|
|
|
64
64
|
muttersprache: 6,
|
|
65
65
|
muttersprachler: 6,
|
|
66
66
|
// French
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
d\u00E9butant: 1,
|
|
68
|
+
\u00E9l\u00E9mentaire: 1,
|
|
69
|
+
limit\u00E9: 2,
|
|
70
|
+
interm\u00E9diaire: 3,
|
|
71
|
+
avanc\u00E9: 4,
|
|
72
72
|
courant: 5,
|
|
73
73
|
natif: 6,
|
|
74
74
|
"langue maternelle": 6,
|
|
@@ -133,6 +133,67 @@ function diffLanguages(resumeLanguages, requiredLanguages) {
|
|
|
133
133
|
return { matched, missing };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// src/utils/titles.ts
|
|
137
|
+
var SENIORITY_SIGNALS = [
|
|
138
|
+
{ seniority: "principal", pattern: /\bprincipal\b/i },
|
|
139
|
+
{ seniority: "lead", pattern: /\b(lead|staff|head of|director)\b/i },
|
|
140
|
+
{ seniority: "senior", pattern: /\b(senior|sr\.?)\b/i },
|
|
141
|
+
{
|
|
142
|
+
seniority: "junior",
|
|
143
|
+
pattern: /\b(junior|jr\.?|entry[\s-]?level|intern(?:ship)?|associate)\b/i
|
|
144
|
+
}
|
|
145
|
+
];
|
|
146
|
+
var SENIORITY_RANK = {
|
|
147
|
+
junior: 0,
|
|
148
|
+
mid: 1,
|
|
149
|
+
senior: 2,
|
|
150
|
+
lead: 3,
|
|
151
|
+
principal: 4
|
|
152
|
+
};
|
|
153
|
+
function inferSeniority(titles) {
|
|
154
|
+
let best;
|
|
155
|
+
for (const title of titles) {
|
|
156
|
+
for (const { seniority, pattern } of SENIORITY_SIGNALS) {
|
|
157
|
+
if (pattern.test(title)) {
|
|
158
|
+
if (!best || SENIORITY_RANK[seniority] > SENIORITY_RANK[best]) {
|
|
159
|
+
best = seniority;
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return best;
|
|
166
|
+
}
|
|
167
|
+
function normalizeTitle(title) {
|
|
168
|
+
return title.toLowerCase().replace(/[.,/#!$%^&*;:{}=_`~()]/g, " ").replace(/\s+/g, " ").trim();
|
|
169
|
+
}
|
|
170
|
+
function titleTokens(title) {
|
|
171
|
+
const tokens = normalizeTitle(title).split(" ").filter((t) => t && !STOP_WORDS.has(t)).map((t) => stem(t));
|
|
172
|
+
return new Set(tokens);
|
|
173
|
+
}
|
|
174
|
+
function titleMatch(resumeTitles, jdRoleKeywords) {
|
|
175
|
+
const keywords = jdRoleKeywords.map((k) => stem(normalizeTitle(k))).filter(Boolean);
|
|
176
|
+
if (keywords.length === 0) {
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
const resumeTokenSets = resumeTitles.map(titleTokens);
|
|
180
|
+
const roleNounStems = new Set(ROLE_NOUNS.map((n) => stem(n)));
|
|
181
|
+
let matched = 0;
|
|
182
|
+
for (const keyword of keywords) {
|
|
183
|
+
const isRoleNoun = roleNounStems.has(keyword);
|
|
184
|
+
const hit = resumeTokenSets.some((tokens) => {
|
|
185
|
+
if (tokens.has(keyword)) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
return isRoleNoun && [...tokens].some((t) => roleNounStems.has(t) && t === keyword);
|
|
189
|
+
});
|
|
190
|
+
if (hit) {
|
|
191
|
+
matched += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return matched / keywords.length;
|
|
195
|
+
}
|
|
196
|
+
|
|
136
197
|
// src/core/parser/jd.parser.ts
|
|
137
198
|
var DEGREE_VARIANTS = [
|
|
138
199
|
[/\b(?:bachelor(?:'s)?|b\.s\.?|bs\.?|bsc\.?|licence)\b/i, "bachelor"],
|
|
@@ -159,13 +220,49 @@ function extractPreferredSkills(lines) {
|
|
|
159
220
|
}
|
|
160
221
|
return preferred;
|
|
161
222
|
}
|
|
223
|
+
var REQUIRED_HEADER_RE = /^(?:requirements?|must[\s-]?haves?|required\s+skills?|required\s+qualifications?|qualifications?|minimum\s+qualifications?)\s*:?\s*$/i;
|
|
224
|
+
var PREFERRED_HEADER_RE = /^(?:preferred(?:\s+skills?|\s+qualifications?)?|nice[\s-]?to[\s-]?haves?|bonus(?:\s+points?)?|pluses?)\s*:?\s*$/i;
|
|
225
|
+
var OTHER_HEADER_RE = /^(?:responsibilities|duties|about(?:\s+the)?(?:\s+role|\s+us|\s+company|\s+team)?|what\s+you.?ll\s+do|who\s+you\s+are|benefits|perks|compensation|how\s+to\s+apply|overview|summary|about)\s*:?\s*$/i;
|
|
226
|
+
function extractHeaderScopedSkills(lines) {
|
|
227
|
+
const required = [];
|
|
228
|
+
const preferred = [];
|
|
229
|
+
let scope = null;
|
|
230
|
+
for (const line of lines) {
|
|
231
|
+
if (REQUIRED_HEADER_RE.test(line)) {
|
|
232
|
+
scope = "required";
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (PREFERRED_HEADER_RE.test(line)) {
|
|
236
|
+
scope = "preferred";
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (OTHER_HEADER_RE.test(line)) {
|
|
240
|
+
scope = null;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (scope === "required") {
|
|
244
|
+
required.push(...tokenize(line));
|
|
245
|
+
} else if (scope === "preferred") {
|
|
246
|
+
preferred.push(...tokenize(line));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { required, preferred };
|
|
250
|
+
}
|
|
251
|
+
var ROLE_NOUN_RE = new RegExp(`(${ROLE_NOUNS.join("|")})`, "gi");
|
|
162
252
|
function extractRoleKeywords(text) {
|
|
163
|
-
const roleMatches = text.match(
|
|
253
|
+
const roleMatches = text.match(ROLE_NOUN_RE) ?? [];
|
|
164
254
|
const fallback = roleMatches.length === 0 ? [text.split(/\n/)[0] ?? ""] : [];
|
|
165
255
|
return unique(tokenize([...roleMatches, ...fallback].join(" ")));
|
|
166
256
|
}
|
|
257
|
+
function extractTitleContextLines(text) {
|
|
258
|
+
const lines = splitLines(text);
|
|
259
|
+
const roleLines = lines.filter((line) => line.match(ROLE_NOUN_RE) !== null);
|
|
260
|
+
return roleLines.length > 0 ? roleLines : lines.slice(0, 1);
|
|
261
|
+
}
|
|
167
262
|
function extractMinExperience(text) {
|
|
168
|
-
const match = text.match(
|
|
263
|
+
const match = text.match(
|
|
264
|
+
/(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i
|
|
265
|
+
);
|
|
169
266
|
if (!match) return void 0;
|
|
170
267
|
const parsed = Number.parseInt(match[1], 10);
|
|
171
268
|
return parsed <= 60 ? parsed : void 0;
|
|
@@ -191,6 +288,38 @@ function isLanguageRequired(lang, jobDescription) {
|
|
|
191
288
|
return LANG_SECTION_RE.test(line) || LANG_REQUIREMENT_HINT_RE.test(line);
|
|
192
289
|
});
|
|
193
290
|
}
|
|
291
|
+
var YEARS_BEFORE_SKILL_RE = /(\d{1,2})\+?\s*years?\s*(?:of|in|with)?\s*((?:[a-z0-9][a-z0-9.#+\-/]*\s*){1,4})/gi;
|
|
292
|
+
var SKILL_BEFORE_YEARS_RE = /((?:[a-z0-9][a-z0-9.#+\-/]*[\s,]*){1,4})\(?(\d{1,2})\+?\s*years?\)?/gi;
|
|
293
|
+
function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
|
|
294
|
+
const requirements = /* @__PURE__ */ new Map();
|
|
295
|
+
const record = (rawWord, years) => {
|
|
296
|
+
if (!Number.isFinite(years) || years <= 0 || years > 60) return;
|
|
297
|
+
const canonical = normalizeSkill(rawWord, aliases);
|
|
298
|
+
if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical))
|
|
299
|
+
return;
|
|
300
|
+
const existing = requirements.get(canonical);
|
|
301
|
+
if (existing === void 0 || years > existing) {
|
|
302
|
+
requirements.set(canonical, years);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
for (const line of lines) {
|
|
306
|
+
for (const re of [YEARS_BEFORE_SKILL_RE, SKILL_BEFORE_YEARS_RE]) {
|
|
307
|
+
re.lastIndex = 0;
|
|
308
|
+
let match;
|
|
309
|
+
while (match = re.exec(line)) {
|
|
310
|
+
const years = Number.parseInt(
|
|
311
|
+
re === YEARS_BEFORE_SKILL_RE ? match[1] : match[2],
|
|
312
|
+
10
|
|
313
|
+
);
|
|
314
|
+
const candidateWords = tokenize(
|
|
315
|
+
re === YEARS_BEFORE_SKILL_RE ? match[2] : match[1]
|
|
316
|
+
);
|
|
317
|
+
for (const word of candidateWords) record(word, years);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return [...requirements.entries()].map(([skill, years]) => ({ skill, years })).sort((a, b) => a.skill.localeCompare(b.skill));
|
|
322
|
+
}
|
|
194
323
|
function extractDegreeLevels(text) {
|
|
195
324
|
const found = /* @__PURE__ */ new Set();
|
|
196
325
|
for (const [pattern, canonical] of DEGREE_VARIANTS) {
|
|
@@ -206,21 +335,47 @@ function parseJobDescription(jobDescription, config) {
|
|
|
206
335
|
skillVocab.add(canonical.toLowerCase());
|
|
207
336
|
for (const alias of aliases) skillVocab.add(alias.toLowerCase());
|
|
208
337
|
}
|
|
209
|
-
for (const s of config.profile?.mandatorySkills ?? [])
|
|
210
|
-
|
|
338
|
+
for (const s of config.profile?.mandatorySkills ?? [])
|
|
339
|
+
skillVocab.add(s.toLowerCase());
|
|
340
|
+
for (const s of config.profile?.optionalSkills ?? [])
|
|
341
|
+
skillVocab.add(s.toLowerCase());
|
|
211
342
|
const isSkillLike = (t) => {
|
|
212
343
|
if (skillVocab.has(t)) return true;
|
|
213
344
|
if (/[.#+]/.test(t) && /[a-z]/.test(t)) return true;
|
|
214
|
-
if (t.includes("/"))
|
|
345
|
+
if (t.includes("/"))
|
|
346
|
+
return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
|
|
215
347
|
return false;
|
|
216
348
|
};
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
349
|
+
const headerScoped = extractHeaderScopedSkills(lines);
|
|
350
|
+
const requiredSkillsRaw = unique([
|
|
351
|
+
...extractRequiredSkills(lines),
|
|
352
|
+
...headerScoped.required
|
|
353
|
+
]).filter(isSkillLike);
|
|
354
|
+
const preferredSkillsRaw = unique([
|
|
355
|
+
...extractPreferredSkills(lines),
|
|
356
|
+
...headerScoped.preferred
|
|
357
|
+
]).filter(isSkillLike);
|
|
358
|
+
const requiredSkills = normalizeSkills(
|
|
359
|
+
requiredSkillsRaw,
|
|
360
|
+
config.skillAliases
|
|
361
|
+
);
|
|
362
|
+
const preferredSkills = normalizeSkills(
|
|
363
|
+
preferredSkillsRaw,
|
|
364
|
+
config.skillAliases
|
|
365
|
+
);
|
|
221
366
|
const bodyTokens = tokenize(normalizedText).filter(isSkillLike);
|
|
222
367
|
const roleKeywords = extractRoleKeywords(jobDescription);
|
|
223
|
-
const keywords = unique([
|
|
368
|
+
const keywords = unique([
|
|
369
|
+
...requiredSkills,
|
|
370
|
+
...preferredSkills,
|
|
371
|
+
...roleKeywords,
|
|
372
|
+
...bodyTokens
|
|
373
|
+
]);
|
|
374
|
+
const skillExperienceRequirements = extractSkillExperienceRequirements(
|
|
375
|
+
lines,
|
|
376
|
+
skillVocab,
|
|
377
|
+
config.skillAliases
|
|
378
|
+
);
|
|
224
379
|
return {
|
|
225
380
|
raw: jobDescription,
|
|
226
381
|
normalizedText,
|
|
@@ -229,13 +384,18 @@ function parseJobDescription(jobDescription, config) {
|
|
|
229
384
|
roleKeywords,
|
|
230
385
|
keywords,
|
|
231
386
|
minExperienceYears: extractMinExperience(jobDescription),
|
|
387
|
+
skillExperienceRequirements,
|
|
232
388
|
educationRequirements: extractDegreeLevels(jobDescription),
|
|
233
|
-
keywordSurfaceForms: collectKeywordSurfaceForms(
|
|
389
|
+
keywordSurfaceForms: collectKeywordSurfaceForms(
|
|
390
|
+
jobDescription,
|
|
391
|
+
config.skillAliases
|
|
392
|
+
),
|
|
234
393
|
// A language only counts as required if its mention carries a requirement/level cue
|
|
235
394
|
// or sits in a "Languages:" line — plain references ("our Berlin office") don't count.
|
|
236
395
|
requiredLanguages: parseLanguageMentions(jobDescription).filter(
|
|
237
396
|
(lang) => isLanguageRequired(lang, jobDescription)
|
|
238
|
-
)
|
|
397
|
+
),
|
|
398
|
+
seniority: inferSeniority(extractTitleContextLines(jobDescription))
|
|
239
399
|
};
|
|
240
400
|
}
|
|
241
401
|
|
|
@@ -333,7 +493,9 @@ function parseDateRange(text, referenceDate) {
|
|
|
333
493
|
}
|
|
334
494
|
const startToken = parseDateToken(rangeMatch[1]);
|
|
335
495
|
const endRaw = rangeMatch[2];
|
|
336
|
-
const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(
|
|
496
|
+
const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(
|
|
497
|
+
endRaw
|
|
498
|
+
);
|
|
337
499
|
const endToken = isPresent ? void 0 : parseDateToken(endRaw);
|
|
338
500
|
if (!startToken) {
|
|
339
501
|
return null;
|
|
@@ -381,27 +543,92 @@ function sumExperienceYears(ranges) {
|
|
|
381
543
|
totalMonths += curEnd - curStart + 1;
|
|
382
544
|
return Number((totalMonths / 12).toFixed(2));
|
|
383
545
|
}
|
|
384
|
-
const months = ranges.reduce(
|
|
546
|
+
const months = ranges.reduce(
|
|
547
|
+
(total, r) => total + (r.durationInMonths ?? 0),
|
|
548
|
+
0
|
|
549
|
+
);
|
|
385
550
|
return Number((months / 12).toFixed(2));
|
|
386
551
|
}
|
|
552
|
+
function detectEmploymentGaps(entries, minGapMonths = 3) {
|
|
553
|
+
const toIndex = (year, month) => year * 12 + month;
|
|
554
|
+
const intervals = entries.filter(
|
|
555
|
+
(e) => e.dates?.startYear !== void 0 && e.dates?.endYear !== void 0
|
|
556
|
+
).map((e) => ({
|
|
557
|
+
s: toIndex(e.dates.startYear, e.dates.startMonth ?? 1),
|
|
558
|
+
e: toIndex(e.dates.endYear, e.dates.endMonth ?? 12),
|
|
559
|
+
label: e.title || e.company || "previous role"
|
|
560
|
+
})).sort((a, b) => a.s - b.s || b.e - a.e);
|
|
561
|
+
if (intervals.length < 2) {
|
|
562
|
+
return [];
|
|
563
|
+
}
|
|
564
|
+
const gaps = [];
|
|
565
|
+
let curEnd = intervals[0].e;
|
|
566
|
+
let curEndLabel = intervals[0].label;
|
|
567
|
+
for (let i = 1; i < intervals.length; i++) {
|
|
568
|
+
const interval = intervals[i];
|
|
569
|
+
if (interval.s <= curEnd + 1) {
|
|
570
|
+
if (interval.e > curEnd) {
|
|
571
|
+
curEnd = interval.e;
|
|
572
|
+
curEndLabel = interval.label;
|
|
573
|
+
}
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const gapMonths = interval.s - curEnd - 1;
|
|
577
|
+
if (gapMonths >= minGapMonths) {
|
|
578
|
+
gaps.push({ afterRole: curEndLabel, months: gapMonths });
|
|
579
|
+
}
|
|
580
|
+
curEnd = interval.e;
|
|
581
|
+
curEndLabel = interval.label;
|
|
582
|
+
}
|
|
583
|
+
return gaps;
|
|
584
|
+
}
|
|
387
585
|
|
|
388
586
|
// src/core/parser/resume.parser.ts
|
|
389
587
|
var SECTION_ALIASES = {
|
|
390
|
-
summary: [
|
|
588
|
+
summary: [
|
|
589
|
+
"summary",
|
|
590
|
+
"profile",
|
|
591
|
+
"about",
|
|
592
|
+
"zusammenfassung",
|
|
593
|
+
"profil",
|
|
594
|
+
"r\xE9sum\xE9",
|
|
595
|
+
"\xE0 propos"
|
|
596
|
+
],
|
|
391
597
|
experience: [
|
|
392
598
|
"experience",
|
|
393
599
|
"work experience",
|
|
394
600
|
"professional experience",
|
|
395
601
|
"employment",
|
|
602
|
+
"work history",
|
|
603
|
+
"employment history",
|
|
396
604
|
"erfahrung",
|
|
397
605
|
"berufserfahrung",
|
|
398
606
|
"exp\xE9rience",
|
|
399
607
|
"exp\xE9rience professionnelle"
|
|
400
608
|
],
|
|
401
|
-
skills: [
|
|
402
|
-
|
|
609
|
+
skills: [
|
|
610
|
+
"skills",
|
|
611
|
+
"technical skills",
|
|
612
|
+
"technologies",
|
|
613
|
+
"f\xE4higkeiten",
|
|
614
|
+
"kenntnisse",
|
|
615
|
+
"comp\xE9tences"
|
|
616
|
+
],
|
|
617
|
+
education: [
|
|
618
|
+
"education",
|
|
619
|
+
"academics",
|
|
620
|
+
"academic background",
|
|
621
|
+
"ausbildung",
|
|
622
|
+
"formation",
|
|
623
|
+
"\xE9tudes"
|
|
624
|
+
],
|
|
403
625
|
projects: ["projects", "portfolio", "projekte", "projets"],
|
|
404
|
-
certifications: [
|
|
626
|
+
certifications: [
|
|
627
|
+
"certifications",
|
|
628
|
+
"licenses",
|
|
629
|
+
"zertifizierungen",
|
|
630
|
+
"certifications professionnelles"
|
|
631
|
+
]
|
|
405
632
|
};
|
|
406
633
|
var STRONG_VERBS = [
|
|
407
634
|
"led",
|
|
@@ -424,28 +651,90 @@ var STRONG_VERBS = [
|
|
|
424
651
|
"reduced",
|
|
425
652
|
"increased"
|
|
426
653
|
];
|
|
427
|
-
var WEAK_VERBS = [
|
|
654
|
+
var WEAK_VERBS = [
|
|
655
|
+
"worked",
|
|
656
|
+
"helped",
|
|
657
|
+
"performed",
|
|
658
|
+
"responsible",
|
|
659
|
+
"assisted",
|
|
660
|
+
"participated",
|
|
661
|
+
"involved"
|
|
662
|
+
];
|
|
663
|
+
var TITLE_SENIORITY_PREFIXES = [
|
|
664
|
+
"senior",
|
|
665
|
+
"lead",
|
|
666
|
+
"principal",
|
|
667
|
+
"staff",
|
|
668
|
+
"vp",
|
|
669
|
+
"director"
|
|
670
|
+
];
|
|
671
|
+
var TITLE_MODIFIER_WORDS = [
|
|
672
|
+
"software",
|
|
673
|
+
"full\\s*stack",
|
|
674
|
+
"frontend",
|
|
675
|
+
"backend"
|
|
676
|
+
];
|
|
677
|
+
var TITLE_PREFIX_WORDS = unique([
|
|
678
|
+
...TITLE_SENIORITY_PREFIXES,
|
|
679
|
+
...TITLE_MODIFIER_WORDS,
|
|
680
|
+
...ROLE_NOUNS
|
|
681
|
+
]);
|
|
682
|
+
var TITLE_RE = new RegExp(`^(${TITLE_PREFIX_WORDS.join("|")})[^,(-]*`, "i");
|
|
428
683
|
var METRIC_RE = /\d|%|\$|\bk\+|\bm\+/i;
|
|
429
684
|
function classifyAchievement(line) {
|
|
430
685
|
const lower = line.toLowerCase();
|
|
431
686
|
const hasMetric = METRIC_RE.test(line);
|
|
432
|
-
const hasStrongVerb = STRONG_VERBS.some(
|
|
433
|
-
|
|
687
|
+
const hasStrongVerb = STRONG_VERBS.some(
|
|
688
|
+
(verb) => new RegExp(`\\b${verb}\\b`).test(lower)
|
|
689
|
+
);
|
|
690
|
+
const hasWeakVerb = WEAK_VERBS.some(
|
|
691
|
+
(verb) => new RegExp(`\\b${verb}\\b`).test(lower)
|
|
692
|
+
);
|
|
434
693
|
if (hasStrongVerb && hasMetric) {
|
|
435
|
-
return {
|
|
694
|
+
return {
|
|
695
|
+
text: line,
|
|
696
|
+
strength: "strong",
|
|
697
|
+
reason: "strong verb + quantified impact"
|
|
698
|
+
};
|
|
436
699
|
}
|
|
437
700
|
if (hasWeakVerb) {
|
|
438
701
|
return { text: line, strength: "weak", reason: "weak verb" };
|
|
439
702
|
}
|
|
440
703
|
return { text: line, strength: "weak", reason: "no quantified impact" };
|
|
441
704
|
}
|
|
705
|
+
var HEADER_TAIL_RE = /^[\s:.\-–—,]*(?:\d{4}\s*(?:[-–—]|to)\s*(?:\d{4}|present|current|now)|\d{4})?[\s:.\-–—,]*$/i;
|
|
706
|
+
var MAX_HEADER_LINE_LENGTH = 60;
|
|
442
707
|
function detectSection(line) {
|
|
443
|
-
const
|
|
708
|
+
const trimmed = line.trim();
|
|
709
|
+
if (!trimmed || trimmed.length > MAX_HEADER_LINE_LENGTH) return null;
|
|
710
|
+
const normalized = trimmed.toLowerCase();
|
|
444
711
|
for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
|
|
445
712
|
for (const alias of aliases) {
|
|
446
713
|
const safeAlias = escapeRegExp(alias);
|
|
447
|
-
const headerPattern = new RegExp(`^${safeAlias}(
|
|
448
|
-
|
|
714
|
+
const headerPattern = new RegExp(`^${safeAlias}(.*)$`, "i");
|
|
715
|
+
const match = normalized.match(headerPattern);
|
|
716
|
+
if (match && HEADER_TAIL_RE.test(match[1])) {
|
|
717
|
+
return section;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const coreLine = normalized.replace(/[\s:.\-–—,]+$/, "");
|
|
722
|
+
const lineWords = coreLine.split(/\s+/).filter(Boolean);
|
|
723
|
+
for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
|
|
724
|
+
for (const alias of aliases) {
|
|
725
|
+
const aliasWords = alias.split(/\s+/);
|
|
726
|
+
if (lineWords.length < aliasWords.length) continue;
|
|
727
|
+
const candidateWords = lineWords.slice(0, aliasWords.length);
|
|
728
|
+
const allFuzzy = aliasWords.every(
|
|
729
|
+
(aliasWord, i) => aliasWord !== candidateWords[i] && fuzzyEqual(stem(aliasWord), stem(candidateWords[i]))
|
|
730
|
+
);
|
|
731
|
+
if (!allFuzzy) continue;
|
|
732
|
+
const prefixPattern = new RegExp(
|
|
733
|
+
`^\\s*${candidateWords.map((w) => escapeRegExp(w)).join("\\s+")}(.*)$`,
|
|
734
|
+
"i"
|
|
735
|
+
);
|
|
736
|
+
const prefixMatch = normalized.match(prefixPattern);
|
|
737
|
+
if (prefixMatch && HEADER_TAIL_RE.test(prefixMatch[1])) {
|
|
449
738
|
return section;
|
|
450
739
|
}
|
|
451
740
|
}
|
|
@@ -477,12 +766,42 @@ function extractSections(text) {
|
|
|
477
766
|
flush();
|
|
478
767
|
return { sections, detected: unique(detected) };
|
|
479
768
|
}
|
|
480
|
-
function
|
|
769
|
+
function parseExplicitSkillList(sectionContent) {
|
|
481
770
|
if (!sectionContent) return [];
|
|
482
771
|
const hasBullets = /[•·‣▪○●◦]/.test(sectionContent);
|
|
483
772
|
const normalized = hasBullets ? sectionContent.replace(/\n/g, " ") : sectionContent;
|
|
484
|
-
|
|
485
|
-
|
|
773
|
+
return normalized.split(/[,;\n]|[•·‣▪○●◦]/).map(
|
|
774
|
+
(skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()
|
|
775
|
+
).filter(Boolean);
|
|
776
|
+
}
|
|
777
|
+
function extractKnownSkillsFromText(text, aliases) {
|
|
778
|
+
if (!text) return [];
|
|
779
|
+
const canonicalSet = new Set(
|
|
780
|
+
Object.keys(aliases).map((c) => c.toLowerCase())
|
|
781
|
+
);
|
|
782
|
+
const found = [];
|
|
783
|
+
for (const token of tokenize(text)) {
|
|
784
|
+
const canonical = normalizeSkill(token, aliases);
|
|
785
|
+
if (canonicalSet.has(canonical)) {
|
|
786
|
+
found.push(canonical);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return unique(found);
|
|
790
|
+
}
|
|
791
|
+
function parseSkills(sections, aliases) {
|
|
792
|
+
const explicit = normalizeSkills(
|
|
793
|
+
parseExplicitSkillList(sections.skills),
|
|
794
|
+
aliases
|
|
795
|
+
);
|
|
796
|
+
const fromExperience = extractKnownSkillsFromText(
|
|
797
|
+
sections.experience,
|
|
798
|
+
aliases
|
|
799
|
+
);
|
|
800
|
+
const fromSummary = extractKnownSkillsFromText(sections.summary, aliases);
|
|
801
|
+
return normalizeSkills(
|
|
802
|
+
unique([...explicit, ...fromExperience, ...fromSummary]),
|
|
803
|
+
aliases
|
|
804
|
+
);
|
|
486
805
|
}
|
|
487
806
|
function parseActionVerbs(text) {
|
|
488
807
|
const words = tokenize(text);
|
|
@@ -491,7 +810,7 @@ function parseActionVerbs(text) {
|
|
|
491
810
|
weak: WEAK_VERBS.filter((verb) => words.includes(verb))
|
|
492
811
|
};
|
|
493
812
|
}
|
|
494
|
-
function parseExperience(sectionContent, referenceDate) {
|
|
813
|
+
function parseExperience(sectionContent, referenceDate, aliases) {
|
|
495
814
|
if (!sectionContent) {
|
|
496
815
|
return { entries: [], rangesInMonths: [], jobTitles: [], achievements: [] };
|
|
497
816
|
}
|
|
@@ -502,23 +821,32 @@ function parseExperience(sectionContent, referenceDate) {
|
|
|
502
821
|
const achievements = [];
|
|
503
822
|
for (const line of lines) {
|
|
504
823
|
const range = parseDateRange(line, referenceDate);
|
|
824
|
+
const titleMatch2 = line.match(TITLE_RE);
|
|
825
|
+
const title = titleMatch2 ? titleMatch2[0].trim() : void 0;
|
|
505
826
|
if (range) {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
827
|
+
if (title) {
|
|
828
|
+
jobTitles.push(title.toLowerCase());
|
|
829
|
+
entries.push({ title, dates: range, description: line, skills: [] });
|
|
509
830
|
} else {
|
|
510
|
-
entries.
|
|
831
|
+
const previous = entries[entries.length - 1];
|
|
832
|
+
if (previous && !previous.dates) {
|
|
833
|
+
previous.dates = range;
|
|
834
|
+
} else {
|
|
835
|
+
entries.push({ dates: range, skills: [] });
|
|
836
|
+
}
|
|
511
837
|
}
|
|
512
838
|
if (range.durationInMonths) {
|
|
513
839
|
rangesInMonths.push(range.durationInMonths);
|
|
514
840
|
}
|
|
515
841
|
continue;
|
|
516
842
|
}
|
|
517
|
-
|
|
518
|
-
if (titleMatch) {
|
|
519
|
-
const title = titleMatch[0].trim();
|
|
843
|
+
if (title) {
|
|
520
844
|
jobTitles.push(title.toLowerCase());
|
|
521
|
-
const entry = {
|
|
845
|
+
const entry = {
|
|
846
|
+
title,
|
|
847
|
+
description: line,
|
|
848
|
+
skills: []
|
|
849
|
+
};
|
|
522
850
|
entries.push(entry);
|
|
523
851
|
achievements.push(classifyAchievement(line));
|
|
524
852
|
continue;
|
|
@@ -529,7 +857,15 @@ function parseExperience(sectionContent, referenceDate) {
|
|
|
529
857
|
}
|
|
530
858
|
achievements.push(classifyAchievement(line));
|
|
531
859
|
}
|
|
532
|
-
|
|
860
|
+
for (const entry of entries) {
|
|
861
|
+
entry.skills = extractKnownSkillsFromText(entry.description, aliases);
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
entries,
|
|
865
|
+
rangesInMonths,
|
|
866
|
+
jobTitles: unique(jobTitles),
|
|
867
|
+
achievements
|
|
868
|
+
};
|
|
533
869
|
}
|
|
534
870
|
function parseEducation(sectionContent) {
|
|
535
871
|
if (!sectionContent) return [];
|
|
@@ -538,12 +874,38 @@ function parseEducation(sectionContent) {
|
|
|
538
874
|
function collectKeywords(text) {
|
|
539
875
|
return unique(tokenize(text));
|
|
540
876
|
}
|
|
877
|
+
var EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i;
|
|
878
|
+
var PHONE_RE = /\+?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}/;
|
|
879
|
+
var LINKEDIN_RE = /(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/(?:in|pub)\/[a-z0-9\-_%]+\/?/i;
|
|
880
|
+
var LOCATION_RE = /^[A-Z][A-Za-z.'-]+(?:\s[A-Z][A-Za-z.'-]+)*,\s*[A-Z][A-Za-z]{1,20}$/;
|
|
881
|
+
function extractLocation(text) {
|
|
882
|
+
const candidateLines = splitLines(text).slice(0, 8);
|
|
883
|
+
for (const line of candidateLines) {
|
|
884
|
+
if (EMAIL_RE.test(line) || /\d/.test(line)) continue;
|
|
885
|
+
if (LOCATION_RE.test(line)) {
|
|
886
|
+
return line;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return void 0;
|
|
890
|
+
}
|
|
891
|
+
function extractContact(text) {
|
|
892
|
+
const email = text.match(EMAIL_RE)?.[0];
|
|
893
|
+
const phone = text.match(PHONE_RE)?.[0]?.trim();
|
|
894
|
+
const linkedin = text.match(LINKEDIN_RE)?.[0];
|
|
895
|
+
const location = extractLocation(text);
|
|
896
|
+
if (!email && !phone && !linkedin && !location) return void 0;
|
|
897
|
+
return { email, phone, linkedin, location };
|
|
898
|
+
}
|
|
541
899
|
function parseResume(resumeText, config) {
|
|
542
900
|
const normalizedText = normalizeWhitespace(resumeText);
|
|
543
901
|
const { sections, detected } = extractSections(resumeText);
|
|
544
|
-
const skills = parseSkills(sections
|
|
902
|
+
const skills = parseSkills(sections, config.skillAliases);
|
|
545
903
|
const actionVerbs = parseActionVerbs(normalizedText);
|
|
546
|
-
const experienceData = parseExperience(
|
|
904
|
+
const experienceData = parseExperience(
|
|
905
|
+
sections.experience,
|
|
906
|
+
config.referenceDate,
|
|
907
|
+
config.skillAliases
|
|
908
|
+
);
|
|
547
909
|
const educationEntries = parseEducation(sections.education);
|
|
548
910
|
let totalExperienceYears = sumExperienceYears(
|
|
549
911
|
experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
|
|
@@ -556,7 +918,12 @@ function parseResume(resumeText, config) {
|
|
|
556
918
|
totalExperienceYears = parsed <= 60 ? parsed : 0;
|
|
557
919
|
}
|
|
558
920
|
}
|
|
559
|
-
const requiredSections = [
|
|
921
|
+
const requiredSections = [
|
|
922
|
+
"summary",
|
|
923
|
+
"experience",
|
|
924
|
+
"skills",
|
|
925
|
+
"education"
|
|
926
|
+
];
|
|
560
927
|
const warnings = [];
|
|
561
928
|
const lineCount = splitLines(resumeText).length;
|
|
562
929
|
if (resumeText.trim().length < 100) {
|
|
@@ -573,6 +940,12 @@ function parseResume(resumeText, config) {
|
|
|
573
940
|
warnings.push(`${section} section not detected`);
|
|
574
941
|
}
|
|
575
942
|
}
|
|
943
|
+
const contact = extractContact(resumeText);
|
|
944
|
+
if (!contact?.email) {
|
|
945
|
+
warnings.push(
|
|
946
|
+
"No email address detected \u2014 most ATS require a parseable contact email"
|
|
947
|
+
);
|
|
948
|
+
}
|
|
576
949
|
return {
|
|
577
950
|
raw: resumeText,
|
|
578
951
|
normalizedText,
|
|
@@ -588,6 +961,10 @@ function parseResume(resumeText, config) {
|
|
|
588
961
|
totalExperienceYears,
|
|
589
962
|
keywords: collectKeywords(normalizedText),
|
|
590
963
|
languages: parseLanguageMentions(resumeText),
|
|
964
|
+
contact,
|
|
965
|
+
seniority: inferSeniority(experienceData.jobTitles),
|
|
966
|
+
employmentGaps: detectEmploymentGaps(experienceData.entries),
|
|
967
|
+
formatting: detectFormatting(resumeText),
|
|
591
968
|
warnings
|
|
592
969
|
};
|
|
593
970
|
}
|
|
@@ -598,7 +975,12 @@ var RuleEngine = class {
|
|
|
598
975
|
this.config = config;
|
|
599
976
|
}
|
|
600
977
|
applySectionPenalties(resume) {
|
|
601
|
-
const requiredSections = [
|
|
978
|
+
const requiredSections = [
|
|
979
|
+
"summary",
|
|
980
|
+
"experience",
|
|
981
|
+
"skills",
|
|
982
|
+
"education"
|
|
983
|
+
];
|
|
602
984
|
let totalPenalty = 0;
|
|
603
985
|
const warnings = [];
|
|
604
986
|
const penaltyBySection = {
|
|
@@ -624,19 +1006,26 @@ var RuleEngine = class {
|
|
|
624
1006
|
const sectionResult = this.applySectionPenalties(input.resume);
|
|
625
1007
|
totalPenalty += sectionResult.totalPenalty;
|
|
626
1008
|
warnings.push(...sectionResult.warnings);
|
|
627
|
-
if (
|
|
1009
|
+
if (input.resume.formatting.hasTables) {
|
|
628
1010
|
totalPenalty += 8;
|
|
629
1011
|
warnings.push("Detected table-like or columnar formatting (penalty 8)");
|
|
630
1012
|
}
|
|
631
1013
|
if (input.overusedKeywords && input.overusedKeywords.length > 0) {
|
|
632
1014
|
const penalty = input.overusedKeywords.length * this.config.keywordDensity.overusePenalty;
|
|
633
1015
|
totalPenalty += penalty;
|
|
634
|
-
warnings.push(
|
|
1016
|
+
warnings.push(
|
|
1017
|
+
`Keyword stuffing detected for: ${input.overusedKeywords.join(", ")} (penalty ${penalty})`
|
|
1018
|
+
);
|
|
635
1019
|
}
|
|
636
1020
|
if (input.resume.detectedSections.length < 3) {
|
|
637
1021
|
totalPenalty += 5;
|
|
638
1022
|
warnings.push("Few recognizable sections found (penalty 5)");
|
|
639
1023
|
}
|
|
1024
|
+
if (!input.resume.contact?.email) {
|
|
1025
|
+
const penalty = this.config.sectionPenalties.missingContact;
|
|
1026
|
+
totalPenalty += penalty;
|
|
1027
|
+
warnings.push(`Missing contact email (penalty ${penalty})`);
|
|
1028
|
+
}
|
|
640
1029
|
return { totalPenalty, warnings };
|
|
641
1030
|
}
|
|
642
1031
|
evaluate(input) {
|
|
@@ -667,9 +1056,31 @@ var RuleEngine = class {
|
|
|
667
1056
|
// src/core/scoring/scorer.ts
|
|
668
1057
|
var REQUIRED_SKILL_WEIGHT = 0.7;
|
|
669
1058
|
var OPTIONAL_SKILL_WEIGHT = 0.3;
|
|
670
|
-
var EXPERIENCE_YEARS_WEIGHT = 0.
|
|
671
|
-
var EXPERIENCE_ROLE_WEIGHT = 0.
|
|
672
|
-
var
|
|
1059
|
+
var EXPERIENCE_YEARS_WEIGHT = 0.65;
|
|
1060
|
+
var EXPERIENCE_ROLE_WEIGHT = 0.2;
|
|
1061
|
+
var EXPERIENCE_SENIORITY_WEIGHT = 0.15;
|
|
1062
|
+
var ALL_CATEGORIES = [
|
|
1063
|
+
"technical",
|
|
1064
|
+
"tool",
|
|
1065
|
+
"concept",
|
|
1066
|
+
"soft",
|
|
1067
|
+
"marketing",
|
|
1068
|
+
"domain"
|
|
1069
|
+
];
|
|
1070
|
+
var SENIORITY_RANK2 = {
|
|
1071
|
+
junior: 0,
|
|
1072
|
+
mid: 1,
|
|
1073
|
+
senior: 2,
|
|
1074
|
+
lead: 3,
|
|
1075
|
+
principal: 4
|
|
1076
|
+
};
|
|
1077
|
+
var DEGREE_RANK = {
|
|
1078
|
+
associate: 1,
|
|
1079
|
+
bachelor: 2,
|
|
1080
|
+
master: 3,
|
|
1081
|
+
mba: 3,
|
|
1082
|
+
phd: 4
|
|
1083
|
+
};
|
|
673
1084
|
function emptyCategoryBuckets() {
|
|
674
1085
|
const buckets = {};
|
|
675
1086
|
for (const category of ALL_CATEGORIES) {
|
|
@@ -677,18 +1088,32 @@ function emptyCategoryBuckets() {
|
|
|
677
1088
|
}
|
|
678
1089
|
return buckets;
|
|
679
1090
|
}
|
|
680
|
-
function scoreSkills(resume, job, config) {
|
|
1091
|
+
function scoreSkills(resume, job, config, matchOptions) {
|
|
681
1092
|
const profileRequired = config.profile?.mandatorySkills ?? [];
|
|
682
1093
|
const profileOptional = config.profile?.optionalSkills ?? [];
|
|
683
1094
|
const required = new Set(
|
|
684
|
-
normalizeSkills(
|
|
1095
|
+
normalizeSkills(
|
|
1096
|
+
[...job.requiredSkills, ...profileRequired],
|
|
1097
|
+
config.skillAliases,
|
|
1098
|
+
matchOptions
|
|
1099
|
+
)
|
|
685
1100
|
);
|
|
686
1101
|
const optional = new Set(
|
|
687
|
-
normalizeSkills(
|
|
1102
|
+
normalizeSkills(
|
|
1103
|
+
[...job.preferredSkills, ...profileOptional],
|
|
1104
|
+
config.skillAliases,
|
|
1105
|
+
matchOptions
|
|
1106
|
+
)
|
|
1107
|
+
);
|
|
1108
|
+
const resumeSkills = new Set(
|
|
1109
|
+
normalizeSkills(resume.skills, config.skillAliases, matchOptions)
|
|
1110
|
+
);
|
|
1111
|
+
const matchedRequired = [...required].filter(
|
|
1112
|
+
(skill) => resumeSkills.has(skill)
|
|
1113
|
+
);
|
|
1114
|
+
const matchedOptional = [...optional].filter(
|
|
1115
|
+
(skill) => resumeSkills.has(skill)
|
|
688
1116
|
);
|
|
689
|
-
const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
|
|
690
|
-
const matchedRequired = [...required].filter((skill) => resumeSkills.has(skill));
|
|
691
|
-
const matchedOptional = [...optional].filter((skill) => resumeSkills.has(skill));
|
|
692
1117
|
const requiredCoverage = required.size === 0 ? 1 : matchedRequired.length / required.size;
|
|
693
1118
|
const optionalCoverage = optional.size === 0 ? 1 : matchedOptional.length / optional.size;
|
|
694
1119
|
const score = clamp(
|
|
@@ -700,29 +1125,45 @@ function scoreSkills(resume, job, config) {
|
|
|
700
1125
|
const missing = [...required].filter((skill) => !resumeSkills.has(skill)).sort();
|
|
701
1126
|
return { score, matched, missing };
|
|
702
1127
|
}
|
|
1128
|
+
function computeSeniorityMatch(resume, job) {
|
|
1129
|
+
const resumeSeniority = resume.seniority;
|
|
1130
|
+
const required = job.seniority;
|
|
1131
|
+
const met = !resumeSeniority || !required || SENIORITY_RANK2[resumeSeniority] >= SENIORITY_RANK2[required];
|
|
1132
|
+
return { resume: resumeSeniority, required, met };
|
|
1133
|
+
}
|
|
703
1134
|
function scoreExperience(resume, job, config) {
|
|
1135
|
+
const seniorityMatch = computeSeniorityMatch(resume, job);
|
|
704
1136
|
const requiredYears = job.minExperienceYears ?? config.profile?.minExperience ?? 0;
|
|
705
1137
|
if (!requiredYears) {
|
|
706
|
-
return { score: 100, missingYears: 0 };
|
|
1138
|
+
return { score: 100, missingYears: 0, seniorityMatch };
|
|
707
1139
|
}
|
|
708
1140
|
const yearCoverage = clamp(resume.totalExperienceYears / requiredYears, 0, 2);
|
|
709
1141
|
const yearsComponent = clamp(yearCoverage, 0, 1) * EXPERIENCE_YEARS_WEIGHT;
|
|
710
|
-
const
|
|
711
|
-
const titleMatches = resume.jobTitles.filter((title) => jobRoleSet.has(title.toLowerCase()));
|
|
712
|
-
const titleCoverage = jobRoleSet.size === 0 ? 1 : titleMatches.length / jobRoleSet.size;
|
|
1142
|
+
const titleCoverage = job.roleKeywords.length === 0 ? 1 : titleMatch(resume.jobTitles, job.roleKeywords);
|
|
713
1143
|
const roleComponent = clamp(titleCoverage, 0, 1) * EXPERIENCE_ROLE_WEIGHT;
|
|
714
|
-
const
|
|
1144
|
+
const seniorityComponent = (seniorityMatch.met ? 1 : 0) * EXPERIENCE_SENIORITY_WEIGHT;
|
|
1145
|
+
const score = clamp(
|
|
1146
|
+
(yearsComponent + roleComponent + seniorityComponent) * 100,
|
|
1147
|
+
0,
|
|
1148
|
+
100
|
|
1149
|
+
);
|
|
715
1150
|
const missingYears = Math.max(requiredYears - resume.totalExperienceYears, 0);
|
|
716
|
-
return {
|
|
1151
|
+
return {
|
|
1152
|
+
score,
|
|
1153
|
+
missingYears: Number(missingYears.toFixed(2)),
|
|
1154
|
+
seniorityMatch
|
|
1155
|
+
};
|
|
717
1156
|
}
|
|
718
1157
|
function keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies) {
|
|
719
1158
|
const base = requiredSet.has(keyword) ? 3 : preferredSet.has(keyword) ? 2 : 1;
|
|
720
1159
|
const freqBonus = Math.min((jdFrequencies[keyword] ?? 1) - 1, 3) * 0.25;
|
|
721
1160
|
return base + freqBonus;
|
|
722
1161
|
}
|
|
723
|
-
function scoreKeywords(resume, job, config) {
|
|
1162
|
+
function scoreKeywords(resume, job, config, matchOptions) {
|
|
724
1163
|
const jobKeywordSet = new Set(
|
|
725
|
-
job.keywords.map(
|
|
1164
|
+
job.keywords.map(
|
|
1165
|
+
(k) => normalizeSkill(k, config.skillAliases, matchOptions)
|
|
1166
|
+
)
|
|
726
1167
|
);
|
|
727
1168
|
if (jobKeywordSet.size === 0) {
|
|
728
1169
|
return {
|
|
@@ -735,20 +1176,32 @@ function scoreKeywords(resume, job, config) {
|
|
|
735
1176
|
};
|
|
736
1177
|
}
|
|
737
1178
|
const resumeTokens = tokenize(resume.normalizedText).map(
|
|
738
|
-
(t) => normalizeSkill(t, config.skillAliases)
|
|
1179
|
+
(t) => normalizeSkill(t, config.skillAliases, matchOptions)
|
|
739
1180
|
);
|
|
740
1181
|
const resumeTokenSet = new Set(resumeTokens);
|
|
741
1182
|
const resumeFrequencies = countFrequencies(resumeTokens);
|
|
742
1183
|
const requiredSet = new Set(job.requiredSkills);
|
|
743
1184
|
const preferredSet = new Set(job.preferredSkills);
|
|
744
1185
|
const jdFrequencies = countFrequencies(
|
|
745
|
-
tokenize(job.normalizedText).map(
|
|
1186
|
+
tokenize(job.normalizedText).map(
|
|
1187
|
+
(t) => normalizeSkill(t, config.skillAliases, matchOptions)
|
|
1188
|
+
)
|
|
746
1189
|
);
|
|
747
1190
|
const weightOf = (keyword) => keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies);
|
|
748
|
-
const matchedKeywords = [...jobKeywordSet].filter(
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const
|
|
1191
|
+
const matchedKeywords = [...jobKeywordSet].filter(
|
|
1192
|
+
(keyword) => resumeTokenSet.has(keyword)
|
|
1193
|
+
);
|
|
1194
|
+
const missingKeywords = [...jobKeywordSet].filter(
|
|
1195
|
+
(keyword) => !resumeTokenSet.has(keyword)
|
|
1196
|
+
);
|
|
1197
|
+
const totalWeight = [...jobKeywordSet].reduce(
|
|
1198
|
+
(sum, keyword) => sum + weightOf(keyword),
|
|
1199
|
+
0
|
|
1200
|
+
);
|
|
1201
|
+
const matchedWeight = matchedKeywords.reduce(
|
|
1202
|
+
(sum, keyword) => sum + weightOf(keyword),
|
|
1203
|
+
0
|
|
1204
|
+
);
|
|
752
1205
|
const score = clamp(matchedWeight / totalWeight * 100, 0, 100);
|
|
753
1206
|
const totalTokens = resumeTokens.length || 1;
|
|
754
1207
|
const overusedKeywords = matchedKeywords.filter((keyword) => {
|
|
@@ -785,38 +1238,158 @@ function scoreKeywords(resume, job, config) {
|
|
|
785
1238
|
keywordWeights
|
|
786
1239
|
};
|
|
787
1240
|
}
|
|
1241
|
+
function computePerSkillExperience(resume, config, matchOptions) {
|
|
1242
|
+
const bySkill = /* @__PURE__ */ new Map();
|
|
1243
|
+
for (const entry of resume.experience) {
|
|
1244
|
+
if (!entry.dates || entry.skills.length === 0) continue;
|
|
1245
|
+
const skillsInRole = normalizeSkills(
|
|
1246
|
+
entry.skills,
|
|
1247
|
+
config.skillAliases,
|
|
1248
|
+
matchOptions
|
|
1249
|
+
);
|
|
1250
|
+
for (const skill of skillsInRole) {
|
|
1251
|
+
const list = bySkill.get(skill) ?? [];
|
|
1252
|
+
list.push(entry.dates);
|
|
1253
|
+
bySkill.set(skill, list);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return [...bySkill.entries()].map(([skill, dates]) => ({ skill, years: sumExperienceYears(dates) })).sort((a, b) => a.skill.localeCompare(b.skill));
|
|
1257
|
+
}
|
|
1258
|
+
function computeSkillExperienceGaps(resume, job, config, matchOptions, perSkillExperience) {
|
|
1259
|
+
if (job.skillExperienceRequirements.length === 0) return [];
|
|
1260
|
+
const resumeSkills = new Set(
|
|
1261
|
+
normalizeSkills(resume.skills, config.skillAliases, matchOptions)
|
|
1262
|
+
);
|
|
1263
|
+
const yearsBySkill = new Map(
|
|
1264
|
+
perSkillExperience.map((p) => [p.skill, p.years])
|
|
1265
|
+
);
|
|
1266
|
+
const gaps = [];
|
|
1267
|
+
for (const { skill, years } of job.skillExperienceRequirements) {
|
|
1268
|
+
if (!resumeSkills.has(skill)) continue;
|
|
1269
|
+
const resumeYears = yearsBySkill.get(skill) ?? 0;
|
|
1270
|
+
if (resumeYears < years) {
|
|
1271
|
+
gaps.push({ skill, requiredYears: years, resumeYears });
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return gaps.sort((a, b) => a.skill.localeCompare(b.skill));
|
|
1275
|
+
}
|
|
788
1276
|
function scoreEducation(resume, job) {
|
|
789
1277
|
if (job.educationRequirements.length === 0) {
|
|
790
1278
|
return 100;
|
|
791
1279
|
}
|
|
792
|
-
const resumeDegreeLevels = extractDegreeLevels(
|
|
793
|
-
|
|
794
|
-
(requirement) => resumeDegreeLevels.includes(requirement)
|
|
1280
|
+
const resumeDegreeLevels = extractDegreeLevels(
|
|
1281
|
+
resume.educationEntries.join(" ")
|
|
795
1282
|
);
|
|
796
|
-
if (
|
|
1283
|
+
if (resumeDegreeLevels.length === 0) {
|
|
797
1284
|
return 0;
|
|
798
1285
|
}
|
|
799
|
-
|
|
1286
|
+
const resumeMaxRank = Math.max(
|
|
1287
|
+
...resumeDegreeLevels.map((d) => DEGREE_RANK[d] ?? 0)
|
|
1288
|
+
);
|
|
1289
|
+
const perRequirementCredit = job.educationRequirements.map(
|
|
1290
|
+
(requirement) => {
|
|
1291
|
+
if (resumeDegreeLevels.includes(requirement)) return 1;
|
|
1292
|
+
const requiredRank = DEGREE_RANK[requirement] ?? 0;
|
|
1293
|
+
const diff = resumeMaxRank - requiredRank;
|
|
1294
|
+
if (diff >= 1) return 0.85;
|
|
1295
|
+
if (diff === 0) return 0.6;
|
|
1296
|
+
if (diff === -1) return 0.35;
|
|
1297
|
+
return 0;
|
|
1298
|
+
}
|
|
1299
|
+
);
|
|
1300
|
+
const avgCredit = perRequirementCredit.reduce((sum, credit) => sum + credit, 0) / perRequirementCredit.length;
|
|
1301
|
+
return clamp(avgCredit * 100, 0, 100);
|
|
1302
|
+
}
|
|
1303
|
+
function scoreParseability(formatting, contact, detectedSections) {
|
|
1304
|
+
let score = 100;
|
|
1305
|
+
const deductions = [];
|
|
1306
|
+
const deduct = (points, reason) => {
|
|
1307
|
+
score -= points;
|
|
1308
|
+
deductions.push({ reason, points });
|
|
1309
|
+
};
|
|
1310
|
+
if (formatting.hasTables) {
|
|
1311
|
+
deduct(
|
|
1312
|
+
20,
|
|
1313
|
+
"Table or columnar formatting detected \u2014 ATS parsers often misalign fields extracted from tables"
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
if (formatting.hasMultiColumn) {
|
|
1317
|
+
deduct(
|
|
1318
|
+
20,
|
|
1319
|
+
"Multi-column layout detected \u2014 text may extract out of reading order in real ATS systems"
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
if (formatting.hasSpecialChars) {
|
|
1323
|
+
deduct(
|
|
1324
|
+
10,
|
|
1325
|
+
"Special or control characters detected \u2014 likely icon fonts or a lossy text extraction"
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
if (formatting.nonStandardBullets) {
|
|
1329
|
+
deduct(
|
|
1330
|
+
8,
|
|
1331
|
+
"Non-standard bullet characters detected \u2014 some ATS parsers won't recognize them as list items"
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
if (formatting.likelyScanned) {
|
|
1335
|
+
deduct(
|
|
1336
|
+
30,
|
|
1337
|
+
"Resume text looks scanned or image-based with little extractable text"
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
if (!contact?.email || !formatting.contactParseable) {
|
|
1341
|
+
deduct(15, "No parseable contact email detected");
|
|
1342
|
+
}
|
|
1343
|
+
if (detectedSections.length < 3) {
|
|
1344
|
+
deduct(10, "Fewer than 3 standard resume sections were detected");
|
|
1345
|
+
}
|
|
1346
|
+
return {
|
|
1347
|
+
score: clamp(score, 0, 100),
|
|
1348
|
+
report: {
|
|
1349
|
+
...formatting,
|
|
1350
|
+
detectedSectionCount: detectedSections.length,
|
|
1351
|
+
deductions
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
800
1354
|
}
|
|
801
1355
|
function calculateScore(resume, job, config) {
|
|
802
|
-
const
|
|
1356
|
+
const matchOptions = {
|
|
1357
|
+
fuzzy: config.matching.fuzzy,
|
|
1358
|
+
maxDistance: config.matching.threshold
|
|
1359
|
+
};
|
|
1360
|
+
const skillsResult = scoreSkills(resume, job, config, matchOptions);
|
|
803
1361
|
const experienceResult = scoreExperience(resume, job, config);
|
|
804
|
-
const keywordResult = scoreKeywords(resume, job, config);
|
|
1362
|
+
const keywordResult = scoreKeywords(resume, job, config, matchOptions);
|
|
805
1363
|
const educationScore = scoreEducation(resume, job);
|
|
1364
|
+
const parseabilityResult = scoreParseability(
|
|
1365
|
+
resume.formatting,
|
|
1366
|
+
resume.contact,
|
|
1367
|
+
resume.detectedSections
|
|
1368
|
+
);
|
|
806
1369
|
const breakdown = {
|
|
807
1370
|
skills: skillsResult.score,
|
|
808
1371
|
experience: experienceResult.score,
|
|
809
1372
|
keywords: keywordResult.score,
|
|
1373
|
+
parseability: parseabilityResult.score,
|
|
810
1374
|
education: educationScore
|
|
811
1375
|
};
|
|
812
|
-
const weightedScore = breakdown.skills * config.weights.skills + breakdown.experience * config.weights.experience + breakdown.keywords * config.weights.keywords + breakdown.education * config.weights.education;
|
|
1376
|
+
const weightedScore = breakdown.skills * config.weights.skills + breakdown.experience * config.weights.experience + breakdown.keywords * config.weights.keywords + breakdown.parseability * config.weights.parseability + breakdown.education * config.weights.education;
|
|
813
1377
|
const achievementStrength = {
|
|
814
1378
|
strong: resume.achievements.filter((a) => a.strength === "strong").length,
|
|
815
1379
|
weak: resume.achievements.filter((a) => a.strength === "weak").length
|
|
816
1380
|
};
|
|
817
|
-
const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(
|
|
818
|
-
|
|
819
|
-
|
|
1381
|
+
const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(resume.languages, job.requiredLanguages);
|
|
1382
|
+
const perSkillExperience = computePerSkillExperience(
|
|
1383
|
+
resume,
|
|
1384
|
+
config,
|
|
1385
|
+
matchOptions
|
|
1386
|
+
);
|
|
1387
|
+
const skillExperienceGaps = computeSkillExperienceGaps(
|
|
1388
|
+
resume,
|
|
1389
|
+
job,
|
|
1390
|
+
config,
|
|
1391
|
+
matchOptions,
|
|
1392
|
+
perSkillExperience
|
|
820
1393
|
);
|
|
821
1394
|
return {
|
|
822
1395
|
score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
|
|
@@ -831,6 +1404,7 @@ function calculateScore(resume, job, config) {
|
|
|
831
1404
|
achievementStrength,
|
|
832
1405
|
matchedLanguages,
|
|
833
1406
|
missingLanguages,
|
|
1407
|
+
skillExperienceGaps,
|
|
834
1408
|
suggestions: [],
|
|
835
1409
|
warnings: [],
|
|
836
1410
|
// detectedSections / parsedExperienceYears / experienceGap / experienceEntries: filled by index.ts
|
|
@@ -839,16 +1413,21 @@ function calculateScore(resume, job, config) {
|
|
|
839
1413
|
parsedExperienceYears: 0,
|
|
840
1414
|
experienceEntries: [],
|
|
841
1415
|
missingExperienceYears: experienceResult.missingYears,
|
|
842
|
-
educationScore
|
|
1416
|
+
educationScore,
|
|
1417
|
+
parseabilityReport: parseabilityResult.report,
|
|
1418
|
+
employmentGaps: resume.employmentGaps,
|
|
1419
|
+
seniorityMatch: experienceResult.seniorityMatch,
|
|
1420
|
+
perSkillExperience
|
|
843
1421
|
};
|
|
844
1422
|
}
|
|
845
1423
|
|
|
846
1424
|
// src/core/scoring/weights.ts
|
|
847
1425
|
var DEFAULT_WEIGHTS = {
|
|
848
|
-
skills: 0.
|
|
849
|
-
experience: 0.
|
|
1426
|
+
skills: 0.25,
|
|
1427
|
+
experience: 0.2,
|
|
850
1428
|
keywords: 0.25,
|
|
851
|
-
|
|
1429
|
+
parseability: 0.2,
|
|
1430
|
+
education: 0.1
|
|
852
1431
|
};
|
|
853
1432
|
var DEFAULT_KEYWORD_DENSITY = {
|
|
854
1433
|
min: 25e-4,
|
|
@@ -859,16 +1438,20 @@ var DEFAULT_SECTION_PENALTIES = {
|
|
|
859
1438
|
missingSummary: 4,
|
|
860
1439
|
missingExperience: 10,
|
|
861
1440
|
missingSkills: 8,
|
|
862
|
-
missingEducation: 6
|
|
1441
|
+
missingEducation: 6,
|
|
1442
|
+
// Raised from 0 (warning-only) in v1 — a real ATS treats an unparseable contact email as a
|
|
1443
|
+
// near-knockout (no way to reach the candidate at all), so this now actually moves the score.
|
|
1444
|
+
missingContact: 12
|
|
863
1445
|
};
|
|
864
1446
|
function normalizeWeights(weights) {
|
|
865
|
-
const total = weights.skills + weights.experience + weights.keywords + weights.education;
|
|
1447
|
+
const total = weights.skills + weights.experience + weights.keywords + weights.parseability + weights.education;
|
|
866
1448
|
if (total === 0) {
|
|
867
|
-
const equal = 1 /
|
|
1449
|
+
const equal = 1 / 5;
|
|
868
1450
|
return {
|
|
869
1451
|
skills: equal,
|
|
870
1452
|
experience: equal,
|
|
871
1453
|
keywords: equal,
|
|
1454
|
+
parseability: equal,
|
|
872
1455
|
education: equal,
|
|
873
1456
|
normalizedTotal: 1
|
|
874
1457
|
};
|
|
@@ -877,6 +1460,7 @@ function normalizeWeights(weights) {
|
|
|
877
1460
|
skills: weights.skills / total,
|
|
878
1461
|
experience: weights.experience / total,
|
|
879
1462
|
keywords: weights.keywords / total,
|
|
1463
|
+
parseability: weights.parseability / total,
|
|
880
1464
|
education: weights.education / total,
|
|
881
1465
|
normalizedTotal: 1
|
|
882
1466
|
};
|
|
@@ -886,12 +1470,19 @@ function resolveConfig(config = {}) {
|
|
|
886
1470
|
skills: config.weights?.skills ?? DEFAULT_WEIGHTS.skills,
|
|
887
1471
|
experience: config.weights?.experience ?? DEFAULT_WEIGHTS.experience,
|
|
888
1472
|
keywords: config.weights?.keywords ?? DEFAULT_WEIGHTS.keywords,
|
|
1473
|
+
parseability: config.weights?.parseability ?? DEFAULT_WEIGHTS.parseability,
|
|
889
1474
|
education: config.weights?.education ?? DEFAULT_WEIGHTS.education
|
|
890
1475
|
};
|
|
891
|
-
const keywordRegistry = mergeKeywordRegistries(
|
|
1476
|
+
const keywordRegistry = mergeKeywordRegistries(
|
|
1477
|
+
defaultKeywordRegistry,
|
|
1478
|
+
config.keywordRegistry ?? []
|
|
1479
|
+
);
|
|
892
1480
|
const resolved = {
|
|
893
1481
|
weights: normalizeWeights(weights),
|
|
894
|
-
skillAliases: {
|
|
1482
|
+
skillAliases: {
|
|
1483
|
+
...deriveSkillAliases(keywordRegistry),
|
|
1484
|
+
...config.skillAliases ?? {}
|
|
1485
|
+
},
|
|
895
1486
|
keywordRegistry,
|
|
896
1487
|
categoryIndex: buildCategoryIndex(keywordRegistry),
|
|
897
1488
|
profile: config.profile ?? softwareEngineerProfile,
|
|
@@ -902,6 +1493,13 @@ function resolveConfig(config = {}) {
|
|
|
902
1493
|
...config.sectionPenalties ?? {}
|
|
903
1494
|
},
|
|
904
1495
|
allowPartialMatches: config.allowPartialMatches ?? true,
|
|
1496
|
+
// Fuzzy/stem matching is on by default in v2 (typos, word-form variants like "developing"
|
|
1497
|
+
// vs "develop", "ReactJS" vs "react") — callers who want v1's exact-match-only behavior can
|
|
1498
|
+
// override via config.matching = { fuzzy: false }.
|
|
1499
|
+
matching: {
|
|
1500
|
+
fuzzy: config.matching?.fuzzy ?? true,
|
|
1501
|
+
threshold: config.matching?.threshold
|
|
1502
|
+
},
|
|
905
1503
|
referenceDate: config.referenceDate ? new Date(config.referenceDate) : void 0
|
|
906
1504
|
};
|
|
907
1505
|
return resolved;
|
|
@@ -914,13 +1512,17 @@ function formatList(values, max = 6) {
|
|
|
914
1512
|
return trimmed.join(", ") + (uniqueValues.length > max ? "..." : "");
|
|
915
1513
|
}
|
|
916
1514
|
function buildAliasReplacementSuggestions(resume, job, config) {
|
|
917
|
-
const jobKeywordSet = new Set(
|
|
1515
|
+
const jobKeywordSet = new Set(
|
|
1516
|
+
job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
|
|
1517
|
+
);
|
|
918
1518
|
const replacements = [];
|
|
919
1519
|
for (const token of unique(tokenize(resume.normalizedText))) {
|
|
920
1520
|
const canonical = normalizeSkill(token, config.skillAliases);
|
|
921
1521
|
const jdSurface = job.keywordSurfaceForms[canonical];
|
|
922
1522
|
if (jdSurface && jobKeywordSet.has(canonical) && jdSurface.toLowerCase() !== token.toLowerCase()) {
|
|
923
|
-
replacements.push(
|
|
1523
|
+
replacements.push(
|
|
1524
|
+
`Replace "${token}" with "${jdSurface}" to match the job description's wording.`
|
|
1525
|
+
);
|
|
924
1526
|
}
|
|
925
1527
|
}
|
|
926
1528
|
return unique(replacements).slice(0, 5);
|
|
@@ -928,7 +1530,10 @@ function buildAliasReplacementSuggestions(resume, job, config) {
|
|
|
928
1530
|
var SuggestionEngine = class {
|
|
929
1531
|
generate(input) {
|
|
930
1532
|
const suggestions = [];
|
|
931
|
-
const warnings = [
|
|
1533
|
+
const warnings = [
|
|
1534
|
+
...input.ruleWarnings,
|
|
1535
|
+
...input.resume.warnings
|
|
1536
|
+
];
|
|
932
1537
|
if (input.score.missingSkills.length > 0) {
|
|
933
1538
|
suggestions.push(
|
|
934
1539
|
`Highlight these required skills: ${formatList(input.score.missingSkills)}`
|
|
@@ -939,7 +1544,9 @@ var SuggestionEngine = class {
|
|
|
939
1544
|
`Incorporate job-specific keywords: ${formatList(input.score.missingKeywords)}`
|
|
940
1545
|
);
|
|
941
1546
|
}
|
|
942
|
-
suggestions.push(
|
|
1547
|
+
suggestions.push(
|
|
1548
|
+
...buildAliasReplacementSuggestions(input.resume, input.job, input.config)
|
|
1549
|
+
);
|
|
943
1550
|
if (input.score.overusedKeywords.length > 0) {
|
|
944
1551
|
suggestions.push(
|
|
945
1552
|
`Avoid keyword stuffing for: ${formatList(input.score.overusedKeywords)}`
|
|
@@ -950,7 +1557,7 @@ var SuggestionEngine = class {
|
|
|
950
1557
|
`Clarify at least ${input.job.minExperienceYears ?? input.score.missingExperienceYears} years of relevant experience with quantified achievements.`
|
|
951
1558
|
);
|
|
952
1559
|
}
|
|
953
|
-
if (input.job.educationRequirements.length > 0 && input.score.educationScore
|
|
1560
|
+
if (input.job.educationRequirements.length > 0 && input.score.educationScore < 60) {
|
|
954
1561
|
suggestions.push(
|
|
955
1562
|
`State your education credentials matching: ${formatList(input.job.educationRequirements)}`
|
|
956
1563
|
);
|
|
@@ -969,7 +1576,9 @@ var SuggestionEngine = class {
|
|
|
969
1576
|
const formatted = input.score.missingLanguages.map((l) => l.level ? `${l.name} (${l.level})` : l.name).join(", ");
|
|
970
1577
|
suggestions.push(`Mention your proficiency in: ${formatted}`);
|
|
971
1578
|
}
|
|
972
|
-
const weakAchievement = input.resume.achievements.find(
|
|
1579
|
+
const weakAchievement = input.resume.achievements.find(
|
|
1580
|
+
(a) => a.strength === "weak"
|
|
1581
|
+
);
|
|
973
1582
|
if (weakAchievement) {
|
|
974
1583
|
suggestions.push(
|
|
975
1584
|
`Strengthen "${weakAchievement.text}" \u2014 add scope/metrics, e.g. "Built and maintained scalable services handling 500k+ requests/day."`
|
|
@@ -980,6 +1589,34 @@ var SuggestionEngine = class {
|
|
|
980
1589
|
"Your resume may use a multi-column layout. Export as a single-column PDF or paste plain text \u2014 most ATS systems and this parser work best with a linear layout."
|
|
981
1590
|
);
|
|
982
1591
|
}
|
|
1592
|
+
if (!input.resume.contact?.email) {
|
|
1593
|
+
suggestions.push(
|
|
1594
|
+
"Add a clearly formatted email address near the top of your resume so ATS and recruiters can contact you."
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
for (const gap of input.score.skillExperienceGaps) {
|
|
1598
|
+
suggestions.push(
|
|
1599
|
+
`The role asks for ${gap.requiredYears}+ years of ${gap.skill}; make that duration explicit in your experience section.`
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
if (input.score.breakdown.parseability < 80) {
|
|
1603
|
+
for (const deduction of input.score.parseabilityReport.deductions) {
|
|
1604
|
+
suggestions.push(`Improve resume parseability: ${deduction.reason}.`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
const GAP_THRESHOLD_MONTHS = 6;
|
|
1608
|
+
for (const gap of input.score.employmentGaps) {
|
|
1609
|
+
if (gap.months >= GAP_THRESHOLD_MONTHS) {
|
|
1610
|
+
suggestions.push(
|
|
1611
|
+
`Address the ${gap.months}-month employment gap after "${gap.afterRole}" \u2014 a brief explanation (upskilling, caregiving, freelance work, etc.) reassures recruiters.`
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
if (!input.score.seniorityMatch.met) {
|
|
1616
|
+
suggestions.push(
|
|
1617
|
+
`This role expects ${input.score.seniorityMatch.required} seniority, but your resume reads as ${input.score.seniorityMatch.resume}; emphasize scope/ownership that reflects the level the role expects.`
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
983
1620
|
return { suggestions, warnings };
|
|
984
1621
|
}
|
|
985
1622
|
};
|
|
@@ -1027,7 +1664,10 @@ var LLMBudgetManager = class {
|
|
|
1027
1664
|
callsUsed: this.callCount,
|
|
1028
1665
|
callsRemaining: Math.max(0, this.limits.maxCalls - this.callCount),
|
|
1029
1666
|
tokensUsed: this.totalTokensUsed,
|
|
1030
|
-
tokensRemaining: Math.max(
|
|
1667
|
+
tokensRemaining: Math.max(
|
|
1668
|
+
0,
|
|
1669
|
+
this.limits.maxTotalTokens - this.totalTokensUsed
|
|
1670
|
+
),
|
|
1031
1671
|
totalCalls: this.limits.maxCalls,
|
|
1032
1672
|
totalTokens: this.limits.maxTotalTokens
|
|
1033
1673
|
};
|
|
@@ -1049,7 +1689,8 @@ var LLMBudgetManager = class {
|
|
|
1049
1689
|
|
|
1050
1690
|
// src/llm/validation.ts
|
|
1051
1691
|
function validateJsonSchema(data, schema) {
|
|
1052
|
-
if (schema.type !== "object" || typeof data !== "object" || data === null)
|
|
1692
|
+
if (schema.type !== "object" || typeof data !== "object" || data === null)
|
|
1693
|
+
return false;
|
|
1053
1694
|
const obj = data;
|
|
1054
1695
|
if (schema.required) {
|
|
1055
1696
|
for (const r of schema.required) {
|
|
@@ -1079,9 +1720,12 @@ function validateJsonSchema(data, schema) {
|
|
|
1079
1720
|
if (items && items.type && Array.isArray(value)) {
|
|
1080
1721
|
const itemType = items.type;
|
|
1081
1722
|
for (const item of value) {
|
|
1082
|
-
if (itemType === "string" && typeof item !== "string")
|
|
1083
|
-
|
|
1084
|
-
if (itemType === "
|
|
1723
|
+
if (itemType === "string" && typeof item !== "string")
|
|
1724
|
+
return false;
|
|
1725
|
+
if (itemType === "number" && typeof item !== "number")
|
|
1726
|
+
return false;
|
|
1727
|
+
if (itemType === "object" && (typeof item !== "object" || item === null))
|
|
1728
|
+
return false;
|
|
1085
1729
|
}
|
|
1086
1730
|
}
|
|
1087
1731
|
break;
|
|
@@ -1108,24 +1752,36 @@ var LLMManager = class {
|
|
|
1108
1752
|
*/
|
|
1109
1753
|
async callLLM(systemPrompt, userPrompt, schema, options = {}) {
|
|
1110
1754
|
const onUnhandled = (reason) => {
|
|
1111
|
-
this.warnings.push(
|
|
1755
|
+
this.warnings.push(
|
|
1756
|
+
`Unhandled rejection during LLM call: ${String(reason)}`
|
|
1757
|
+
);
|
|
1112
1758
|
};
|
|
1113
1759
|
process.on("unhandledRejection", onUnhandled);
|
|
1114
1760
|
let clientPromise = void 0;
|
|
1115
1761
|
try {
|
|
1116
|
-
const estimatedTokens = this.estimateTokens(
|
|
1762
|
+
const estimatedTokens = this.estimateTokens(
|
|
1763
|
+
systemPrompt,
|
|
1764
|
+
userPrompt,
|
|
1765
|
+
options.requestedTokens
|
|
1766
|
+
);
|
|
1117
1767
|
try {
|
|
1118
1768
|
this.budgetManager.assertCanCall(estimatedTokens);
|
|
1119
1769
|
} catch (e) {
|
|
1120
1770
|
const msg = `LLM budget exhausted: ${e.message}`;
|
|
1121
1771
|
this.warnings.push(msg);
|
|
1122
|
-
globalThis.setTimeout(
|
|
1772
|
+
globalThis.setTimeout(
|
|
1773
|
+
() => process.removeListener("unhandledRejection", onUnhandled),
|
|
1774
|
+
100
|
|
1775
|
+
);
|
|
1123
1776
|
return { success: false, fallback: true, error: msg };
|
|
1124
1777
|
}
|
|
1125
1778
|
if (!this.isValidJsonSchema(schema)) {
|
|
1126
1779
|
const msg = "Invalid JSON schema provided";
|
|
1127
1780
|
this.warnings.push(msg);
|
|
1128
|
-
globalThis.setTimeout(
|
|
1781
|
+
globalThis.setTimeout(
|
|
1782
|
+
() => process.removeListener("unhandledRejection", onUnhandled),
|
|
1783
|
+
100
|
|
1784
|
+
);
|
|
1129
1785
|
return { success: false, fallback: true, error: msg };
|
|
1130
1786
|
}
|
|
1131
1787
|
const strictUserPrompt = `${userPrompt}
|
|
@@ -1144,22 +1800,36 @@ No explanations. No markdown. No additional text.`;
|
|
|
1144
1800
|
void clientPromise.catch(() => {
|
|
1145
1801
|
});
|
|
1146
1802
|
const timeoutPromise = new Promise((_, reject) => {
|
|
1147
|
-
const id = globalThis.setTimeout(
|
|
1148
|
-
|
|
1803
|
+
const id = globalThis.setTimeout(
|
|
1804
|
+
() => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)),
|
|
1805
|
+
this.timeoutMs
|
|
1806
|
+
);
|
|
1807
|
+
void clientPromise.finally(() => globalThis.clearTimeout(id)).catch(() => {
|
|
1808
|
+
});
|
|
1149
1809
|
});
|
|
1150
1810
|
void timeoutPromise.catch(() => {
|
|
1151
1811
|
});
|
|
1152
|
-
const response = await Promise.race([
|
|
1812
|
+
const response = await Promise.race([
|
|
1813
|
+
clientPromise,
|
|
1814
|
+
timeoutPromise
|
|
1815
|
+
]);
|
|
1153
1816
|
if (!response || !response.content) {
|
|
1154
1817
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1155
1818
|
try {
|
|
1156
|
-
await Promise.race([
|
|
1157
|
-
|
|
1158
|
-
|
|
1819
|
+
await Promise.race([
|
|
1820
|
+
clientPromise.catch(() => {
|
|
1821
|
+
}),
|
|
1822
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
1823
|
+
]);
|
|
1824
|
+
} catch {
|
|
1159
1825
|
} finally {
|
|
1160
1826
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1161
1827
|
}
|
|
1162
|
-
return {
|
|
1828
|
+
return {
|
|
1829
|
+
success: false,
|
|
1830
|
+
fallback: true,
|
|
1831
|
+
error: "Empty response from LLM"
|
|
1832
|
+
};
|
|
1163
1833
|
}
|
|
1164
1834
|
let parsedContent;
|
|
1165
1835
|
try {
|
|
@@ -1173,9 +1843,12 @@ No explanations. No markdown. No additional text.`;
|
|
|
1173
1843
|
this.warnings.push(msg);
|
|
1174
1844
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1175
1845
|
try {
|
|
1176
|
-
await Promise.race([
|
|
1177
|
-
|
|
1178
|
-
|
|
1846
|
+
await Promise.race([
|
|
1847
|
+
clientPromise.catch(() => {
|
|
1848
|
+
}),
|
|
1849
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
1850
|
+
]);
|
|
1851
|
+
} catch {
|
|
1179
1852
|
} finally {
|
|
1180
1853
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1181
1854
|
}
|
|
@@ -1186,9 +1859,12 @@ No explanations. No markdown. No additional text.`;
|
|
|
1186
1859
|
this.warnings.push(msg);
|
|
1187
1860
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1188
1861
|
try {
|
|
1189
|
-
await Promise.race([
|
|
1190
|
-
|
|
1191
|
-
|
|
1862
|
+
await Promise.race([
|
|
1863
|
+
clientPromise.catch(() => {
|
|
1864
|
+
}),
|
|
1865
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
1866
|
+
]);
|
|
1867
|
+
} catch {
|
|
1192
1868
|
} finally {
|
|
1193
1869
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1194
1870
|
}
|
|
@@ -1208,9 +1884,12 @@ No explanations. No markdown. No additional text.`;
|
|
|
1208
1884
|
this.warnings.push(msg);
|
|
1209
1885
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1210
1886
|
try {
|
|
1211
|
-
await Promise.race([
|
|
1212
|
-
|
|
1213
|
-
|
|
1887
|
+
await Promise.race([
|
|
1888
|
+
clientPromise.catch(() => {
|
|
1889
|
+
}),
|
|
1890
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
1891
|
+
]);
|
|
1892
|
+
} catch {
|
|
1214
1893
|
} finally {
|
|
1215
1894
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1216
1895
|
}
|
|
@@ -1260,7 +1939,7 @@ No explanations. No markdown. No additional text.`;
|
|
|
1260
1939
|
validateAgainstSchema(data, schema) {
|
|
1261
1940
|
try {
|
|
1262
1941
|
return validateJsonSchema(data, schema);
|
|
1263
|
-
} catch
|
|
1942
|
+
} catch {
|
|
1264
1943
|
if (typeof data !== "object" || data === null) {
|
|
1265
1944
|
return false;
|
|
1266
1945
|
}
|
|
@@ -1320,7 +1999,15 @@ var sectionClassificationSchema = {
|
|
|
1320
1999
|
},
|
|
1321
2000
|
classification: {
|
|
1322
2001
|
type: "string",
|
|
1323
|
-
enum: [
|
|
2002
|
+
enum: [
|
|
2003
|
+
"summary",
|
|
2004
|
+
"experience",
|
|
2005
|
+
"skills",
|
|
2006
|
+
"education",
|
|
2007
|
+
"projects",
|
|
2008
|
+
"certifications",
|
|
2009
|
+
"other"
|
|
2010
|
+
],
|
|
1324
2011
|
description: "Classified section type"
|
|
1325
2012
|
},
|
|
1326
2013
|
confidence: {
|
|
@@ -1623,7 +2310,10 @@ function analyzeResume(input) {
|
|
|
1623
2310
|
let suggestions = suggestionResult.suggestions;
|
|
1624
2311
|
const llmWarnings = [];
|
|
1625
2312
|
if (input.llm && suggestionResult.suggestions.length > 0) {
|
|
1626
|
-
const llmResult = enhanceSuggestionsWithLLM(
|
|
2313
|
+
const llmResult = enhanceSuggestionsWithLLM(
|
|
2314
|
+
input.llm,
|
|
2315
|
+
suggestionResult.suggestions
|
|
2316
|
+
);
|
|
1627
2317
|
if (llmResult.success) {
|
|
1628
2318
|
suggestions = llmResult.enhancedSuggestions || suggestions;
|
|
1629
2319
|
}
|
|
@@ -1643,21 +2333,25 @@ function analyzeResume(input) {
|
|
|
1643
2333
|
achievementStrength: scoring.achievementStrength,
|
|
1644
2334
|
matchedLanguages: scoring.matchedLanguages,
|
|
1645
2335
|
missingLanguages: scoring.missingLanguages,
|
|
2336
|
+
skillExperienceGaps: scoring.skillExperienceGaps,
|
|
1646
2337
|
experienceGap: scoring.experienceGap,
|
|
1647
2338
|
detectedSections: parsedResume.detectedSections,
|
|
1648
2339
|
parsedExperienceYears: parsedResume.totalExperienceYears,
|
|
1649
2340
|
experienceEntries: parsedResume.experience,
|
|
2341
|
+
parseabilityReport: scoring.parseabilityReport,
|
|
2342
|
+
employmentGaps: scoring.employmentGaps,
|
|
2343
|
+
seniorityMatch: scoring.seniorityMatch,
|
|
2344
|
+
perSkillExperience: scoring.perSkillExperience,
|
|
1650
2345
|
suggestions,
|
|
1651
2346
|
warnings: [...suggestionResult.warnings, ...llmWarnings]
|
|
1652
2347
|
};
|
|
1653
2348
|
}
|
|
1654
|
-
function enhanceSuggestionsWithLLM(config,
|
|
2349
|
+
function enhanceSuggestionsWithLLM(config, _suggestions) {
|
|
1655
2350
|
if (!config.enable?.suggestions) {
|
|
1656
2351
|
return { success: false, warnings: [] };
|
|
1657
2352
|
}
|
|
1658
2353
|
const warnings = [];
|
|
1659
2354
|
try {
|
|
1660
|
-
const llmManager = new LLMManager(config);
|
|
1661
2355
|
warnings.push(
|
|
1662
2356
|
"LLM suggestion enhancement skipped - use async analyzeResumeAsync for LLM features"
|
|
1663
2357
|
);
|
|
@@ -1714,10 +2408,15 @@ async function analyzeResumeAsync(input) {
|
|
|
1714
2408
|
achievementStrength: scoring.achievementStrength,
|
|
1715
2409
|
matchedLanguages: scoring.matchedLanguages,
|
|
1716
2410
|
missingLanguages: scoring.missingLanguages,
|
|
2411
|
+
skillExperienceGaps: scoring.skillExperienceGaps,
|
|
1717
2412
|
experienceGap: scoring.experienceGap,
|
|
1718
2413
|
detectedSections: parsedResume.detectedSections,
|
|
1719
2414
|
parsedExperienceYears: parsedResume.totalExperienceYears,
|
|
1720
2415
|
experienceEntries: parsedResume.experience,
|
|
2416
|
+
parseabilityReport: scoring.parseabilityReport,
|
|
2417
|
+
employmentGaps: scoring.employmentGaps,
|
|
2418
|
+
seniorityMatch: scoring.seniorityMatch,
|
|
2419
|
+
perSkillExperience: scoring.perSkillExperience,
|
|
1721
2420
|
suggestions,
|
|
1722
2421
|
warnings: [...suggestionResult.warnings, ...llmWarnings]
|
|
1723
2422
|
};
|
|
@@ -1739,13 +2438,19 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
|
|
|
1739
2438
|
if (result.error) {
|
|
1740
2439
|
warnings.push(`LLM suggestion enhancement failed: ${result.error}`);
|
|
1741
2440
|
}
|
|
1742
|
-
return {
|
|
2441
|
+
return {
|
|
2442
|
+
success: false,
|
|
2443
|
+
warnings: [...warnings, ...llmManager.getWarnings()]
|
|
2444
|
+
};
|
|
1743
2445
|
}
|
|
1744
2446
|
const enhanced = adaptSuggestionEnhancementResponse(result.data);
|
|
1745
2447
|
const enhancedSuggestions = enhanced.filter((e) => e.actionable !== false).map((e) => e.enhanced);
|
|
1746
2448
|
if (enhancedSuggestions.length === 0) {
|
|
1747
2449
|
warnings.push("LLM returned no actionable enhanced suggestions");
|
|
1748
|
-
return {
|
|
2450
|
+
return {
|
|
2451
|
+
success: false,
|
|
2452
|
+
warnings: [...warnings, ...llmManager.getWarnings()]
|
|
2453
|
+
};
|
|
1749
2454
|
}
|
|
1750
2455
|
return {
|
|
1751
2456
|
success: true,
|
|
@@ -1753,7 +2458,9 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
|
|
|
1753
2458
|
warnings: llmManager.getWarnings()
|
|
1754
2459
|
};
|
|
1755
2460
|
} catch (e) {
|
|
1756
|
-
warnings.push(
|
|
2461
|
+
warnings.push(
|
|
2462
|
+
`Unexpected error in LLM enhancement: ${e.message}`
|
|
2463
|
+
);
|
|
1757
2464
|
return { success: false, warnings };
|
|
1758
2465
|
}
|
|
1759
2466
|
}
|