@pranavraut033/ats-checker 1.4.0 → 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 -54
- package/dist/chunk-JAFFS7YZ.mjs +1219 -0
- package/dist/chunk-JAFFS7YZ.mjs.map +1 -0
- package/dist/{scoring-pZZR3soa.d.mts → config-CVmXtmvk.d.mts} +186 -90
- package/dist/{scoring-pZZR3soa.d.ts → config-CVmXtmvk.d.ts} +186 -90
- package/dist/index.cjs +1663 -309
- 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 +759 -144
- 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-IY7OVF6Z.mjs +0 -479
- package/dist/chunk-IY7OVF6Z.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ROLE_NOUNS, unique, 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,14 +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
|
+
}
|
|
162
251
|
var ROLE_NOUN_RE = new RegExp(`(${ROLE_NOUNS.join("|")})`, "gi");
|
|
163
252
|
function extractRoleKeywords(text) {
|
|
164
253
|
const roleMatches = text.match(ROLE_NOUN_RE) ?? [];
|
|
165
254
|
const fallback = roleMatches.length === 0 ? [text.split(/\n/)[0] ?? ""] : [];
|
|
166
255
|
return unique(tokenize([...roleMatches, ...fallback].join(" ")));
|
|
167
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
|
+
}
|
|
168
262
|
function extractMinExperience(text) {
|
|
169
|
-
const match = text.match(
|
|
263
|
+
const match = text.match(
|
|
264
|
+
/(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i
|
|
265
|
+
);
|
|
170
266
|
if (!match) return void 0;
|
|
171
267
|
const parsed = Number.parseInt(match[1], 10);
|
|
172
268
|
return parsed <= 60 ? parsed : void 0;
|
|
@@ -199,7 +295,8 @@ function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
|
|
|
199
295
|
const record = (rawWord, years) => {
|
|
200
296
|
if (!Number.isFinite(years) || years <= 0 || years > 60) return;
|
|
201
297
|
const canonical = normalizeSkill(rawWord, aliases);
|
|
202
|
-
if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical))
|
|
298
|
+
if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical))
|
|
299
|
+
return;
|
|
203
300
|
const existing = requirements.get(canonical);
|
|
204
301
|
if (existing === void 0 || years > existing) {
|
|
205
302
|
requirements.set(canonical, years);
|
|
@@ -210,8 +307,13 @@ function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
|
|
|
210
307
|
re.lastIndex = 0;
|
|
211
308
|
let match;
|
|
212
309
|
while (match = re.exec(line)) {
|
|
213
|
-
const years = Number.parseInt(
|
|
214
|
-
|
|
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
|
+
);
|
|
215
317
|
for (const word of candidateWords) record(word, years);
|
|
216
318
|
}
|
|
217
319
|
}
|
|
@@ -233,21 +335,42 @@ function parseJobDescription(jobDescription, config) {
|
|
|
233
335
|
skillVocab.add(canonical.toLowerCase());
|
|
234
336
|
for (const alias of aliases) skillVocab.add(alias.toLowerCase());
|
|
235
337
|
}
|
|
236
|
-
for (const s of config.profile?.mandatorySkills ?? [])
|
|
237
|
-
|
|
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());
|
|
238
342
|
const isSkillLike = (t) => {
|
|
239
343
|
if (skillVocab.has(t)) return true;
|
|
240
344
|
if (/[.#+]/.test(t) && /[a-z]/.test(t)) return true;
|
|
241
|
-
if (t.includes("/"))
|
|
345
|
+
if (t.includes("/"))
|
|
346
|
+
return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
|
|
242
347
|
return false;
|
|
243
348
|
};
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
);
|
|
248
366
|
const bodyTokens = tokenize(normalizedText).filter(isSkillLike);
|
|
249
367
|
const roleKeywords = extractRoleKeywords(jobDescription);
|
|
250
|
-
const keywords = unique([
|
|
368
|
+
const keywords = unique([
|
|
369
|
+
...requiredSkills,
|
|
370
|
+
...preferredSkills,
|
|
371
|
+
...roleKeywords,
|
|
372
|
+
...bodyTokens
|
|
373
|
+
]);
|
|
251
374
|
const skillExperienceRequirements = extractSkillExperienceRequirements(
|
|
252
375
|
lines,
|
|
253
376
|
skillVocab,
|
|
@@ -263,12 +386,16 @@ function parseJobDescription(jobDescription, config) {
|
|
|
263
386
|
minExperienceYears: extractMinExperience(jobDescription),
|
|
264
387
|
skillExperienceRequirements,
|
|
265
388
|
educationRequirements: extractDegreeLevels(jobDescription),
|
|
266
|
-
keywordSurfaceForms: collectKeywordSurfaceForms(
|
|
389
|
+
keywordSurfaceForms: collectKeywordSurfaceForms(
|
|
390
|
+
jobDescription,
|
|
391
|
+
config.skillAliases
|
|
392
|
+
),
|
|
267
393
|
// A language only counts as required if its mention carries a requirement/level cue
|
|
268
394
|
// or sits in a "Languages:" line — plain references ("our Berlin office") don't count.
|
|
269
395
|
requiredLanguages: parseLanguageMentions(jobDescription).filter(
|
|
270
396
|
(lang) => isLanguageRequired(lang, jobDescription)
|
|
271
|
-
)
|
|
397
|
+
),
|
|
398
|
+
seniority: inferSeniority(extractTitleContextLines(jobDescription))
|
|
272
399
|
};
|
|
273
400
|
}
|
|
274
401
|
|
|
@@ -366,7 +493,9 @@ function parseDateRange(text, referenceDate) {
|
|
|
366
493
|
}
|
|
367
494
|
const startToken = parseDateToken(rangeMatch[1]);
|
|
368
495
|
const endRaw = rangeMatch[2];
|
|
369
|
-
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
|
+
);
|
|
370
499
|
const endToken = isPresent ? void 0 : parseDateToken(endRaw);
|
|
371
500
|
if (!startToken) {
|
|
372
501
|
return null;
|
|
@@ -414,27 +543,92 @@ function sumExperienceYears(ranges) {
|
|
|
414
543
|
totalMonths += curEnd - curStart + 1;
|
|
415
544
|
return Number((totalMonths / 12).toFixed(2));
|
|
416
545
|
}
|
|
417
|
-
const months = ranges.reduce(
|
|
546
|
+
const months = ranges.reduce(
|
|
547
|
+
(total, r) => total + (r.durationInMonths ?? 0),
|
|
548
|
+
0
|
|
549
|
+
);
|
|
418
550
|
return Number((months / 12).toFixed(2));
|
|
419
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
|
+
}
|
|
420
585
|
|
|
421
586
|
// src/core/parser/resume.parser.ts
|
|
422
587
|
var SECTION_ALIASES = {
|
|
423
|
-
summary: [
|
|
588
|
+
summary: [
|
|
589
|
+
"summary",
|
|
590
|
+
"profile",
|
|
591
|
+
"about",
|
|
592
|
+
"zusammenfassung",
|
|
593
|
+
"profil",
|
|
594
|
+
"r\xE9sum\xE9",
|
|
595
|
+
"\xE0 propos"
|
|
596
|
+
],
|
|
424
597
|
experience: [
|
|
425
598
|
"experience",
|
|
426
599
|
"work experience",
|
|
427
600
|
"professional experience",
|
|
428
601
|
"employment",
|
|
602
|
+
"work history",
|
|
603
|
+
"employment history",
|
|
429
604
|
"erfahrung",
|
|
430
605
|
"berufserfahrung",
|
|
431
606
|
"exp\xE9rience",
|
|
432
607
|
"exp\xE9rience professionnelle"
|
|
433
608
|
],
|
|
434
|
-
skills: [
|
|
435
|
-
|
|
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
|
+
],
|
|
436
625
|
projects: ["projects", "portfolio", "projekte", "projets"],
|
|
437
|
-
certifications: [
|
|
626
|
+
certifications: [
|
|
627
|
+
"certifications",
|
|
628
|
+
"licenses",
|
|
629
|
+
"zertifizierungen",
|
|
630
|
+
"certifications professionnelles"
|
|
631
|
+
]
|
|
438
632
|
};
|
|
439
633
|
var STRONG_VERBS = [
|
|
440
634
|
"led",
|
|
@@ -457,32 +651,90 @@ var STRONG_VERBS = [
|
|
|
457
651
|
"reduced",
|
|
458
652
|
"increased"
|
|
459
653
|
];
|
|
460
|
-
var WEAK_VERBS = [
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
+
]);
|
|
464
682
|
var TITLE_RE = new RegExp(`^(${TITLE_PREFIX_WORDS.join("|")})[^,(-]*`, "i");
|
|
465
683
|
var METRIC_RE = /\d|%|\$|\bk\+|\bm\+/i;
|
|
466
684
|
function classifyAchievement(line) {
|
|
467
685
|
const lower = line.toLowerCase();
|
|
468
686
|
const hasMetric = METRIC_RE.test(line);
|
|
469
|
-
const hasStrongVerb = STRONG_VERBS.some(
|
|
470
|
-
|
|
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
|
+
);
|
|
471
693
|
if (hasStrongVerb && hasMetric) {
|
|
472
|
-
return {
|
|
694
|
+
return {
|
|
695
|
+
text: line,
|
|
696
|
+
strength: "strong",
|
|
697
|
+
reason: "strong verb + quantified impact"
|
|
698
|
+
};
|
|
473
699
|
}
|
|
474
700
|
if (hasWeakVerb) {
|
|
475
701
|
return { text: line, strength: "weak", reason: "weak verb" };
|
|
476
702
|
}
|
|
477
703
|
return { text: line, strength: "weak", reason: "no quantified impact" };
|
|
478
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;
|
|
479
707
|
function detectSection(line) {
|
|
480
|
-
const
|
|
708
|
+
const trimmed = line.trim();
|
|
709
|
+
if (!trimmed || trimmed.length > MAX_HEADER_LINE_LENGTH) return null;
|
|
710
|
+
const normalized = trimmed.toLowerCase();
|
|
481
711
|
for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
|
|
482
712
|
for (const alias of aliases) {
|
|
483
713
|
const safeAlias = escapeRegExp(alias);
|
|
484
|
-
const headerPattern = new RegExp(`^${safeAlias}(
|
|
485
|
-
|
|
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])) {
|
|
486
738
|
return section;
|
|
487
739
|
}
|
|
488
740
|
}
|
|
@@ -514,12 +766,42 @@ function extractSections(text) {
|
|
|
514
766
|
flush();
|
|
515
767
|
return { sections, detected: unique(detected) };
|
|
516
768
|
}
|
|
517
|
-
function
|
|
769
|
+
function parseExplicitSkillList(sectionContent) {
|
|
518
770
|
if (!sectionContent) return [];
|
|
519
771
|
const hasBullets = /[•·‣▪○●◦]/.test(sectionContent);
|
|
520
772
|
const normalized = hasBullets ? sectionContent.replace(/\n/g, " ") : sectionContent;
|
|
521
|
-
|
|
522
|
-
|
|
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
|
+
);
|
|
523
805
|
}
|
|
524
806
|
function parseActionVerbs(text) {
|
|
525
807
|
const words = tokenize(text);
|
|
@@ -528,7 +810,7 @@ function parseActionVerbs(text) {
|
|
|
528
810
|
weak: WEAK_VERBS.filter((verb) => words.includes(verb))
|
|
529
811
|
};
|
|
530
812
|
}
|
|
531
|
-
function parseExperience(sectionContent, referenceDate) {
|
|
813
|
+
function parseExperience(sectionContent, referenceDate, aliases) {
|
|
532
814
|
if (!sectionContent) {
|
|
533
815
|
return { entries: [], rangesInMonths: [], jobTitles: [], achievements: [] };
|
|
534
816
|
}
|
|
@@ -539,18 +821,18 @@ function parseExperience(sectionContent, referenceDate) {
|
|
|
539
821
|
const achievements = [];
|
|
540
822
|
for (const line of lines) {
|
|
541
823
|
const range = parseDateRange(line, referenceDate);
|
|
542
|
-
const
|
|
543
|
-
const title =
|
|
824
|
+
const titleMatch2 = line.match(TITLE_RE);
|
|
825
|
+
const title = titleMatch2 ? titleMatch2[0].trim() : void 0;
|
|
544
826
|
if (range) {
|
|
545
827
|
if (title) {
|
|
546
828
|
jobTitles.push(title.toLowerCase());
|
|
547
|
-
entries.push({ title, dates: range, description: line });
|
|
829
|
+
entries.push({ title, dates: range, description: line, skills: [] });
|
|
548
830
|
} else {
|
|
549
831
|
const previous = entries[entries.length - 1];
|
|
550
832
|
if (previous && !previous.dates) {
|
|
551
833
|
previous.dates = range;
|
|
552
834
|
} else {
|
|
553
|
-
entries.push({ dates: range });
|
|
835
|
+
entries.push({ dates: range, skills: [] });
|
|
554
836
|
}
|
|
555
837
|
}
|
|
556
838
|
if (range.durationInMonths) {
|
|
@@ -560,7 +842,11 @@ function parseExperience(sectionContent, referenceDate) {
|
|
|
560
842
|
}
|
|
561
843
|
if (title) {
|
|
562
844
|
jobTitles.push(title.toLowerCase());
|
|
563
|
-
const entry = {
|
|
845
|
+
const entry = {
|
|
846
|
+
title,
|
|
847
|
+
description: line,
|
|
848
|
+
skills: []
|
|
849
|
+
};
|
|
564
850
|
entries.push(entry);
|
|
565
851
|
achievements.push(classifyAchievement(line));
|
|
566
852
|
continue;
|
|
@@ -571,7 +857,15 @@ function parseExperience(sectionContent, referenceDate) {
|
|
|
571
857
|
}
|
|
572
858
|
achievements.push(classifyAchievement(line));
|
|
573
859
|
}
|
|
574
|
-
|
|
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
|
+
};
|
|
575
869
|
}
|
|
576
870
|
function parseEducation(sectionContent) {
|
|
577
871
|
if (!sectionContent) return [];
|
|
@@ -582,18 +876,36 @@ function collectKeywords(text) {
|
|
|
582
876
|
}
|
|
583
877
|
var EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i;
|
|
584
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
|
+
}
|
|
585
891
|
function extractContact(text) {
|
|
586
892
|
const email = text.match(EMAIL_RE)?.[0];
|
|
587
893
|
const phone = text.match(PHONE_RE)?.[0]?.trim();
|
|
588
|
-
|
|
589
|
-
|
|
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 };
|
|
590
898
|
}
|
|
591
899
|
function parseResume(resumeText, config) {
|
|
592
900
|
const normalizedText = normalizeWhitespace(resumeText);
|
|
593
901
|
const { sections, detected } = extractSections(resumeText);
|
|
594
|
-
const skills = parseSkills(sections
|
|
902
|
+
const skills = parseSkills(sections, config.skillAliases);
|
|
595
903
|
const actionVerbs = parseActionVerbs(normalizedText);
|
|
596
|
-
const experienceData = parseExperience(
|
|
904
|
+
const experienceData = parseExperience(
|
|
905
|
+
sections.experience,
|
|
906
|
+
config.referenceDate,
|
|
907
|
+
config.skillAliases
|
|
908
|
+
);
|
|
597
909
|
const educationEntries = parseEducation(sections.education);
|
|
598
910
|
let totalExperienceYears = sumExperienceYears(
|
|
599
911
|
experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
|
|
@@ -606,7 +918,12 @@ function parseResume(resumeText, config) {
|
|
|
606
918
|
totalExperienceYears = parsed <= 60 ? parsed : 0;
|
|
607
919
|
}
|
|
608
920
|
}
|
|
609
|
-
const requiredSections = [
|
|
921
|
+
const requiredSections = [
|
|
922
|
+
"summary",
|
|
923
|
+
"experience",
|
|
924
|
+
"skills",
|
|
925
|
+
"education"
|
|
926
|
+
];
|
|
610
927
|
const warnings = [];
|
|
611
928
|
const lineCount = splitLines(resumeText).length;
|
|
612
929
|
if (resumeText.trim().length < 100) {
|
|
@@ -645,6 +962,9 @@ function parseResume(resumeText, config) {
|
|
|
645
962
|
keywords: collectKeywords(normalizedText),
|
|
646
963
|
languages: parseLanguageMentions(resumeText),
|
|
647
964
|
contact,
|
|
965
|
+
seniority: inferSeniority(experienceData.jobTitles),
|
|
966
|
+
employmentGaps: detectEmploymentGaps(experienceData.entries),
|
|
967
|
+
formatting: detectFormatting(resumeText),
|
|
648
968
|
warnings
|
|
649
969
|
};
|
|
650
970
|
}
|
|
@@ -655,7 +975,12 @@ var RuleEngine = class {
|
|
|
655
975
|
this.config = config;
|
|
656
976
|
}
|
|
657
977
|
applySectionPenalties(resume) {
|
|
658
|
-
const requiredSections = [
|
|
978
|
+
const requiredSections = [
|
|
979
|
+
"summary",
|
|
980
|
+
"experience",
|
|
981
|
+
"skills",
|
|
982
|
+
"education"
|
|
983
|
+
];
|
|
659
984
|
let totalPenalty = 0;
|
|
660
985
|
const warnings = [];
|
|
661
986
|
const penaltyBySection = {
|
|
@@ -681,14 +1006,16 @@ var RuleEngine = class {
|
|
|
681
1006
|
const sectionResult = this.applySectionPenalties(input.resume);
|
|
682
1007
|
totalPenalty += sectionResult.totalPenalty;
|
|
683
1008
|
warnings.push(...sectionResult.warnings);
|
|
684
|
-
if (
|
|
1009
|
+
if (input.resume.formatting.hasTables) {
|
|
685
1010
|
totalPenalty += 8;
|
|
686
1011
|
warnings.push("Detected table-like or columnar formatting (penalty 8)");
|
|
687
1012
|
}
|
|
688
1013
|
if (input.overusedKeywords && input.overusedKeywords.length > 0) {
|
|
689
1014
|
const penalty = input.overusedKeywords.length * this.config.keywordDensity.overusePenalty;
|
|
690
1015
|
totalPenalty += penalty;
|
|
691
|
-
warnings.push(
|
|
1016
|
+
warnings.push(
|
|
1017
|
+
`Keyword stuffing detected for: ${input.overusedKeywords.join(", ")} (penalty ${penalty})`
|
|
1018
|
+
);
|
|
692
1019
|
}
|
|
693
1020
|
if (input.resume.detectedSections.length < 3) {
|
|
694
1021
|
totalPenalty += 5;
|
|
@@ -729,9 +1056,31 @@ var RuleEngine = class {
|
|
|
729
1056
|
// src/core/scoring/scorer.ts
|
|
730
1057
|
var REQUIRED_SKILL_WEIGHT = 0.7;
|
|
731
1058
|
var OPTIONAL_SKILL_WEIGHT = 0.3;
|
|
732
|
-
var EXPERIENCE_YEARS_WEIGHT = 0.
|
|
733
|
-
var EXPERIENCE_ROLE_WEIGHT = 0.
|
|
734
|
-
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
|
+
};
|
|
735
1084
|
function emptyCategoryBuckets() {
|
|
736
1085
|
const buckets = {};
|
|
737
1086
|
for (const category of ALL_CATEGORIES) {
|
|
@@ -739,18 +1088,32 @@ function emptyCategoryBuckets() {
|
|
|
739
1088
|
}
|
|
740
1089
|
return buckets;
|
|
741
1090
|
}
|
|
742
|
-
function scoreSkills(resume, job, config) {
|
|
1091
|
+
function scoreSkills(resume, job, config, matchOptions) {
|
|
743
1092
|
const profileRequired = config.profile?.mandatorySkills ?? [];
|
|
744
1093
|
const profileOptional = config.profile?.optionalSkills ?? [];
|
|
745
1094
|
const required = new Set(
|
|
746
|
-
normalizeSkills(
|
|
1095
|
+
normalizeSkills(
|
|
1096
|
+
[...job.requiredSkills, ...profileRequired],
|
|
1097
|
+
config.skillAliases,
|
|
1098
|
+
matchOptions
|
|
1099
|
+
)
|
|
747
1100
|
);
|
|
748
1101
|
const optional = new Set(
|
|
749
|
-
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)
|
|
750
1116
|
);
|
|
751
|
-
const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
|
|
752
|
-
const matchedRequired = [...required].filter((skill) => resumeSkills.has(skill));
|
|
753
|
-
const matchedOptional = [...optional].filter((skill) => resumeSkills.has(skill));
|
|
754
1117
|
const requiredCoverage = required.size === 0 ? 1 : matchedRequired.length / required.size;
|
|
755
1118
|
const optionalCoverage = optional.size === 0 ? 1 : matchedOptional.length / optional.size;
|
|
756
1119
|
const score = clamp(
|
|
@@ -762,30 +1125,45 @@ function scoreSkills(resume, job, config) {
|
|
|
762
1125
|
const missing = [...required].filter((skill) => !resumeSkills.has(skill)).sort();
|
|
763
1126
|
return { score, matched, missing };
|
|
764
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
|
+
}
|
|
765
1134
|
function scoreExperience(resume, job, config) {
|
|
1135
|
+
const seniorityMatch = computeSeniorityMatch(resume, job);
|
|
766
1136
|
const requiredYears = job.minExperienceYears ?? config.profile?.minExperience ?? 0;
|
|
767
1137
|
if (!requiredYears) {
|
|
768
|
-
return { score: 100, missingYears: 0 };
|
|
1138
|
+
return { score: 100, missingYears: 0, seniorityMatch };
|
|
769
1139
|
}
|
|
770
1140
|
const yearCoverage = clamp(resume.totalExperienceYears / requiredYears, 0, 2);
|
|
771
1141
|
const yearsComponent = clamp(yearCoverage, 0, 1) * EXPERIENCE_YEARS_WEIGHT;
|
|
772
|
-
const
|
|
773
|
-
const resumeTitleTokens = new Set(resume.jobTitles.flatMap((t) => tokenize(t)));
|
|
774
|
-
const matchedRoles = job.roleKeywords.filter((rk) => resumeTitleTokens.has(rk.toLowerCase()));
|
|
775
|
-
const titleCoverage = jobRoleSet.size === 0 ? 1 : matchedRoles.length / jobRoleSet.size;
|
|
1142
|
+
const titleCoverage = job.roleKeywords.length === 0 ? 1 : titleMatch(resume.jobTitles, job.roleKeywords);
|
|
776
1143
|
const roleComponent = clamp(titleCoverage, 0, 1) * EXPERIENCE_ROLE_WEIGHT;
|
|
777
|
-
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
|
+
);
|
|
778
1150
|
const missingYears = Math.max(requiredYears - resume.totalExperienceYears, 0);
|
|
779
|
-
return {
|
|
1151
|
+
return {
|
|
1152
|
+
score,
|
|
1153
|
+
missingYears: Number(missingYears.toFixed(2)),
|
|
1154
|
+
seniorityMatch
|
|
1155
|
+
};
|
|
780
1156
|
}
|
|
781
1157
|
function keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies) {
|
|
782
1158
|
const base = requiredSet.has(keyword) ? 3 : preferredSet.has(keyword) ? 2 : 1;
|
|
783
1159
|
const freqBonus = Math.min((jdFrequencies[keyword] ?? 1) - 1, 3) * 0.25;
|
|
784
1160
|
return base + freqBonus;
|
|
785
1161
|
}
|
|
786
|
-
function scoreKeywords(resume, job, config) {
|
|
1162
|
+
function scoreKeywords(resume, job, config, matchOptions) {
|
|
787
1163
|
const jobKeywordSet = new Set(
|
|
788
|
-
job.keywords.map(
|
|
1164
|
+
job.keywords.map(
|
|
1165
|
+
(k) => normalizeSkill(k, config.skillAliases, matchOptions)
|
|
1166
|
+
)
|
|
789
1167
|
);
|
|
790
1168
|
if (jobKeywordSet.size === 0) {
|
|
791
1169
|
return {
|
|
@@ -798,20 +1176,32 @@ function scoreKeywords(resume, job, config) {
|
|
|
798
1176
|
};
|
|
799
1177
|
}
|
|
800
1178
|
const resumeTokens = tokenize(resume.normalizedText).map(
|
|
801
|
-
(t) => normalizeSkill(t, config.skillAliases)
|
|
1179
|
+
(t) => normalizeSkill(t, config.skillAliases, matchOptions)
|
|
802
1180
|
);
|
|
803
1181
|
const resumeTokenSet = new Set(resumeTokens);
|
|
804
1182
|
const resumeFrequencies = countFrequencies(resumeTokens);
|
|
805
1183
|
const requiredSet = new Set(job.requiredSkills);
|
|
806
1184
|
const preferredSet = new Set(job.preferredSkills);
|
|
807
1185
|
const jdFrequencies = countFrequencies(
|
|
808
|
-
tokenize(job.normalizedText).map(
|
|
1186
|
+
tokenize(job.normalizedText).map(
|
|
1187
|
+
(t) => normalizeSkill(t, config.skillAliases, matchOptions)
|
|
1188
|
+
)
|
|
809
1189
|
);
|
|
810
1190
|
const weightOf = (keyword) => keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies);
|
|
811
|
-
const matchedKeywords = [...jobKeywordSet].filter(
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
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
|
+
);
|
|
815
1205
|
const score = clamp(matchedWeight / totalWeight * 100, 0, 100);
|
|
816
1206
|
const totalTokens = resumeTokens.length || 1;
|
|
817
1207
|
const overusedKeywords = matchedKeywords.filter((keyword) => {
|
|
@@ -848,13 +1238,37 @@ function scoreKeywords(resume, job, config) {
|
|
|
848
1238
|
keywordWeights
|
|
849
1239
|
};
|
|
850
1240
|
}
|
|
851
|
-
function
|
|
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) {
|
|
852
1259
|
if (job.skillExperienceRequirements.length === 0) return [];
|
|
853
|
-
const resumeSkills = new Set(
|
|
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
|
+
);
|
|
854
1266
|
const gaps = [];
|
|
855
1267
|
for (const { skill, years } of job.skillExperienceRequirements) {
|
|
856
|
-
if (resumeSkills.has(skill)
|
|
857
|
-
|
|
1268
|
+
if (!resumeSkills.has(skill)) continue;
|
|
1269
|
+
const resumeYears = yearsBySkill.get(skill) ?? 0;
|
|
1270
|
+
if (resumeYears < years) {
|
|
1271
|
+
gaps.push({ skill, requiredYears: years, resumeYears });
|
|
858
1272
|
}
|
|
859
1273
|
}
|
|
860
1274
|
return gaps.sort((a, b) => a.skill.localeCompare(b.skill));
|
|
@@ -863,36 +1277,120 @@ function scoreEducation(resume, job) {
|
|
|
863
1277
|
if (job.educationRequirements.length === 0) {
|
|
864
1278
|
return 100;
|
|
865
1279
|
}
|
|
866
|
-
const resumeDegreeLevels = extractDegreeLevels(
|
|
867
|
-
|
|
868
|
-
(requirement) => resumeDegreeLevels.includes(requirement)
|
|
1280
|
+
const resumeDegreeLevels = extractDegreeLevels(
|
|
1281
|
+
resume.educationEntries.join(" ")
|
|
869
1282
|
);
|
|
870
|
-
if (
|
|
1283
|
+
if (resumeDegreeLevels.length === 0) {
|
|
871
1284
|
return 0;
|
|
872
1285
|
}
|
|
873
|
-
|
|
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
|
+
};
|
|
874
1354
|
}
|
|
875
1355
|
function calculateScore(resume, job, config) {
|
|
876
|
-
const
|
|
1356
|
+
const matchOptions = {
|
|
1357
|
+
fuzzy: config.matching.fuzzy,
|
|
1358
|
+
maxDistance: config.matching.threshold
|
|
1359
|
+
};
|
|
1360
|
+
const skillsResult = scoreSkills(resume, job, config, matchOptions);
|
|
877
1361
|
const experienceResult = scoreExperience(resume, job, config);
|
|
878
|
-
const keywordResult = scoreKeywords(resume, job, config);
|
|
1362
|
+
const keywordResult = scoreKeywords(resume, job, config, matchOptions);
|
|
879
1363
|
const educationScore = scoreEducation(resume, job);
|
|
1364
|
+
const parseabilityResult = scoreParseability(
|
|
1365
|
+
resume.formatting,
|
|
1366
|
+
resume.contact,
|
|
1367
|
+
resume.detectedSections
|
|
1368
|
+
);
|
|
880
1369
|
const breakdown = {
|
|
881
1370
|
skills: skillsResult.score,
|
|
882
1371
|
experience: experienceResult.score,
|
|
883
1372
|
keywords: keywordResult.score,
|
|
1373
|
+
parseability: parseabilityResult.score,
|
|
884
1374
|
education: educationScore
|
|
885
1375
|
};
|
|
886
|
-
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;
|
|
887
1377
|
const achievementStrength = {
|
|
888
1378
|
strong: resume.achievements.filter((a) => a.strength === "strong").length,
|
|
889
1379
|
weak: resume.achievements.filter((a) => a.strength === "weak").length
|
|
890
1380
|
};
|
|
891
|
-
const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(
|
|
892
|
-
|
|
893
|
-
|
|
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
|
|
894
1393
|
);
|
|
895
|
-
const skillExperienceGaps = computeSkillExperienceGaps(resume, job, config);
|
|
896
1394
|
return {
|
|
897
1395
|
score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
|
|
898
1396
|
breakdown,
|
|
@@ -915,16 +1413,21 @@ function calculateScore(resume, job, config) {
|
|
|
915
1413
|
parsedExperienceYears: 0,
|
|
916
1414
|
experienceEntries: [],
|
|
917
1415
|
missingExperienceYears: experienceResult.missingYears,
|
|
918
|
-
educationScore
|
|
1416
|
+
educationScore,
|
|
1417
|
+
parseabilityReport: parseabilityResult.report,
|
|
1418
|
+
employmentGaps: resume.employmentGaps,
|
|
1419
|
+
seniorityMatch: experienceResult.seniorityMatch,
|
|
1420
|
+
perSkillExperience
|
|
919
1421
|
};
|
|
920
1422
|
}
|
|
921
1423
|
|
|
922
1424
|
// src/core/scoring/weights.ts
|
|
923
1425
|
var DEFAULT_WEIGHTS = {
|
|
924
|
-
skills: 0.
|
|
925
|
-
experience: 0.
|
|
1426
|
+
skills: 0.25,
|
|
1427
|
+
experience: 0.2,
|
|
926
1428
|
keywords: 0.25,
|
|
927
|
-
|
|
1429
|
+
parseability: 0.2,
|
|
1430
|
+
education: 0.1
|
|
928
1431
|
};
|
|
929
1432
|
var DEFAULT_KEYWORD_DENSITY = {
|
|
930
1433
|
min: 25e-4,
|
|
@@ -936,18 +1439,19 @@ var DEFAULT_SECTION_PENALTIES = {
|
|
|
936
1439
|
missingExperience: 10,
|
|
937
1440
|
missingSkills: 8,
|
|
938
1441
|
missingEducation: 6,
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
missingContact:
|
|
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
|
|
942
1445
|
};
|
|
943
1446
|
function normalizeWeights(weights) {
|
|
944
|
-
const total = weights.skills + weights.experience + weights.keywords + weights.education;
|
|
1447
|
+
const total = weights.skills + weights.experience + weights.keywords + weights.parseability + weights.education;
|
|
945
1448
|
if (total === 0) {
|
|
946
|
-
const equal = 1 /
|
|
1449
|
+
const equal = 1 / 5;
|
|
947
1450
|
return {
|
|
948
1451
|
skills: equal,
|
|
949
1452
|
experience: equal,
|
|
950
1453
|
keywords: equal,
|
|
1454
|
+
parseability: equal,
|
|
951
1455
|
education: equal,
|
|
952
1456
|
normalizedTotal: 1
|
|
953
1457
|
};
|
|
@@ -956,6 +1460,7 @@ function normalizeWeights(weights) {
|
|
|
956
1460
|
skills: weights.skills / total,
|
|
957
1461
|
experience: weights.experience / total,
|
|
958
1462
|
keywords: weights.keywords / total,
|
|
1463
|
+
parseability: weights.parseability / total,
|
|
959
1464
|
education: weights.education / total,
|
|
960
1465
|
normalizedTotal: 1
|
|
961
1466
|
};
|
|
@@ -965,12 +1470,19 @@ function resolveConfig(config = {}) {
|
|
|
965
1470
|
skills: config.weights?.skills ?? DEFAULT_WEIGHTS.skills,
|
|
966
1471
|
experience: config.weights?.experience ?? DEFAULT_WEIGHTS.experience,
|
|
967
1472
|
keywords: config.weights?.keywords ?? DEFAULT_WEIGHTS.keywords,
|
|
1473
|
+
parseability: config.weights?.parseability ?? DEFAULT_WEIGHTS.parseability,
|
|
968
1474
|
education: config.weights?.education ?? DEFAULT_WEIGHTS.education
|
|
969
1475
|
};
|
|
970
|
-
const keywordRegistry = mergeKeywordRegistries(
|
|
1476
|
+
const keywordRegistry = mergeKeywordRegistries(
|
|
1477
|
+
defaultKeywordRegistry,
|
|
1478
|
+
config.keywordRegistry ?? []
|
|
1479
|
+
);
|
|
971
1480
|
const resolved = {
|
|
972
1481
|
weights: normalizeWeights(weights),
|
|
973
|
-
skillAliases: {
|
|
1482
|
+
skillAliases: {
|
|
1483
|
+
...deriveSkillAliases(keywordRegistry),
|
|
1484
|
+
...config.skillAliases ?? {}
|
|
1485
|
+
},
|
|
974
1486
|
keywordRegistry,
|
|
975
1487
|
categoryIndex: buildCategoryIndex(keywordRegistry),
|
|
976
1488
|
profile: config.profile ?? softwareEngineerProfile,
|
|
@@ -981,6 +1493,13 @@ function resolveConfig(config = {}) {
|
|
|
981
1493
|
...config.sectionPenalties ?? {}
|
|
982
1494
|
},
|
|
983
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
|
+
},
|
|
984
1503
|
referenceDate: config.referenceDate ? new Date(config.referenceDate) : void 0
|
|
985
1504
|
};
|
|
986
1505
|
return resolved;
|
|
@@ -993,13 +1512,17 @@ function formatList(values, max = 6) {
|
|
|
993
1512
|
return trimmed.join(", ") + (uniqueValues.length > max ? "..." : "");
|
|
994
1513
|
}
|
|
995
1514
|
function buildAliasReplacementSuggestions(resume, job, config) {
|
|
996
|
-
const jobKeywordSet = new Set(
|
|
1515
|
+
const jobKeywordSet = new Set(
|
|
1516
|
+
job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
|
|
1517
|
+
);
|
|
997
1518
|
const replacements = [];
|
|
998
1519
|
for (const token of unique(tokenize(resume.normalizedText))) {
|
|
999
1520
|
const canonical = normalizeSkill(token, config.skillAliases);
|
|
1000
1521
|
const jdSurface = job.keywordSurfaceForms[canonical];
|
|
1001
1522
|
if (jdSurface && jobKeywordSet.has(canonical) && jdSurface.toLowerCase() !== token.toLowerCase()) {
|
|
1002
|
-
replacements.push(
|
|
1523
|
+
replacements.push(
|
|
1524
|
+
`Replace "${token}" with "${jdSurface}" to match the job description's wording.`
|
|
1525
|
+
);
|
|
1003
1526
|
}
|
|
1004
1527
|
}
|
|
1005
1528
|
return unique(replacements).slice(0, 5);
|
|
@@ -1007,7 +1530,10 @@ function buildAliasReplacementSuggestions(resume, job, config) {
|
|
|
1007
1530
|
var SuggestionEngine = class {
|
|
1008
1531
|
generate(input) {
|
|
1009
1532
|
const suggestions = [];
|
|
1010
|
-
const warnings = [
|
|
1533
|
+
const warnings = [
|
|
1534
|
+
...input.ruleWarnings,
|
|
1535
|
+
...input.resume.warnings
|
|
1536
|
+
];
|
|
1011
1537
|
if (input.score.missingSkills.length > 0) {
|
|
1012
1538
|
suggestions.push(
|
|
1013
1539
|
`Highlight these required skills: ${formatList(input.score.missingSkills)}`
|
|
@@ -1018,7 +1544,9 @@ var SuggestionEngine = class {
|
|
|
1018
1544
|
`Incorporate job-specific keywords: ${formatList(input.score.missingKeywords)}`
|
|
1019
1545
|
);
|
|
1020
1546
|
}
|
|
1021
|
-
suggestions.push(
|
|
1547
|
+
suggestions.push(
|
|
1548
|
+
...buildAliasReplacementSuggestions(input.resume, input.job, input.config)
|
|
1549
|
+
);
|
|
1022
1550
|
if (input.score.overusedKeywords.length > 0) {
|
|
1023
1551
|
suggestions.push(
|
|
1024
1552
|
`Avoid keyword stuffing for: ${formatList(input.score.overusedKeywords)}`
|
|
@@ -1029,7 +1557,7 @@ var SuggestionEngine = class {
|
|
|
1029
1557
|
`Clarify at least ${input.job.minExperienceYears ?? input.score.missingExperienceYears} years of relevant experience with quantified achievements.`
|
|
1030
1558
|
);
|
|
1031
1559
|
}
|
|
1032
|
-
if (input.job.educationRequirements.length > 0 && input.score.educationScore
|
|
1560
|
+
if (input.job.educationRequirements.length > 0 && input.score.educationScore < 60) {
|
|
1033
1561
|
suggestions.push(
|
|
1034
1562
|
`State your education credentials matching: ${formatList(input.job.educationRequirements)}`
|
|
1035
1563
|
);
|
|
@@ -1048,7 +1576,9 @@ var SuggestionEngine = class {
|
|
|
1048
1576
|
const formatted = input.score.missingLanguages.map((l) => l.level ? `${l.name} (${l.level})` : l.name).join(", ");
|
|
1049
1577
|
suggestions.push(`Mention your proficiency in: ${formatted}`);
|
|
1050
1578
|
}
|
|
1051
|
-
const weakAchievement = input.resume.achievements.find(
|
|
1579
|
+
const weakAchievement = input.resume.achievements.find(
|
|
1580
|
+
(a) => a.strength === "weak"
|
|
1581
|
+
);
|
|
1052
1582
|
if (weakAchievement) {
|
|
1053
1583
|
suggestions.push(
|
|
1054
1584
|
`Strengthen "${weakAchievement.text}" \u2014 add scope/metrics, e.g. "Built and maintained scalable services handling 500k+ requests/day."`
|
|
@@ -1069,6 +1599,24 @@ var SuggestionEngine = class {
|
|
|
1069
1599
|
`The role asks for ${gap.requiredYears}+ years of ${gap.skill}; make that duration explicit in your experience section.`
|
|
1070
1600
|
);
|
|
1071
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
|
+
}
|
|
1072
1620
|
return { suggestions, warnings };
|
|
1073
1621
|
}
|
|
1074
1622
|
};
|
|
@@ -1116,7 +1664,10 @@ var LLMBudgetManager = class {
|
|
|
1116
1664
|
callsUsed: this.callCount,
|
|
1117
1665
|
callsRemaining: Math.max(0, this.limits.maxCalls - this.callCount),
|
|
1118
1666
|
tokensUsed: this.totalTokensUsed,
|
|
1119
|
-
tokensRemaining: Math.max(
|
|
1667
|
+
tokensRemaining: Math.max(
|
|
1668
|
+
0,
|
|
1669
|
+
this.limits.maxTotalTokens - this.totalTokensUsed
|
|
1670
|
+
),
|
|
1120
1671
|
totalCalls: this.limits.maxCalls,
|
|
1121
1672
|
totalTokens: this.limits.maxTotalTokens
|
|
1122
1673
|
};
|
|
@@ -1138,7 +1689,8 @@ var LLMBudgetManager = class {
|
|
|
1138
1689
|
|
|
1139
1690
|
// src/llm/validation.ts
|
|
1140
1691
|
function validateJsonSchema(data, schema) {
|
|
1141
|
-
if (schema.type !== "object" || typeof data !== "object" || data === null)
|
|
1692
|
+
if (schema.type !== "object" || typeof data !== "object" || data === null)
|
|
1693
|
+
return false;
|
|
1142
1694
|
const obj = data;
|
|
1143
1695
|
if (schema.required) {
|
|
1144
1696
|
for (const r of schema.required) {
|
|
@@ -1168,9 +1720,12 @@ function validateJsonSchema(data, schema) {
|
|
|
1168
1720
|
if (items && items.type && Array.isArray(value)) {
|
|
1169
1721
|
const itemType = items.type;
|
|
1170
1722
|
for (const item of value) {
|
|
1171
|
-
if (itemType === "string" && typeof item !== "string")
|
|
1172
|
-
|
|
1173
|
-
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;
|
|
1174
1729
|
}
|
|
1175
1730
|
}
|
|
1176
1731
|
break;
|
|
@@ -1197,24 +1752,36 @@ var LLMManager = class {
|
|
|
1197
1752
|
*/
|
|
1198
1753
|
async callLLM(systemPrompt, userPrompt, schema, options = {}) {
|
|
1199
1754
|
const onUnhandled = (reason) => {
|
|
1200
|
-
this.warnings.push(
|
|
1755
|
+
this.warnings.push(
|
|
1756
|
+
`Unhandled rejection during LLM call: ${String(reason)}`
|
|
1757
|
+
);
|
|
1201
1758
|
};
|
|
1202
1759
|
process.on("unhandledRejection", onUnhandled);
|
|
1203
1760
|
let clientPromise = void 0;
|
|
1204
1761
|
try {
|
|
1205
|
-
const estimatedTokens = this.estimateTokens(
|
|
1762
|
+
const estimatedTokens = this.estimateTokens(
|
|
1763
|
+
systemPrompt,
|
|
1764
|
+
userPrompt,
|
|
1765
|
+
options.requestedTokens
|
|
1766
|
+
);
|
|
1206
1767
|
try {
|
|
1207
1768
|
this.budgetManager.assertCanCall(estimatedTokens);
|
|
1208
1769
|
} catch (e) {
|
|
1209
1770
|
const msg = `LLM budget exhausted: ${e.message}`;
|
|
1210
1771
|
this.warnings.push(msg);
|
|
1211
|
-
globalThis.setTimeout(
|
|
1772
|
+
globalThis.setTimeout(
|
|
1773
|
+
() => process.removeListener("unhandledRejection", onUnhandled),
|
|
1774
|
+
100
|
|
1775
|
+
);
|
|
1212
1776
|
return { success: false, fallback: true, error: msg };
|
|
1213
1777
|
}
|
|
1214
1778
|
if (!this.isValidJsonSchema(schema)) {
|
|
1215
1779
|
const msg = "Invalid JSON schema provided";
|
|
1216
1780
|
this.warnings.push(msg);
|
|
1217
|
-
globalThis.setTimeout(
|
|
1781
|
+
globalThis.setTimeout(
|
|
1782
|
+
() => process.removeListener("unhandledRejection", onUnhandled),
|
|
1783
|
+
100
|
|
1784
|
+
);
|
|
1218
1785
|
return { success: false, fallback: true, error: msg };
|
|
1219
1786
|
}
|
|
1220
1787
|
const strictUserPrompt = `${userPrompt}
|
|
@@ -1233,23 +1800,36 @@ No explanations. No markdown. No additional text.`;
|
|
|
1233
1800
|
void clientPromise.catch(() => {
|
|
1234
1801
|
});
|
|
1235
1802
|
const timeoutPromise = new Promise((_, reject) => {
|
|
1236
|
-
const id = globalThis.setTimeout(
|
|
1803
|
+
const id = globalThis.setTimeout(
|
|
1804
|
+
() => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)),
|
|
1805
|
+
this.timeoutMs
|
|
1806
|
+
);
|
|
1237
1807
|
void clientPromise.finally(() => globalThis.clearTimeout(id)).catch(() => {
|
|
1238
1808
|
});
|
|
1239
1809
|
});
|
|
1240
1810
|
void timeoutPromise.catch(() => {
|
|
1241
1811
|
});
|
|
1242
|
-
const response = await Promise.race([
|
|
1812
|
+
const response = await Promise.race([
|
|
1813
|
+
clientPromise,
|
|
1814
|
+
timeoutPromise
|
|
1815
|
+
]);
|
|
1243
1816
|
if (!response || !response.content) {
|
|
1244
1817
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1245
1818
|
try {
|
|
1246
|
-
await Promise.race([
|
|
1247
|
-
|
|
1248
|
-
|
|
1819
|
+
await Promise.race([
|
|
1820
|
+
clientPromise.catch(() => {
|
|
1821
|
+
}),
|
|
1822
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
1823
|
+
]);
|
|
1824
|
+
} catch {
|
|
1249
1825
|
} finally {
|
|
1250
1826
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1251
1827
|
}
|
|
1252
|
-
return {
|
|
1828
|
+
return {
|
|
1829
|
+
success: false,
|
|
1830
|
+
fallback: true,
|
|
1831
|
+
error: "Empty response from LLM"
|
|
1832
|
+
};
|
|
1253
1833
|
}
|
|
1254
1834
|
let parsedContent;
|
|
1255
1835
|
try {
|
|
@@ -1263,9 +1843,12 @@ No explanations. No markdown. No additional text.`;
|
|
|
1263
1843
|
this.warnings.push(msg);
|
|
1264
1844
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1265
1845
|
try {
|
|
1266
|
-
await Promise.race([
|
|
1267
|
-
|
|
1268
|
-
|
|
1846
|
+
await Promise.race([
|
|
1847
|
+
clientPromise.catch(() => {
|
|
1848
|
+
}),
|
|
1849
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
1850
|
+
]);
|
|
1851
|
+
} catch {
|
|
1269
1852
|
} finally {
|
|
1270
1853
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1271
1854
|
}
|
|
@@ -1276,9 +1859,12 @@ No explanations. No markdown. No additional text.`;
|
|
|
1276
1859
|
this.warnings.push(msg);
|
|
1277
1860
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1278
1861
|
try {
|
|
1279
|
-
await Promise.race([
|
|
1280
|
-
|
|
1281
|
-
|
|
1862
|
+
await Promise.race([
|
|
1863
|
+
clientPromise.catch(() => {
|
|
1864
|
+
}),
|
|
1865
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
1866
|
+
]);
|
|
1867
|
+
} catch {
|
|
1282
1868
|
} finally {
|
|
1283
1869
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1284
1870
|
}
|
|
@@ -1298,9 +1884,12 @@ No explanations. No markdown. No additional text.`;
|
|
|
1298
1884
|
this.warnings.push(msg);
|
|
1299
1885
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1300
1886
|
try {
|
|
1301
|
-
await Promise.race([
|
|
1302
|
-
|
|
1303
|
-
|
|
1887
|
+
await Promise.race([
|
|
1888
|
+
clientPromise.catch(() => {
|
|
1889
|
+
}),
|
|
1890
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
1891
|
+
]);
|
|
1892
|
+
} catch {
|
|
1304
1893
|
} finally {
|
|
1305
1894
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1306
1895
|
}
|
|
@@ -1350,7 +1939,7 @@ No explanations. No markdown. No additional text.`;
|
|
|
1350
1939
|
validateAgainstSchema(data, schema) {
|
|
1351
1940
|
try {
|
|
1352
1941
|
return validateJsonSchema(data, schema);
|
|
1353
|
-
} catch
|
|
1942
|
+
} catch {
|
|
1354
1943
|
if (typeof data !== "object" || data === null) {
|
|
1355
1944
|
return false;
|
|
1356
1945
|
}
|
|
@@ -1410,7 +1999,15 @@ var sectionClassificationSchema = {
|
|
|
1410
1999
|
},
|
|
1411
2000
|
classification: {
|
|
1412
2001
|
type: "string",
|
|
1413
|
-
enum: [
|
|
2002
|
+
enum: [
|
|
2003
|
+
"summary",
|
|
2004
|
+
"experience",
|
|
2005
|
+
"skills",
|
|
2006
|
+
"education",
|
|
2007
|
+
"projects",
|
|
2008
|
+
"certifications",
|
|
2009
|
+
"other"
|
|
2010
|
+
],
|
|
1414
2011
|
description: "Classified section type"
|
|
1415
2012
|
},
|
|
1416
2013
|
confidence: {
|
|
@@ -1713,7 +2310,10 @@ function analyzeResume(input) {
|
|
|
1713
2310
|
let suggestions = suggestionResult.suggestions;
|
|
1714
2311
|
const llmWarnings = [];
|
|
1715
2312
|
if (input.llm && suggestionResult.suggestions.length > 0) {
|
|
1716
|
-
const llmResult = enhanceSuggestionsWithLLM(
|
|
2313
|
+
const llmResult = enhanceSuggestionsWithLLM(
|
|
2314
|
+
input.llm,
|
|
2315
|
+
suggestionResult.suggestions
|
|
2316
|
+
);
|
|
1717
2317
|
if (llmResult.success) {
|
|
1718
2318
|
suggestions = llmResult.enhancedSuggestions || suggestions;
|
|
1719
2319
|
}
|
|
@@ -1738,17 +2338,20 @@ function analyzeResume(input) {
|
|
|
1738
2338
|
detectedSections: parsedResume.detectedSections,
|
|
1739
2339
|
parsedExperienceYears: parsedResume.totalExperienceYears,
|
|
1740
2340
|
experienceEntries: parsedResume.experience,
|
|
2341
|
+
parseabilityReport: scoring.parseabilityReport,
|
|
2342
|
+
employmentGaps: scoring.employmentGaps,
|
|
2343
|
+
seniorityMatch: scoring.seniorityMatch,
|
|
2344
|
+
perSkillExperience: scoring.perSkillExperience,
|
|
1741
2345
|
suggestions,
|
|
1742
2346
|
warnings: [...suggestionResult.warnings, ...llmWarnings]
|
|
1743
2347
|
};
|
|
1744
2348
|
}
|
|
1745
|
-
function enhanceSuggestionsWithLLM(config,
|
|
2349
|
+
function enhanceSuggestionsWithLLM(config, _suggestions) {
|
|
1746
2350
|
if (!config.enable?.suggestions) {
|
|
1747
2351
|
return { success: false, warnings: [] };
|
|
1748
2352
|
}
|
|
1749
2353
|
const warnings = [];
|
|
1750
2354
|
try {
|
|
1751
|
-
const llmManager = new LLMManager(config);
|
|
1752
2355
|
warnings.push(
|
|
1753
2356
|
"LLM suggestion enhancement skipped - use async analyzeResumeAsync for LLM features"
|
|
1754
2357
|
);
|
|
@@ -1810,6 +2413,10 @@ async function analyzeResumeAsync(input) {
|
|
|
1810
2413
|
detectedSections: parsedResume.detectedSections,
|
|
1811
2414
|
parsedExperienceYears: parsedResume.totalExperienceYears,
|
|
1812
2415
|
experienceEntries: parsedResume.experience,
|
|
2416
|
+
parseabilityReport: scoring.parseabilityReport,
|
|
2417
|
+
employmentGaps: scoring.employmentGaps,
|
|
2418
|
+
seniorityMatch: scoring.seniorityMatch,
|
|
2419
|
+
perSkillExperience: scoring.perSkillExperience,
|
|
1813
2420
|
suggestions,
|
|
1814
2421
|
warnings: [...suggestionResult.warnings, ...llmWarnings]
|
|
1815
2422
|
};
|
|
@@ -1831,13 +2438,19 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
|
|
|
1831
2438
|
if (result.error) {
|
|
1832
2439
|
warnings.push(`LLM suggestion enhancement failed: ${result.error}`);
|
|
1833
2440
|
}
|
|
1834
|
-
return {
|
|
2441
|
+
return {
|
|
2442
|
+
success: false,
|
|
2443
|
+
warnings: [...warnings, ...llmManager.getWarnings()]
|
|
2444
|
+
};
|
|
1835
2445
|
}
|
|
1836
2446
|
const enhanced = adaptSuggestionEnhancementResponse(result.data);
|
|
1837
2447
|
const enhancedSuggestions = enhanced.filter((e) => e.actionable !== false).map((e) => e.enhanced);
|
|
1838
2448
|
if (enhancedSuggestions.length === 0) {
|
|
1839
2449
|
warnings.push("LLM returned no actionable enhanced suggestions");
|
|
1840
|
-
return {
|
|
2450
|
+
return {
|
|
2451
|
+
success: false,
|
|
2452
|
+
warnings: [...warnings, ...llmManager.getWarnings()]
|
|
2453
|
+
};
|
|
1841
2454
|
}
|
|
1842
2455
|
return {
|
|
1843
2456
|
success: true,
|
|
@@ -1845,7 +2458,9 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
|
|
|
1845
2458
|
warnings: llmManager.getWarnings()
|
|
1846
2459
|
};
|
|
1847
2460
|
} catch (e) {
|
|
1848
|
-
warnings.push(
|
|
2461
|
+
warnings.push(
|
|
2462
|
+
`Unexpected error in LLM enhancement: ${e.message}`
|
|
2463
|
+
);
|
|
1849
2464
|
return { success: false, warnings };
|
|
1850
2465
|
}
|
|
1851
2466
|
}
|