@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.cjs
CHANGED
|
@@ -1,5 +1,228 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// src/utils/languages.ts
|
|
4
|
+
var KNOWN_LANGUAGES = [
|
|
5
|
+
"english",
|
|
6
|
+
"spanish",
|
|
7
|
+
"french",
|
|
8
|
+
"german",
|
|
9
|
+
"italian",
|
|
10
|
+
"portuguese",
|
|
11
|
+
"dutch",
|
|
12
|
+
"russian",
|
|
13
|
+
"mandarin",
|
|
14
|
+
"chinese",
|
|
15
|
+
"cantonese",
|
|
16
|
+
"japanese",
|
|
17
|
+
"korean",
|
|
18
|
+
"arabic",
|
|
19
|
+
"hindi",
|
|
20
|
+
"polish",
|
|
21
|
+
"turkish",
|
|
22
|
+
"vietnamese",
|
|
23
|
+
"swedish",
|
|
24
|
+
"norwegian",
|
|
25
|
+
"danish",
|
|
26
|
+
"finnish",
|
|
27
|
+
"greek",
|
|
28
|
+
"hebrew",
|
|
29
|
+
"thai",
|
|
30
|
+
"indonesian",
|
|
31
|
+
"ukrainian",
|
|
32
|
+
"czech"
|
|
33
|
+
];
|
|
34
|
+
var LANGUAGE_ALIASES = {
|
|
35
|
+
mandarin: "chinese",
|
|
36
|
+
cantonese: "chinese"
|
|
37
|
+
};
|
|
38
|
+
var LEVEL_RANK = {
|
|
39
|
+
a1: 1,
|
|
40
|
+
a2: 2,
|
|
41
|
+
b1: 3,
|
|
42
|
+
b2: 4,
|
|
43
|
+
c1: 5,
|
|
44
|
+
c2: 6,
|
|
45
|
+
basic: 1,
|
|
46
|
+
elementary: 1,
|
|
47
|
+
limited: 2,
|
|
48
|
+
conversational: 3,
|
|
49
|
+
intermediate: 3,
|
|
50
|
+
professional: 4,
|
|
51
|
+
"upper intermediate": 4,
|
|
52
|
+
advanced: 4,
|
|
53
|
+
fluent: 5,
|
|
54
|
+
native: 6,
|
|
55
|
+
"native speaker": 6,
|
|
56
|
+
bilingual: 6,
|
|
57
|
+
// German
|
|
58
|
+
grundkenntnisse: 1,
|
|
59
|
+
gering: 2,
|
|
60
|
+
gut: 3,
|
|
61
|
+
fortgeschritten: 4,
|
|
62
|
+
flie\u00DFend: 5,
|
|
63
|
+
muttersprache: 6,
|
|
64
|
+
muttersprachler: 6,
|
|
65
|
+
// French
|
|
66
|
+
d\u00E9butant: 1,
|
|
67
|
+
\u00E9l\u00E9mentaire: 1,
|
|
68
|
+
limit\u00E9: 2,
|
|
69
|
+
interm\u00E9diaire: 3,
|
|
70
|
+
avanc\u00E9: 4,
|
|
71
|
+
courant: 5,
|
|
72
|
+
natif: 6,
|
|
73
|
+
"langue maternelle": 6,
|
|
74
|
+
bilingue: 6
|
|
75
|
+
};
|
|
76
|
+
var LANGUAGE_GROUP = KNOWN_LANGUAGES.join("|");
|
|
77
|
+
var LEVEL_GROUP = Object.keys(LEVEL_RANK).sort((a, b) => b.length - a.length).map((l) => l.replace(/\s+/g, "\\s+")).join("|");
|
|
78
|
+
var BOUNDARY_START = "(?:^|(?<=[^a-z\xE0-\xFF]))";
|
|
79
|
+
var BOUNDARY_END = "(?:$|(?=[^a-z\xE0-\xFF]))";
|
|
80
|
+
var LANGUAGE_LEVEL_RE = new RegExp(
|
|
81
|
+
`\\b(${LANGUAGE_GROUP})\\b(?:\\s*[\\(:\\-]?\\s*(${BOUNDARY_START}(?:${LEVEL_GROUP})${BOUNDARY_END}|[abc][12]))?`,
|
|
82
|
+
"gi"
|
|
83
|
+
);
|
|
84
|
+
var LEVEL_BEFORE_LANGUAGE_RE = new RegExp(
|
|
85
|
+
`${BOUNDARY_START}(${LEVEL_GROUP})${BOUNDARY_END}\\s+(?:in\\s+)?(${LANGUAGE_GROUP})\\b`,
|
|
86
|
+
"gi"
|
|
87
|
+
);
|
|
88
|
+
function canonicalLanguage(name) {
|
|
89
|
+
const lower = name.toLowerCase();
|
|
90
|
+
return LANGUAGE_ALIASES[lower] ?? lower;
|
|
91
|
+
}
|
|
92
|
+
function toParsedLanguage(name, level) {
|
|
93
|
+
const normalizedLevel = level?.toLowerCase().replace(/\s+/g, " ");
|
|
94
|
+
return {
|
|
95
|
+
name: canonicalLanguage(name),
|
|
96
|
+
level: normalizedLevel,
|
|
97
|
+
levelRank: normalizedLevel ? LEVEL_RANK[normalizedLevel] : void 0
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function parseLanguageMentions(text) {
|
|
101
|
+
const found = /* @__PURE__ */ new Map();
|
|
102
|
+
for (const match of text.matchAll(LANGUAGE_LEVEL_RE)) {
|
|
103
|
+
const parsed = toParsedLanguage(match[1], match[2]);
|
|
104
|
+
const existing = found.get(parsed.name);
|
|
105
|
+
if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
|
|
106
|
+
found.set(parsed.name, parsed);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const match of text.matchAll(LEVEL_BEFORE_LANGUAGE_RE)) {
|
|
110
|
+
const parsed = toParsedLanguage(match[2], match[1]);
|
|
111
|
+
const existing = found.get(parsed.name);
|
|
112
|
+
if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
|
|
113
|
+
found.set(parsed.name, parsed);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
117
|
+
}
|
|
118
|
+
function diffLanguages(resumeLanguages, requiredLanguages) {
|
|
119
|
+
const byName = new Map(resumeLanguages.map((l) => [l.name, l]));
|
|
120
|
+
const matched = [];
|
|
121
|
+
const missing = [];
|
|
122
|
+
for (const required of requiredLanguages) {
|
|
123
|
+
const have = byName.get(required.name);
|
|
124
|
+
const requiredRank = required.levelRank ?? 0;
|
|
125
|
+
const haveRank = have?.levelRank ?? 0;
|
|
126
|
+
if (have && haveRank >= requiredRank) {
|
|
127
|
+
matched.push(required);
|
|
128
|
+
} else {
|
|
129
|
+
missing.push(required);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { matched, missing };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/utils/match.ts
|
|
136
|
+
var SUFFIXES = [
|
|
137
|
+
{ suffix: "ational", replacement: "ate", minStemLength: 3 },
|
|
138
|
+
{ suffix: "ization", replacement: "ize", minStemLength: 3 },
|
|
139
|
+
{ suffix: "ations", replacement: "ate", minStemLength: 3 },
|
|
140
|
+
{ suffix: "ation", replacement: "ate", minStemLength: 3 },
|
|
141
|
+
{ suffix: "ements", replacement: "e", minStemLength: 3 },
|
|
142
|
+
{ suffix: "ement", replacement: "e", minStemLength: 3 },
|
|
143
|
+
{ suffix: "ments", replacement: "ment", minStemLength: 3 },
|
|
144
|
+
{ suffix: "ment", replacement: "", minStemLength: 3 },
|
|
145
|
+
{ suffix: "ies", replacement: "y", minStemLength: 2 },
|
|
146
|
+
{ suffix: "ied", replacement: "y", minStemLength: 2 },
|
|
147
|
+
{ suffix: "ying", replacement: "y", minStemLength: 2 },
|
|
148
|
+
{ suffix: "tion", replacement: "t", minStemLength: 3 },
|
|
149
|
+
{ suffix: "sion", replacement: "s", minStemLength: 3 },
|
|
150
|
+
{ suffix: "ness", replacement: "", minStemLength: 3 },
|
|
151
|
+
{ suffix: "fully", replacement: "ful", minStemLength: 3 },
|
|
152
|
+
{ suffix: "ally", replacement: "al", minStemLength: 3 },
|
|
153
|
+
{ suffix: "ing", replacement: "", minStemLength: 3 },
|
|
154
|
+
{ suffix: "edly", replacement: "", minStemLength: 3 },
|
|
155
|
+
{ suffix: "ed", replacement: "", minStemLength: 3 },
|
|
156
|
+
{ suffix: "ly", replacement: "", minStemLength: 3 },
|
|
157
|
+
{ suffix: "er", replacement: "", minStemLength: 3 },
|
|
158
|
+
{ suffix: "es", replacement: "", minStemLength: 3 },
|
|
159
|
+
{ suffix: "s", replacement: "", minStemLength: 3 }
|
|
160
|
+
];
|
|
161
|
+
function stripOneSuffix(lower) {
|
|
162
|
+
for (const { suffix, replacement, minStemLength } of SUFFIXES) {
|
|
163
|
+
if (lower.endsWith(suffix) && lower.length - suffix.length >= minStemLength) {
|
|
164
|
+
let stemmed = lower.slice(0, lower.length - suffix.length) + replacement;
|
|
165
|
+
if (replacement === "" && stemmed.length >= 3 && stemmed[stemmed.length - 1] === stemmed[stemmed.length - 2] && !/[aeiou]/.test(stemmed[stemmed.length - 1])) {
|
|
166
|
+
stemmed = stemmed.slice(0, -1);
|
|
167
|
+
}
|
|
168
|
+
return stemmed;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return lower;
|
|
172
|
+
}
|
|
173
|
+
function stem(token) {
|
|
174
|
+
let word = token.trim().toLowerCase();
|
|
175
|
+
for (let pass = 0; pass < 2 && word.length > 3; pass++) {
|
|
176
|
+
const next = stripOneSuffix(word);
|
|
177
|
+
if (next === word) {
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
word = next;
|
|
181
|
+
}
|
|
182
|
+
return word;
|
|
183
|
+
}
|
|
184
|
+
function levenshteinDistance(a, b, maxDistance) {
|
|
185
|
+
const s = a.toLowerCase();
|
|
186
|
+
const t = b.toLowerCase();
|
|
187
|
+
if (s === t) return 0;
|
|
188
|
+
const lenDiff = Math.abs(s.length - t.length);
|
|
189
|
+
if (lenDiff > maxDistance) return maxDistance + 1;
|
|
190
|
+
let prevRow = new Array(t.length + 1);
|
|
191
|
+
for (let j = 0; j <= t.length; j++) prevRow[j] = j;
|
|
192
|
+
for (let i = 1; i <= s.length; i++) {
|
|
193
|
+
const currRow = new Array(t.length + 1);
|
|
194
|
+
currRow[0] = i;
|
|
195
|
+
let rowMin = currRow[0];
|
|
196
|
+
for (let j = 1; j <= t.length; j++) {
|
|
197
|
+
const cost = s[i - 1] === t[j - 1] ? 0 : 1;
|
|
198
|
+
currRow[j] = Math.min(
|
|
199
|
+
prevRow[j] + 1,
|
|
200
|
+
// deletion
|
|
201
|
+
currRow[j - 1] + 1,
|
|
202
|
+
// insertion
|
|
203
|
+
prevRow[j - 1] + cost
|
|
204
|
+
// substitution
|
|
205
|
+
);
|
|
206
|
+
if (currRow[j] < rowMin) rowMin = currRow[j];
|
|
207
|
+
}
|
|
208
|
+
if (rowMin > maxDistance) {
|
|
209
|
+
return maxDistance + 1;
|
|
210
|
+
}
|
|
211
|
+
prevRow = currRow;
|
|
212
|
+
}
|
|
213
|
+
return prevRow[t.length];
|
|
214
|
+
}
|
|
215
|
+
function fuzzyEqual(a, b, opts) {
|
|
216
|
+
const x = a.trim().toLowerCase();
|
|
217
|
+
const y = b.trim().toLowerCase();
|
|
218
|
+
if (x === y) return true;
|
|
219
|
+
if (!x || !y) return false;
|
|
220
|
+
const longer = Math.max(x.length, y.length);
|
|
221
|
+
const defaultMax = longer <= 6 ? 1 : 2;
|
|
222
|
+
const maxDistance = opts?.maxDistance ?? defaultMax;
|
|
223
|
+
return levenshteinDistance(x, y, maxDistance) <= maxDistance;
|
|
224
|
+
}
|
|
225
|
+
|
|
3
226
|
// src/utils/text.ts
|
|
4
227
|
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
5
228
|
// articles / prepositions / conjunctions
|
|
@@ -129,6 +352,39 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
|
129
352
|
"open",
|
|
130
353
|
"dynamic"
|
|
131
354
|
]);
|
|
355
|
+
var ROLE_NOUNS = [
|
|
356
|
+
"engineer",
|
|
357
|
+
"developer",
|
|
358
|
+
"manager",
|
|
359
|
+
"scientist",
|
|
360
|
+
"analyst",
|
|
361
|
+
"designer",
|
|
362
|
+
"architect",
|
|
363
|
+
"director",
|
|
364
|
+
"consultant",
|
|
365
|
+
"lead",
|
|
366
|
+
"vp",
|
|
367
|
+
"specialist",
|
|
368
|
+
"coordinator",
|
|
369
|
+
"administrator",
|
|
370
|
+
"accountant",
|
|
371
|
+
"recruiter",
|
|
372
|
+
"nurse",
|
|
373
|
+
"teacher",
|
|
374
|
+
"associate",
|
|
375
|
+
"representative",
|
|
376
|
+
"executive",
|
|
377
|
+
"officer",
|
|
378
|
+
"technician",
|
|
379
|
+
"strategist",
|
|
380
|
+
"marketer",
|
|
381
|
+
"writer",
|
|
382
|
+
"editor",
|
|
383
|
+
"product",
|
|
384
|
+
"principal",
|
|
385
|
+
"staff",
|
|
386
|
+
"head"
|
|
387
|
+
];
|
|
132
388
|
function normalizeWhitespace(text) {
|
|
133
389
|
return text.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
|
|
134
390
|
}
|
|
@@ -139,11 +395,14 @@ function splitLines(text) {
|
|
|
139
395
|
return text.replace(/\r\n?/g, "\n").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
140
396
|
}
|
|
141
397
|
var TECH_TOKEN_RE = /[a-z0-9][a-z0-9.#+\-/]*[a-z0-9#+]/g;
|
|
142
|
-
function tokenize(text) {
|
|
398
|
+
function tokenize(text, options) {
|
|
143
399
|
const normalized = normalizeForComparison(text);
|
|
144
|
-
|
|
400
|
+
const tokens = (normalized.match(TECH_TOKEN_RE) ?? []).filter(
|
|
145
401
|
(t) => /[a-z]/.test(t) && !STOP_WORDS.has(t)
|
|
146
402
|
);
|
|
403
|
+
{
|
|
404
|
+
return tokens;
|
|
405
|
+
}
|
|
147
406
|
}
|
|
148
407
|
function escapeRegExp(input) {
|
|
149
408
|
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -183,9 +442,48 @@ function containsTableLikeStructure(text) {
|
|
|
183
442
|
}
|
|
184
443
|
return tableLines >= 2;
|
|
185
444
|
}
|
|
445
|
+
var STANDARD_BULLET_RE = /^\s*(?:[-*•o‣▪]|\(?[a-z0-9]+[.)])\s+/i;
|
|
446
|
+
var NON_STANDARD_BULLET_RE = /^\s*[➤➢✓✔✗➔❖◆♦❯›»★☆♥❤]/;
|
|
447
|
+
var SPECIAL_CHAR_RE = /[-�]|[\x00-\x08\x0B\x0C\x0E-\x1F]/;
|
|
448
|
+
function detectFormatting(raw) {
|
|
449
|
+
const lines = splitLines(raw);
|
|
450
|
+
const hasTables = containsTableLikeStructure(raw);
|
|
451
|
+
let wideGapLines = 0;
|
|
452
|
+
for (const line of lines) {
|
|
453
|
+
if (/\S( {6,})\S/.test(line)) {
|
|
454
|
+
wideGapLines += 1;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const hasMultiColumn = wideGapLines >= 2;
|
|
458
|
+
const hasSpecialChars = SPECIAL_CHAR_RE.test(raw);
|
|
459
|
+
let bulletLines = 0;
|
|
460
|
+
let nonStandardBulletLines = 0;
|
|
461
|
+
for (const line of lines) {
|
|
462
|
+
if (NON_STANDARD_BULLET_RE.test(line)) {
|
|
463
|
+
bulletLines += 1;
|
|
464
|
+
nonStandardBulletLines += 1;
|
|
465
|
+
} else if (STANDARD_BULLET_RE.test(line)) {
|
|
466
|
+
bulletLines += 1;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const nonStandardBullets = nonStandardBulletLines > 0 && nonStandardBulletLines >= bulletLines / 2;
|
|
470
|
+
const trimmed = raw.trim();
|
|
471
|
+
const letterCount = (trimmed.match(/[a-zA-Z]/g) ?? []).length;
|
|
472
|
+
const likelyScanned = trimmed.length > 0 && (letterCount < 20 || letterCount / trimmed.length < 0.2);
|
|
473
|
+
const contactParseable = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(raw);
|
|
474
|
+
return {
|
|
475
|
+
hasTables,
|
|
476
|
+
hasMultiColumn,
|
|
477
|
+
hasSpecialChars,
|
|
478
|
+
nonStandardBullets,
|
|
479
|
+
likelyScanned,
|
|
480
|
+
contactParseable
|
|
481
|
+
};
|
|
482
|
+
}
|
|
186
483
|
|
|
187
484
|
// src/utils/skills.ts
|
|
188
485
|
var aliasIndexCache = /* @__PURE__ */ new WeakMap();
|
|
486
|
+
var stemIndexCache = /* @__PURE__ */ new WeakMap();
|
|
189
487
|
function getAliasIndex(aliases) {
|
|
190
488
|
let index = aliasIndexCache.get(aliases);
|
|
191
489
|
if (!index) {
|
|
@@ -201,12 +499,44 @@ function getAliasIndex(aliases) {
|
|
|
201
499
|
}
|
|
202
500
|
return index;
|
|
203
501
|
}
|
|
204
|
-
function
|
|
502
|
+
function getStemIndex(aliases) {
|
|
503
|
+
let index = stemIndexCache.get(aliases);
|
|
504
|
+
if (!index) {
|
|
505
|
+
index = /* @__PURE__ */ new Map();
|
|
506
|
+
for (const [key, canonical] of getAliasIndex(aliases)) {
|
|
507
|
+
const stemmed = stem(key);
|
|
508
|
+
if (!index.has(stemmed)) {
|
|
509
|
+
index.set(stemmed, canonical);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
stemIndexCache.set(aliases, index);
|
|
513
|
+
}
|
|
514
|
+
return index;
|
|
515
|
+
}
|
|
516
|
+
function normalizeSkill(skill, aliases, options) {
|
|
205
517
|
const normalized = skill.trim().toLowerCase();
|
|
206
|
-
|
|
518
|
+
const exact = getAliasIndex(aliases).get(normalized);
|
|
519
|
+
if (exact) {
|
|
520
|
+
return exact;
|
|
521
|
+
}
|
|
522
|
+
if (!options?.fuzzy) {
|
|
523
|
+
return normalized;
|
|
524
|
+
}
|
|
525
|
+
const stemmed = stem(normalized);
|
|
526
|
+
const stemIndex = getStemIndex(aliases);
|
|
527
|
+
const stemHit = stemIndex.get(stemmed);
|
|
528
|
+
if (stemHit) {
|
|
529
|
+
return stemHit;
|
|
530
|
+
}
|
|
531
|
+
for (const [key, canonical] of stemIndex) {
|
|
532
|
+
if (fuzzyEqual(stemmed, key, { maxDistance: options.maxDistance })) {
|
|
533
|
+
return canonical;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return normalized;
|
|
207
537
|
}
|
|
208
|
-
function normalizeSkills(skills, aliases) {
|
|
209
|
-
return unique(skills.map((skill) => normalizeSkill(skill, aliases)));
|
|
538
|
+
function normalizeSkills(skills, aliases, options) {
|
|
539
|
+
return unique(skills.map((skill) => normalizeSkill(skill, aliases, options)));
|
|
210
540
|
}
|
|
211
541
|
function deriveSkillAliases(registry) {
|
|
212
542
|
const aliases = {};
|
|
@@ -224,141 +554,72 @@ function buildCategoryIndex(registry) {
|
|
|
224
554
|
}
|
|
225
555
|
function mergeKeywordRegistries(base, overrides) {
|
|
226
556
|
const byCanonical = /* @__PURE__ */ new Map();
|
|
227
|
-
for (const entry of base)
|
|
228
|
-
|
|
557
|
+
for (const entry of base)
|
|
558
|
+
byCanonical.set(entry.canonical.toLowerCase(), entry);
|
|
559
|
+
for (const entry of overrides)
|
|
560
|
+
byCanonical.set(entry.canonical.toLowerCase(), entry);
|
|
229
561
|
return [...byCanonical.values()];
|
|
230
562
|
}
|
|
231
563
|
|
|
232
|
-
// src/utils/
|
|
233
|
-
var
|
|
234
|
-
"
|
|
235
|
-
"
|
|
236
|
-
"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
"russian",
|
|
242
|
-
"mandarin",
|
|
243
|
-
"chinese",
|
|
244
|
-
"cantonese",
|
|
245
|
-
"japanese",
|
|
246
|
-
"korean",
|
|
247
|
-
"arabic",
|
|
248
|
-
"hindi",
|
|
249
|
-
"polish",
|
|
250
|
-
"turkish",
|
|
251
|
-
"vietnamese",
|
|
252
|
-
"swedish",
|
|
253
|
-
"norwegian",
|
|
254
|
-
"danish",
|
|
255
|
-
"finnish",
|
|
256
|
-
"greek",
|
|
257
|
-
"hebrew",
|
|
258
|
-
"thai",
|
|
259
|
-
"indonesian",
|
|
260
|
-
"ukrainian",
|
|
261
|
-
"czech"
|
|
564
|
+
// src/utils/titles.ts
|
|
565
|
+
var SENIORITY_SIGNALS = [
|
|
566
|
+
{ seniority: "principal", pattern: /\bprincipal\b/i },
|
|
567
|
+
{ seniority: "lead", pattern: /\b(lead|staff|head of|director)\b/i },
|
|
568
|
+
{ seniority: "senior", pattern: /\b(senior|sr\.?)\b/i },
|
|
569
|
+
{
|
|
570
|
+
seniority: "junior",
|
|
571
|
+
pattern: /\b(junior|jr\.?|entry[\s-]?level|intern(?:ship)?|associate)\b/i
|
|
572
|
+
}
|
|
262
573
|
];
|
|
263
|
-
var
|
|
264
|
-
|
|
265
|
-
|
|
574
|
+
var SENIORITY_RANK = {
|
|
575
|
+
junior: 0,
|
|
576
|
+
mid: 1,
|
|
577
|
+
senior: 2,
|
|
578
|
+
lead: 3,
|
|
579
|
+
principal: 4
|
|
266
580
|
};
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
conversational: 3,
|
|
278
|
-
intermediate: 3,
|
|
279
|
-
professional: 4,
|
|
280
|
-
"upper intermediate": 4,
|
|
281
|
-
advanced: 4,
|
|
282
|
-
fluent: 5,
|
|
283
|
-
native: 6,
|
|
284
|
-
"native speaker": 6,
|
|
285
|
-
bilingual: 6,
|
|
286
|
-
// German
|
|
287
|
-
grundkenntnisse: 1,
|
|
288
|
-
gering: 2,
|
|
289
|
-
gut: 3,
|
|
290
|
-
fortgeschritten: 4,
|
|
291
|
-
flie\u00DFend: 5,
|
|
292
|
-
muttersprache: 6,
|
|
293
|
-
muttersprachler: 6,
|
|
294
|
-
// French
|
|
295
|
-
"d\xE9butant": 1,
|
|
296
|
-
"\xE9l\xE9mentaire": 1,
|
|
297
|
-
"limit\xE9": 2,
|
|
298
|
-
"interm\xE9diaire": 3,
|
|
299
|
-
"avanc\xE9": 4,
|
|
300
|
-
courant: 5,
|
|
301
|
-
natif: 6,
|
|
302
|
-
"langue maternelle": 6,
|
|
303
|
-
bilingue: 6
|
|
304
|
-
};
|
|
305
|
-
var LANGUAGE_GROUP = KNOWN_LANGUAGES.join("|");
|
|
306
|
-
var LEVEL_GROUP = Object.keys(LEVEL_RANK).sort((a, b) => b.length - a.length).map((l) => l.replace(/\s+/g, "\\s+")).join("|");
|
|
307
|
-
var BOUNDARY_START = "(?:^|(?<=[^a-z\xE0-\xFF]))";
|
|
308
|
-
var BOUNDARY_END = "(?:$|(?=[^a-z\xE0-\xFF]))";
|
|
309
|
-
var LANGUAGE_LEVEL_RE = new RegExp(
|
|
310
|
-
`\\b(${LANGUAGE_GROUP})\\b(?:\\s*[\\(:\\-]?\\s*(${BOUNDARY_START}(?:${LEVEL_GROUP})${BOUNDARY_END}|[abc][12]))?`,
|
|
311
|
-
"gi"
|
|
312
|
-
);
|
|
313
|
-
var LEVEL_BEFORE_LANGUAGE_RE = new RegExp(
|
|
314
|
-
`${BOUNDARY_START}(${LEVEL_GROUP})${BOUNDARY_END}\\s+(?:in\\s+)?(${LANGUAGE_GROUP})\\b`,
|
|
315
|
-
"gi"
|
|
316
|
-
);
|
|
317
|
-
function canonicalLanguage(name) {
|
|
318
|
-
const lower = name.toLowerCase();
|
|
319
|
-
return LANGUAGE_ALIASES[lower] ?? lower;
|
|
320
|
-
}
|
|
321
|
-
function toParsedLanguage(name, level) {
|
|
322
|
-
const normalizedLevel = level?.toLowerCase().replace(/\s+/g, " ");
|
|
323
|
-
return {
|
|
324
|
-
name: canonicalLanguage(name),
|
|
325
|
-
level: normalizedLevel,
|
|
326
|
-
levelRank: normalizedLevel ? LEVEL_RANK[normalizedLevel] : void 0
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
function parseLanguageMentions(text) {
|
|
330
|
-
const found = /* @__PURE__ */ new Map();
|
|
331
|
-
for (const match of text.matchAll(LANGUAGE_LEVEL_RE)) {
|
|
332
|
-
const parsed = toParsedLanguage(match[1], match[2]);
|
|
333
|
-
const existing = found.get(parsed.name);
|
|
334
|
-
if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
|
|
335
|
-
found.set(parsed.name, parsed);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
for (const match of text.matchAll(LEVEL_BEFORE_LANGUAGE_RE)) {
|
|
339
|
-
const parsed = toParsedLanguage(match[2], match[1]);
|
|
340
|
-
const existing = found.get(parsed.name);
|
|
341
|
-
if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
|
|
342
|
-
found.set(parsed.name, parsed);
|
|
581
|
+
function inferSeniority(titles) {
|
|
582
|
+
let best;
|
|
583
|
+
for (const title of titles) {
|
|
584
|
+
for (const { seniority, pattern } of SENIORITY_SIGNALS) {
|
|
585
|
+
if (pattern.test(title)) {
|
|
586
|
+
if (!best || SENIORITY_RANK[seniority] > SENIORITY_RANK[best]) {
|
|
587
|
+
best = seniority;
|
|
588
|
+
}
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
343
591
|
}
|
|
344
592
|
}
|
|
345
|
-
return
|
|
593
|
+
return best;
|
|
346
594
|
}
|
|
347
|
-
function
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
595
|
+
function normalizeTitle(title) {
|
|
596
|
+
return title.toLowerCase().replace(/[.,/#!$%^&*;:{}=_`~()]/g, " ").replace(/\s+/g, " ").trim();
|
|
597
|
+
}
|
|
598
|
+
function titleTokens(title) {
|
|
599
|
+
const tokens = normalizeTitle(title).split(" ").filter((t) => t && !STOP_WORDS.has(t)).map((t) => stem(t));
|
|
600
|
+
return new Set(tokens);
|
|
601
|
+
}
|
|
602
|
+
function titleMatch(resumeTitles, jdRoleKeywords) {
|
|
603
|
+
const keywords = jdRoleKeywords.map((k) => stem(normalizeTitle(k))).filter(Boolean);
|
|
604
|
+
if (keywords.length === 0) {
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
const resumeTokenSets = resumeTitles.map(titleTokens);
|
|
608
|
+
const roleNounStems = new Set(ROLE_NOUNS.map((n) => stem(n)));
|
|
609
|
+
let matched = 0;
|
|
610
|
+
for (const keyword of keywords) {
|
|
611
|
+
const isRoleNoun = roleNounStems.has(keyword);
|
|
612
|
+
const hit = resumeTokenSets.some((tokens) => {
|
|
613
|
+
if (tokens.has(keyword)) {
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
return isRoleNoun && [...tokens].some((t) => roleNounStems.has(t) && t === keyword);
|
|
617
|
+
});
|
|
618
|
+
if (hit) {
|
|
619
|
+
matched += 1;
|
|
359
620
|
}
|
|
360
621
|
}
|
|
361
|
-
return
|
|
622
|
+
return matched / keywords.length;
|
|
362
623
|
}
|
|
363
624
|
|
|
364
625
|
// src/core/parser/jd.parser.ts
|
|
@@ -387,13 +648,49 @@ function extractPreferredSkills(lines) {
|
|
|
387
648
|
}
|
|
388
649
|
return preferred;
|
|
389
650
|
}
|
|
651
|
+
var REQUIRED_HEADER_RE = /^(?:requirements?|must[\s-]?haves?|required\s+skills?|required\s+qualifications?|qualifications?|minimum\s+qualifications?)\s*:?\s*$/i;
|
|
652
|
+
var PREFERRED_HEADER_RE = /^(?:preferred(?:\s+skills?|\s+qualifications?)?|nice[\s-]?to[\s-]?haves?|bonus(?:\s+points?)?|pluses?)\s*:?\s*$/i;
|
|
653
|
+
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;
|
|
654
|
+
function extractHeaderScopedSkills(lines) {
|
|
655
|
+
const required = [];
|
|
656
|
+
const preferred = [];
|
|
657
|
+
let scope = null;
|
|
658
|
+
for (const line of lines) {
|
|
659
|
+
if (REQUIRED_HEADER_RE.test(line)) {
|
|
660
|
+
scope = "required";
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (PREFERRED_HEADER_RE.test(line)) {
|
|
664
|
+
scope = "preferred";
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (OTHER_HEADER_RE.test(line)) {
|
|
668
|
+
scope = null;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (scope === "required") {
|
|
672
|
+
required.push(...tokenize(line));
|
|
673
|
+
} else if (scope === "preferred") {
|
|
674
|
+
preferred.push(...tokenize(line));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return { required, preferred };
|
|
678
|
+
}
|
|
679
|
+
var ROLE_NOUN_RE = new RegExp(`(${ROLE_NOUNS.join("|")})`, "gi");
|
|
390
680
|
function extractRoleKeywords(text) {
|
|
391
|
-
const roleMatches = text.match(
|
|
681
|
+
const roleMatches = text.match(ROLE_NOUN_RE) ?? [];
|
|
392
682
|
const fallback = roleMatches.length === 0 ? [text.split(/\n/)[0] ?? ""] : [];
|
|
393
683
|
return unique(tokenize([...roleMatches, ...fallback].join(" ")));
|
|
394
684
|
}
|
|
685
|
+
function extractTitleContextLines(text) {
|
|
686
|
+
const lines = splitLines(text);
|
|
687
|
+
const roleLines = lines.filter((line) => line.match(ROLE_NOUN_RE) !== null);
|
|
688
|
+
return roleLines.length > 0 ? roleLines : lines.slice(0, 1);
|
|
689
|
+
}
|
|
395
690
|
function extractMinExperience(text) {
|
|
396
|
-
const match = text.match(
|
|
691
|
+
const match = text.match(
|
|
692
|
+
/(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i
|
|
693
|
+
);
|
|
397
694
|
if (!match) return void 0;
|
|
398
695
|
const parsed = Number.parseInt(match[1], 10);
|
|
399
696
|
return parsed <= 60 ? parsed : void 0;
|
|
@@ -419,6 +716,38 @@ function isLanguageRequired(lang, jobDescription) {
|
|
|
419
716
|
return LANG_SECTION_RE.test(line) || LANG_REQUIREMENT_HINT_RE.test(line);
|
|
420
717
|
});
|
|
421
718
|
}
|
|
719
|
+
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;
|
|
720
|
+
var SKILL_BEFORE_YEARS_RE = /((?:[a-z0-9][a-z0-9.#+\-/]*[\s,]*){1,4})\(?(\d{1,2})\+?\s*years?\)?/gi;
|
|
721
|
+
function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
|
|
722
|
+
const requirements = /* @__PURE__ */ new Map();
|
|
723
|
+
const record = (rawWord, years) => {
|
|
724
|
+
if (!Number.isFinite(years) || years <= 0 || years > 60) return;
|
|
725
|
+
const canonical = normalizeSkill(rawWord, aliases);
|
|
726
|
+
if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical))
|
|
727
|
+
return;
|
|
728
|
+
const existing = requirements.get(canonical);
|
|
729
|
+
if (existing === void 0 || years > existing) {
|
|
730
|
+
requirements.set(canonical, years);
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
for (const line of lines) {
|
|
734
|
+
for (const re of [YEARS_BEFORE_SKILL_RE, SKILL_BEFORE_YEARS_RE]) {
|
|
735
|
+
re.lastIndex = 0;
|
|
736
|
+
let match;
|
|
737
|
+
while (match = re.exec(line)) {
|
|
738
|
+
const years = Number.parseInt(
|
|
739
|
+
re === YEARS_BEFORE_SKILL_RE ? match[1] : match[2],
|
|
740
|
+
10
|
|
741
|
+
);
|
|
742
|
+
const candidateWords = tokenize(
|
|
743
|
+
re === YEARS_BEFORE_SKILL_RE ? match[2] : match[1]
|
|
744
|
+
);
|
|
745
|
+
for (const word of candidateWords) record(word, years);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return [...requirements.entries()].map(([skill, years]) => ({ skill, years })).sort((a, b) => a.skill.localeCompare(b.skill));
|
|
750
|
+
}
|
|
422
751
|
function extractDegreeLevels(text) {
|
|
423
752
|
const found = /* @__PURE__ */ new Set();
|
|
424
753
|
for (const [pattern, canonical] of DEGREE_VARIANTS) {
|
|
@@ -434,21 +763,47 @@ function parseJobDescription(jobDescription, config) {
|
|
|
434
763
|
skillVocab.add(canonical.toLowerCase());
|
|
435
764
|
for (const alias of aliases) skillVocab.add(alias.toLowerCase());
|
|
436
765
|
}
|
|
437
|
-
for (const s of config.profile?.mandatorySkills ?? [])
|
|
438
|
-
|
|
766
|
+
for (const s of config.profile?.mandatorySkills ?? [])
|
|
767
|
+
skillVocab.add(s.toLowerCase());
|
|
768
|
+
for (const s of config.profile?.optionalSkills ?? [])
|
|
769
|
+
skillVocab.add(s.toLowerCase());
|
|
439
770
|
const isSkillLike = (t) => {
|
|
440
771
|
if (skillVocab.has(t)) return true;
|
|
441
772
|
if (/[.#+]/.test(t) && /[a-z]/.test(t)) return true;
|
|
442
|
-
if (t.includes("/"))
|
|
773
|
+
if (t.includes("/"))
|
|
774
|
+
return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
|
|
443
775
|
return false;
|
|
444
776
|
};
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
777
|
+
const headerScoped = extractHeaderScopedSkills(lines);
|
|
778
|
+
const requiredSkillsRaw = unique([
|
|
779
|
+
...extractRequiredSkills(lines),
|
|
780
|
+
...headerScoped.required
|
|
781
|
+
]).filter(isSkillLike);
|
|
782
|
+
const preferredSkillsRaw = unique([
|
|
783
|
+
...extractPreferredSkills(lines),
|
|
784
|
+
...headerScoped.preferred
|
|
785
|
+
]).filter(isSkillLike);
|
|
786
|
+
const requiredSkills = normalizeSkills(
|
|
787
|
+
requiredSkillsRaw,
|
|
788
|
+
config.skillAliases
|
|
789
|
+
);
|
|
790
|
+
const preferredSkills = normalizeSkills(
|
|
791
|
+
preferredSkillsRaw,
|
|
792
|
+
config.skillAliases
|
|
793
|
+
);
|
|
449
794
|
const bodyTokens = tokenize(normalizedText).filter(isSkillLike);
|
|
450
795
|
const roleKeywords = extractRoleKeywords(jobDescription);
|
|
451
|
-
const keywords = unique([
|
|
796
|
+
const keywords = unique([
|
|
797
|
+
...requiredSkills,
|
|
798
|
+
...preferredSkills,
|
|
799
|
+
...roleKeywords,
|
|
800
|
+
...bodyTokens
|
|
801
|
+
]);
|
|
802
|
+
const skillExperienceRequirements = extractSkillExperienceRequirements(
|
|
803
|
+
lines,
|
|
804
|
+
skillVocab,
|
|
805
|
+
config.skillAliases
|
|
806
|
+
);
|
|
452
807
|
return {
|
|
453
808
|
raw: jobDescription,
|
|
454
809
|
normalizedText,
|
|
@@ -457,13 +812,18 @@ function parseJobDescription(jobDescription, config) {
|
|
|
457
812
|
roleKeywords,
|
|
458
813
|
keywords,
|
|
459
814
|
minExperienceYears: extractMinExperience(jobDescription),
|
|
815
|
+
skillExperienceRequirements,
|
|
460
816
|
educationRequirements: extractDegreeLevels(jobDescription),
|
|
461
|
-
keywordSurfaceForms: collectKeywordSurfaceForms(
|
|
817
|
+
keywordSurfaceForms: collectKeywordSurfaceForms(
|
|
818
|
+
jobDescription,
|
|
819
|
+
config.skillAliases
|
|
820
|
+
),
|
|
462
821
|
// A language only counts as required if its mention carries a requirement/level cue
|
|
463
822
|
// or sits in a "Languages:" line — plain references ("our Berlin office") don't count.
|
|
464
823
|
requiredLanguages: parseLanguageMentions(jobDescription).filter(
|
|
465
824
|
(lang) => isLanguageRequired(lang, jobDescription)
|
|
466
|
-
)
|
|
825
|
+
),
|
|
826
|
+
seniority: inferSeniority(extractTitleContextLines(jobDescription))
|
|
467
827
|
};
|
|
468
828
|
}
|
|
469
829
|
|
|
@@ -561,7 +921,9 @@ function parseDateRange(text, referenceDate) {
|
|
|
561
921
|
}
|
|
562
922
|
const startToken = parseDateToken(rangeMatch[1]);
|
|
563
923
|
const endRaw = rangeMatch[2];
|
|
564
|
-
const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(
|
|
924
|
+
const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(
|
|
925
|
+
endRaw
|
|
926
|
+
);
|
|
565
927
|
const endToken = isPresent ? void 0 : parseDateToken(endRaw);
|
|
566
928
|
if (!startToken) {
|
|
567
929
|
return null;
|
|
@@ -609,27 +971,92 @@ function sumExperienceYears(ranges) {
|
|
|
609
971
|
totalMonths += curEnd - curStart + 1;
|
|
610
972
|
return Number((totalMonths / 12).toFixed(2));
|
|
611
973
|
}
|
|
612
|
-
const months = ranges.reduce(
|
|
974
|
+
const months = ranges.reduce(
|
|
975
|
+
(total, r) => total + (r.durationInMonths ?? 0),
|
|
976
|
+
0
|
|
977
|
+
);
|
|
613
978
|
return Number((months / 12).toFixed(2));
|
|
614
979
|
}
|
|
980
|
+
function detectEmploymentGaps(entries, minGapMonths = 3) {
|
|
981
|
+
const toIndex = (year, month) => year * 12 + month;
|
|
982
|
+
const intervals = entries.filter(
|
|
983
|
+
(e) => e.dates?.startYear !== void 0 && e.dates?.endYear !== void 0
|
|
984
|
+
).map((e) => ({
|
|
985
|
+
s: toIndex(e.dates.startYear, e.dates.startMonth ?? 1),
|
|
986
|
+
e: toIndex(e.dates.endYear, e.dates.endMonth ?? 12),
|
|
987
|
+
label: e.title || e.company || "previous role"
|
|
988
|
+
})).sort((a, b) => a.s - b.s || b.e - a.e);
|
|
989
|
+
if (intervals.length < 2) {
|
|
990
|
+
return [];
|
|
991
|
+
}
|
|
992
|
+
const gaps = [];
|
|
993
|
+
let curEnd = intervals[0].e;
|
|
994
|
+
let curEndLabel = intervals[0].label;
|
|
995
|
+
for (let i = 1; i < intervals.length; i++) {
|
|
996
|
+
const interval = intervals[i];
|
|
997
|
+
if (interval.s <= curEnd + 1) {
|
|
998
|
+
if (interval.e > curEnd) {
|
|
999
|
+
curEnd = interval.e;
|
|
1000
|
+
curEndLabel = interval.label;
|
|
1001
|
+
}
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const gapMonths = interval.s - curEnd - 1;
|
|
1005
|
+
if (gapMonths >= minGapMonths) {
|
|
1006
|
+
gaps.push({ afterRole: curEndLabel, months: gapMonths });
|
|
1007
|
+
}
|
|
1008
|
+
curEnd = interval.e;
|
|
1009
|
+
curEndLabel = interval.label;
|
|
1010
|
+
}
|
|
1011
|
+
return gaps;
|
|
1012
|
+
}
|
|
615
1013
|
|
|
616
1014
|
// src/core/parser/resume.parser.ts
|
|
617
1015
|
var SECTION_ALIASES = {
|
|
618
|
-
summary: [
|
|
1016
|
+
summary: [
|
|
1017
|
+
"summary",
|
|
1018
|
+
"profile",
|
|
1019
|
+
"about",
|
|
1020
|
+
"zusammenfassung",
|
|
1021
|
+
"profil",
|
|
1022
|
+
"r\xE9sum\xE9",
|
|
1023
|
+
"\xE0 propos"
|
|
1024
|
+
],
|
|
619
1025
|
experience: [
|
|
620
1026
|
"experience",
|
|
621
1027
|
"work experience",
|
|
622
1028
|
"professional experience",
|
|
623
1029
|
"employment",
|
|
1030
|
+
"work history",
|
|
1031
|
+
"employment history",
|
|
624
1032
|
"erfahrung",
|
|
625
1033
|
"berufserfahrung",
|
|
626
1034
|
"exp\xE9rience",
|
|
627
1035
|
"exp\xE9rience professionnelle"
|
|
628
1036
|
],
|
|
629
|
-
skills: [
|
|
630
|
-
|
|
1037
|
+
skills: [
|
|
1038
|
+
"skills",
|
|
1039
|
+
"technical skills",
|
|
1040
|
+
"technologies",
|
|
1041
|
+
"f\xE4higkeiten",
|
|
1042
|
+
"kenntnisse",
|
|
1043
|
+
"comp\xE9tences"
|
|
1044
|
+
],
|
|
1045
|
+
education: [
|
|
1046
|
+
"education",
|
|
1047
|
+
"academics",
|
|
1048
|
+
"academic background",
|
|
1049
|
+
"ausbildung",
|
|
1050
|
+
"formation",
|
|
1051
|
+
"\xE9tudes"
|
|
1052
|
+
],
|
|
631
1053
|
projects: ["projects", "portfolio", "projekte", "projets"],
|
|
632
|
-
certifications: [
|
|
1054
|
+
certifications: [
|
|
1055
|
+
"certifications",
|
|
1056
|
+
"licenses",
|
|
1057
|
+
"zertifizierungen",
|
|
1058
|
+
"certifications professionnelles"
|
|
1059
|
+
]
|
|
633
1060
|
};
|
|
634
1061
|
var STRONG_VERBS = [
|
|
635
1062
|
"led",
|
|
@@ -652,28 +1079,90 @@ var STRONG_VERBS = [
|
|
|
652
1079
|
"reduced",
|
|
653
1080
|
"increased"
|
|
654
1081
|
];
|
|
655
|
-
var WEAK_VERBS = [
|
|
1082
|
+
var WEAK_VERBS = [
|
|
1083
|
+
"worked",
|
|
1084
|
+
"helped",
|
|
1085
|
+
"performed",
|
|
1086
|
+
"responsible",
|
|
1087
|
+
"assisted",
|
|
1088
|
+
"participated",
|
|
1089
|
+
"involved"
|
|
1090
|
+
];
|
|
1091
|
+
var TITLE_SENIORITY_PREFIXES = [
|
|
1092
|
+
"senior",
|
|
1093
|
+
"lead",
|
|
1094
|
+
"principal",
|
|
1095
|
+
"staff",
|
|
1096
|
+
"vp",
|
|
1097
|
+
"director"
|
|
1098
|
+
];
|
|
1099
|
+
var TITLE_MODIFIER_WORDS = [
|
|
1100
|
+
"software",
|
|
1101
|
+
"full\\s*stack",
|
|
1102
|
+
"frontend",
|
|
1103
|
+
"backend"
|
|
1104
|
+
];
|
|
1105
|
+
var TITLE_PREFIX_WORDS = unique([
|
|
1106
|
+
...TITLE_SENIORITY_PREFIXES,
|
|
1107
|
+
...TITLE_MODIFIER_WORDS,
|
|
1108
|
+
...ROLE_NOUNS
|
|
1109
|
+
]);
|
|
1110
|
+
var TITLE_RE = new RegExp(`^(${TITLE_PREFIX_WORDS.join("|")})[^,(-]*`, "i");
|
|
656
1111
|
var METRIC_RE = /\d|%|\$|\bk\+|\bm\+/i;
|
|
657
1112
|
function classifyAchievement(line) {
|
|
658
1113
|
const lower = line.toLowerCase();
|
|
659
1114
|
const hasMetric = METRIC_RE.test(line);
|
|
660
|
-
const hasStrongVerb = STRONG_VERBS.some(
|
|
661
|
-
|
|
1115
|
+
const hasStrongVerb = STRONG_VERBS.some(
|
|
1116
|
+
(verb) => new RegExp(`\\b${verb}\\b`).test(lower)
|
|
1117
|
+
);
|
|
1118
|
+
const hasWeakVerb = WEAK_VERBS.some(
|
|
1119
|
+
(verb) => new RegExp(`\\b${verb}\\b`).test(lower)
|
|
1120
|
+
);
|
|
662
1121
|
if (hasStrongVerb && hasMetric) {
|
|
663
|
-
return {
|
|
1122
|
+
return {
|
|
1123
|
+
text: line,
|
|
1124
|
+
strength: "strong",
|
|
1125
|
+
reason: "strong verb + quantified impact"
|
|
1126
|
+
};
|
|
664
1127
|
}
|
|
665
1128
|
if (hasWeakVerb) {
|
|
666
1129
|
return { text: line, strength: "weak", reason: "weak verb" };
|
|
667
1130
|
}
|
|
668
1131
|
return { text: line, strength: "weak", reason: "no quantified impact" };
|
|
669
1132
|
}
|
|
1133
|
+
var HEADER_TAIL_RE = /^[\s:.\-–—,]*(?:\d{4}\s*(?:[-–—]|to)\s*(?:\d{4}|present|current|now)|\d{4})?[\s:.\-–—,]*$/i;
|
|
1134
|
+
var MAX_HEADER_LINE_LENGTH = 60;
|
|
670
1135
|
function detectSection(line) {
|
|
671
|
-
const
|
|
1136
|
+
const trimmed = line.trim();
|
|
1137
|
+
if (!trimmed || trimmed.length > MAX_HEADER_LINE_LENGTH) return null;
|
|
1138
|
+
const normalized = trimmed.toLowerCase();
|
|
672
1139
|
for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
|
|
673
1140
|
for (const alias of aliases) {
|
|
674
1141
|
const safeAlias = escapeRegExp(alias);
|
|
675
|
-
const headerPattern = new RegExp(`^${safeAlias}(
|
|
676
|
-
|
|
1142
|
+
const headerPattern = new RegExp(`^${safeAlias}(.*)$`, "i");
|
|
1143
|
+
const match = normalized.match(headerPattern);
|
|
1144
|
+
if (match && HEADER_TAIL_RE.test(match[1])) {
|
|
1145
|
+
return section;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
const coreLine = normalized.replace(/[\s:.\-–—,]+$/, "");
|
|
1150
|
+
const lineWords = coreLine.split(/\s+/).filter(Boolean);
|
|
1151
|
+
for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
|
|
1152
|
+
for (const alias of aliases) {
|
|
1153
|
+
const aliasWords = alias.split(/\s+/);
|
|
1154
|
+
if (lineWords.length < aliasWords.length) continue;
|
|
1155
|
+
const candidateWords = lineWords.slice(0, aliasWords.length);
|
|
1156
|
+
const allFuzzy = aliasWords.every(
|
|
1157
|
+
(aliasWord, i) => aliasWord !== candidateWords[i] && fuzzyEqual(stem(aliasWord), stem(candidateWords[i]))
|
|
1158
|
+
);
|
|
1159
|
+
if (!allFuzzy) continue;
|
|
1160
|
+
const prefixPattern = new RegExp(
|
|
1161
|
+
`^\\s*${candidateWords.map((w) => escapeRegExp(w)).join("\\s+")}(.*)$`,
|
|
1162
|
+
"i"
|
|
1163
|
+
);
|
|
1164
|
+
const prefixMatch = normalized.match(prefixPattern);
|
|
1165
|
+
if (prefixMatch && HEADER_TAIL_RE.test(prefixMatch[1])) {
|
|
677
1166
|
return section;
|
|
678
1167
|
}
|
|
679
1168
|
}
|
|
@@ -705,12 +1194,42 @@ function extractSections(text) {
|
|
|
705
1194
|
flush();
|
|
706
1195
|
return { sections, detected: unique(detected) };
|
|
707
1196
|
}
|
|
708
|
-
function
|
|
1197
|
+
function parseExplicitSkillList(sectionContent) {
|
|
709
1198
|
if (!sectionContent) return [];
|
|
710
1199
|
const hasBullets = /[•·‣▪○●◦]/.test(sectionContent);
|
|
711
1200
|
const normalized = hasBullets ? sectionContent.replace(/\n/g, " ") : sectionContent;
|
|
712
|
-
|
|
713
|
-
|
|
1201
|
+
return normalized.split(/[,;\n]|[•·‣▪○●◦]/).map(
|
|
1202
|
+
(skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()
|
|
1203
|
+
).filter(Boolean);
|
|
1204
|
+
}
|
|
1205
|
+
function extractKnownSkillsFromText(text, aliases) {
|
|
1206
|
+
if (!text) return [];
|
|
1207
|
+
const canonicalSet = new Set(
|
|
1208
|
+
Object.keys(aliases).map((c) => c.toLowerCase())
|
|
1209
|
+
);
|
|
1210
|
+
const found = [];
|
|
1211
|
+
for (const token of tokenize(text)) {
|
|
1212
|
+
const canonical = normalizeSkill(token, aliases);
|
|
1213
|
+
if (canonicalSet.has(canonical)) {
|
|
1214
|
+
found.push(canonical);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return unique(found);
|
|
1218
|
+
}
|
|
1219
|
+
function parseSkills(sections, aliases) {
|
|
1220
|
+
const explicit = normalizeSkills(
|
|
1221
|
+
parseExplicitSkillList(sections.skills),
|
|
1222
|
+
aliases
|
|
1223
|
+
);
|
|
1224
|
+
const fromExperience = extractKnownSkillsFromText(
|
|
1225
|
+
sections.experience,
|
|
1226
|
+
aliases
|
|
1227
|
+
);
|
|
1228
|
+
const fromSummary = extractKnownSkillsFromText(sections.summary, aliases);
|
|
1229
|
+
return normalizeSkills(
|
|
1230
|
+
unique([...explicit, ...fromExperience, ...fromSummary]),
|
|
1231
|
+
aliases
|
|
1232
|
+
);
|
|
714
1233
|
}
|
|
715
1234
|
function parseActionVerbs(text) {
|
|
716
1235
|
const words = tokenize(text);
|
|
@@ -719,7 +1238,7 @@ function parseActionVerbs(text) {
|
|
|
719
1238
|
weak: WEAK_VERBS.filter((verb) => words.includes(verb))
|
|
720
1239
|
};
|
|
721
1240
|
}
|
|
722
|
-
function parseExperience(sectionContent, referenceDate) {
|
|
1241
|
+
function parseExperience(sectionContent, referenceDate, aliases) {
|
|
723
1242
|
if (!sectionContent) {
|
|
724
1243
|
return { entries: [], rangesInMonths: [], jobTitles: [], achievements: [] };
|
|
725
1244
|
}
|
|
@@ -730,23 +1249,32 @@ function parseExperience(sectionContent, referenceDate) {
|
|
|
730
1249
|
const achievements = [];
|
|
731
1250
|
for (const line of lines) {
|
|
732
1251
|
const range = parseDateRange(line, referenceDate);
|
|
1252
|
+
const titleMatch2 = line.match(TITLE_RE);
|
|
1253
|
+
const title = titleMatch2 ? titleMatch2[0].trim() : void 0;
|
|
733
1254
|
if (range) {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1255
|
+
if (title) {
|
|
1256
|
+
jobTitles.push(title.toLowerCase());
|
|
1257
|
+
entries.push({ title, dates: range, description: line, skills: [] });
|
|
737
1258
|
} else {
|
|
738
|
-
entries.
|
|
1259
|
+
const previous = entries[entries.length - 1];
|
|
1260
|
+
if (previous && !previous.dates) {
|
|
1261
|
+
previous.dates = range;
|
|
1262
|
+
} else {
|
|
1263
|
+
entries.push({ dates: range, skills: [] });
|
|
1264
|
+
}
|
|
739
1265
|
}
|
|
740
1266
|
if (range.durationInMonths) {
|
|
741
1267
|
rangesInMonths.push(range.durationInMonths);
|
|
742
1268
|
}
|
|
743
1269
|
continue;
|
|
744
1270
|
}
|
|
745
|
-
|
|
746
|
-
if (titleMatch) {
|
|
747
|
-
const title = titleMatch[0].trim();
|
|
1271
|
+
if (title) {
|
|
748
1272
|
jobTitles.push(title.toLowerCase());
|
|
749
|
-
const entry = {
|
|
1273
|
+
const entry = {
|
|
1274
|
+
title,
|
|
1275
|
+
description: line,
|
|
1276
|
+
skills: []
|
|
1277
|
+
};
|
|
750
1278
|
entries.push(entry);
|
|
751
1279
|
achievements.push(classifyAchievement(line));
|
|
752
1280
|
continue;
|
|
@@ -757,7 +1285,15 @@ function parseExperience(sectionContent, referenceDate) {
|
|
|
757
1285
|
}
|
|
758
1286
|
achievements.push(classifyAchievement(line));
|
|
759
1287
|
}
|
|
760
|
-
|
|
1288
|
+
for (const entry of entries) {
|
|
1289
|
+
entry.skills = extractKnownSkillsFromText(entry.description, aliases);
|
|
1290
|
+
}
|
|
1291
|
+
return {
|
|
1292
|
+
entries,
|
|
1293
|
+
rangesInMonths,
|
|
1294
|
+
jobTitles: unique(jobTitles),
|
|
1295
|
+
achievements
|
|
1296
|
+
};
|
|
761
1297
|
}
|
|
762
1298
|
function parseEducation(sectionContent) {
|
|
763
1299
|
if (!sectionContent) return [];
|
|
@@ -766,12 +1302,38 @@ function parseEducation(sectionContent) {
|
|
|
766
1302
|
function collectKeywords(text) {
|
|
767
1303
|
return unique(tokenize(text));
|
|
768
1304
|
}
|
|
1305
|
+
var EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i;
|
|
1306
|
+
var PHONE_RE = /\+?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}/;
|
|
1307
|
+
var LINKEDIN_RE = /(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/(?:in|pub)\/[a-z0-9\-_%]+\/?/i;
|
|
1308
|
+
var LOCATION_RE = /^[A-Z][A-Za-z.'-]+(?:\s[A-Z][A-Za-z.'-]+)*,\s*[A-Z][A-Za-z]{1,20}$/;
|
|
1309
|
+
function extractLocation(text) {
|
|
1310
|
+
const candidateLines = splitLines(text).slice(0, 8);
|
|
1311
|
+
for (const line of candidateLines) {
|
|
1312
|
+
if (EMAIL_RE.test(line) || /\d/.test(line)) continue;
|
|
1313
|
+
if (LOCATION_RE.test(line)) {
|
|
1314
|
+
return line;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return void 0;
|
|
1318
|
+
}
|
|
1319
|
+
function extractContact(text) {
|
|
1320
|
+
const email = text.match(EMAIL_RE)?.[0];
|
|
1321
|
+
const phone = text.match(PHONE_RE)?.[0]?.trim();
|
|
1322
|
+
const linkedin = text.match(LINKEDIN_RE)?.[0];
|
|
1323
|
+
const location = extractLocation(text);
|
|
1324
|
+
if (!email && !phone && !linkedin && !location) return void 0;
|
|
1325
|
+
return { email, phone, linkedin, location };
|
|
1326
|
+
}
|
|
769
1327
|
function parseResume(resumeText, config) {
|
|
770
1328
|
const normalizedText = normalizeWhitespace(resumeText);
|
|
771
1329
|
const { sections, detected } = extractSections(resumeText);
|
|
772
|
-
const skills = parseSkills(sections
|
|
1330
|
+
const skills = parseSkills(sections, config.skillAliases);
|
|
773
1331
|
const actionVerbs = parseActionVerbs(normalizedText);
|
|
774
|
-
const experienceData = parseExperience(
|
|
1332
|
+
const experienceData = parseExperience(
|
|
1333
|
+
sections.experience,
|
|
1334
|
+
config.referenceDate,
|
|
1335
|
+
config.skillAliases
|
|
1336
|
+
);
|
|
775
1337
|
const educationEntries = parseEducation(sections.education);
|
|
776
1338
|
let totalExperienceYears = sumExperienceYears(
|
|
777
1339
|
experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
|
|
@@ -784,7 +1346,12 @@ function parseResume(resumeText, config) {
|
|
|
784
1346
|
totalExperienceYears = parsed <= 60 ? parsed : 0;
|
|
785
1347
|
}
|
|
786
1348
|
}
|
|
787
|
-
const requiredSections = [
|
|
1349
|
+
const requiredSections = [
|
|
1350
|
+
"summary",
|
|
1351
|
+
"experience",
|
|
1352
|
+
"skills",
|
|
1353
|
+
"education"
|
|
1354
|
+
];
|
|
788
1355
|
const warnings = [];
|
|
789
1356
|
const lineCount = splitLines(resumeText).length;
|
|
790
1357
|
if (resumeText.trim().length < 100) {
|
|
@@ -801,6 +1368,12 @@ function parseResume(resumeText, config) {
|
|
|
801
1368
|
warnings.push(`${section} section not detected`);
|
|
802
1369
|
}
|
|
803
1370
|
}
|
|
1371
|
+
const contact = extractContact(resumeText);
|
|
1372
|
+
if (!contact?.email) {
|
|
1373
|
+
warnings.push(
|
|
1374
|
+
"No email address detected \u2014 most ATS require a parseable contact email"
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
804
1377
|
return {
|
|
805
1378
|
raw: resumeText,
|
|
806
1379
|
normalizedText,
|
|
@@ -816,6 +1389,10 @@ function parseResume(resumeText, config) {
|
|
|
816
1389
|
totalExperienceYears,
|
|
817
1390
|
keywords: collectKeywords(normalizedText),
|
|
818
1391
|
languages: parseLanguageMentions(resumeText),
|
|
1392
|
+
contact,
|
|
1393
|
+
seniority: inferSeniority(experienceData.jobTitles),
|
|
1394
|
+
employmentGaps: detectEmploymentGaps(experienceData.entries),
|
|
1395
|
+
formatting: detectFormatting(resumeText),
|
|
819
1396
|
warnings
|
|
820
1397
|
};
|
|
821
1398
|
}
|
|
@@ -826,7 +1403,12 @@ var RuleEngine = class {
|
|
|
826
1403
|
this.config = config;
|
|
827
1404
|
}
|
|
828
1405
|
applySectionPenalties(resume) {
|
|
829
|
-
const requiredSections = [
|
|
1406
|
+
const requiredSections = [
|
|
1407
|
+
"summary",
|
|
1408
|
+
"experience",
|
|
1409
|
+
"skills",
|
|
1410
|
+
"education"
|
|
1411
|
+
];
|
|
830
1412
|
let totalPenalty = 0;
|
|
831
1413
|
const warnings = [];
|
|
832
1414
|
const penaltyBySection = {
|
|
@@ -852,19 +1434,26 @@ var RuleEngine = class {
|
|
|
852
1434
|
const sectionResult = this.applySectionPenalties(input.resume);
|
|
853
1435
|
totalPenalty += sectionResult.totalPenalty;
|
|
854
1436
|
warnings.push(...sectionResult.warnings);
|
|
855
|
-
if (
|
|
1437
|
+
if (input.resume.formatting.hasTables) {
|
|
856
1438
|
totalPenalty += 8;
|
|
857
1439
|
warnings.push("Detected table-like or columnar formatting (penalty 8)");
|
|
858
1440
|
}
|
|
859
1441
|
if (input.overusedKeywords && input.overusedKeywords.length > 0) {
|
|
860
1442
|
const penalty = input.overusedKeywords.length * this.config.keywordDensity.overusePenalty;
|
|
861
1443
|
totalPenalty += penalty;
|
|
862
|
-
warnings.push(
|
|
1444
|
+
warnings.push(
|
|
1445
|
+
`Keyword stuffing detected for: ${input.overusedKeywords.join(", ")} (penalty ${penalty})`
|
|
1446
|
+
);
|
|
863
1447
|
}
|
|
864
1448
|
if (input.resume.detectedSections.length < 3) {
|
|
865
1449
|
totalPenalty += 5;
|
|
866
1450
|
warnings.push("Few recognizable sections found (penalty 5)");
|
|
867
1451
|
}
|
|
1452
|
+
if (!input.resume.contact?.email) {
|
|
1453
|
+
const penalty = this.config.sectionPenalties.missingContact;
|
|
1454
|
+
totalPenalty += penalty;
|
|
1455
|
+
warnings.push(`Missing contact email (penalty ${penalty})`);
|
|
1456
|
+
}
|
|
868
1457
|
return { totalPenalty, warnings };
|
|
869
1458
|
}
|
|
870
1459
|
evaluate(input) {
|
|
@@ -895,9 +1484,31 @@ var RuleEngine = class {
|
|
|
895
1484
|
// src/core/scoring/scorer.ts
|
|
896
1485
|
var REQUIRED_SKILL_WEIGHT = 0.7;
|
|
897
1486
|
var OPTIONAL_SKILL_WEIGHT = 0.3;
|
|
898
|
-
var EXPERIENCE_YEARS_WEIGHT = 0.
|
|
899
|
-
var EXPERIENCE_ROLE_WEIGHT = 0.
|
|
900
|
-
var
|
|
1487
|
+
var EXPERIENCE_YEARS_WEIGHT = 0.65;
|
|
1488
|
+
var EXPERIENCE_ROLE_WEIGHT = 0.2;
|
|
1489
|
+
var EXPERIENCE_SENIORITY_WEIGHT = 0.15;
|
|
1490
|
+
var ALL_CATEGORIES = [
|
|
1491
|
+
"technical",
|
|
1492
|
+
"tool",
|
|
1493
|
+
"concept",
|
|
1494
|
+
"soft",
|
|
1495
|
+
"marketing",
|
|
1496
|
+
"domain"
|
|
1497
|
+
];
|
|
1498
|
+
var SENIORITY_RANK2 = {
|
|
1499
|
+
junior: 0,
|
|
1500
|
+
mid: 1,
|
|
1501
|
+
senior: 2,
|
|
1502
|
+
lead: 3,
|
|
1503
|
+
principal: 4
|
|
1504
|
+
};
|
|
1505
|
+
var DEGREE_RANK = {
|
|
1506
|
+
associate: 1,
|
|
1507
|
+
bachelor: 2,
|
|
1508
|
+
master: 3,
|
|
1509
|
+
mba: 3,
|
|
1510
|
+
phd: 4
|
|
1511
|
+
};
|
|
901
1512
|
function emptyCategoryBuckets() {
|
|
902
1513
|
const buckets = {};
|
|
903
1514
|
for (const category of ALL_CATEGORIES) {
|
|
@@ -905,18 +1516,32 @@ function emptyCategoryBuckets() {
|
|
|
905
1516
|
}
|
|
906
1517
|
return buckets;
|
|
907
1518
|
}
|
|
908
|
-
function scoreSkills(resume, job, config) {
|
|
1519
|
+
function scoreSkills(resume, job, config, matchOptions) {
|
|
909
1520
|
const profileRequired = config.profile?.mandatorySkills ?? [];
|
|
910
1521
|
const profileOptional = config.profile?.optionalSkills ?? [];
|
|
911
1522
|
const required = new Set(
|
|
912
|
-
normalizeSkills(
|
|
1523
|
+
normalizeSkills(
|
|
1524
|
+
[...job.requiredSkills, ...profileRequired],
|
|
1525
|
+
config.skillAliases,
|
|
1526
|
+
matchOptions
|
|
1527
|
+
)
|
|
913
1528
|
);
|
|
914
1529
|
const optional = new Set(
|
|
915
|
-
normalizeSkills(
|
|
1530
|
+
normalizeSkills(
|
|
1531
|
+
[...job.preferredSkills, ...profileOptional],
|
|
1532
|
+
config.skillAliases,
|
|
1533
|
+
matchOptions
|
|
1534
|
+
)
|
|
1535
|
+
);
|
|
1536
|
+
const resumeSkills = new Set(
|
|
1537
|
+
normalizeSkills(resume.skills, config.skillAliases, matchOptions)
|
|
1538
|
+
);
|
|
1539
|
+
const matchedRequired = [...required].filter(
|
|
1540
|
+
(skill) => resumeSkills.has(skill)
|
|
1541
|
+
);
|
|
1542
|
+
const matchedOptional = [...optional].filter(
|
|
1543
|
+
(skill) => resumeSkills.has(skill)
|
|
916
1544
|
);
|
|
917
|
-
const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
|
|
918
|
-
const matchedRequired = [...required].filter((skill) => resumeSkills.has(skill));
|
|
919
|
-
const matchedOptional = [...optional].filter((skill) => resumeSkills.has(skill));
|
|
920
1545
|
const requiredCoverage = required.size === 0 ? 1 : matchedRequired.length / required.size;
|
|
921
1546
|
const optionalCoverage = optional.size === 0 ? 1 : matchedOptional.length / optional.size;
|
|
922
1547
|
const score = clamp(
|
|
@@ -928,29 +1553,45 @@ function scoreSkills(resume, job, config) {
|
|
|
928
1553
|
const missing = [...required].filter((skill) => !resumeSkills.has(skill)).sort();
|
|
929
1554
|
return { score, matched, missing };
|
|
930
1555
|
}
|
|
1556
|
+
function computeSeniorityMatch(resume, job) {
|
|
1557
|
+
const resumeSeniority = resume.seniority;
|
|
1558
|
+
const required = job.seniority;
|
|
1559
|
+
const met = !resumeSeniority || !required || SENIORITY_RANK2[resumeSeniority] >= SENIORITY_RANK2[required];
|
|
1560
|
+
return { resume: resumeSeniority, required, met };
|
|
1561
|
+
}
|
|
931
1562
|
function scoreExperience(resume, job, config) {
|
|
1563
|
+
const seniorityMatch = computeSeniorityMatch(resume, job);
|
|
932
1564
|
const requiredYears = job.minExperienceYears ?? config.profile?.minExperience ?? 0;
|
|
933
1565
|
if (!requiredYears) {
|
|
934
|
-
return { score: 100, missingYears: 0 };
|
|
1566
|
+
return { score: 100, missingYears: 0, seniorityMatch };
|
|
935
1567
|
}
|
|
936
1568
|
const yearCoverage = clamp(resume.totalExperienceYears / requiredYears, 0, 2);
|
|
937
1569
|
const yearsComponent = clamp(yearCoverage, 0, 1) * EXPERIENCE_YEARS_WEIGHT;
|
|
938
|
-
const
|
|
939
|
-
const titleMatches = resume.jobTitles.filter((title) => jobRoleSet.has(title.toLowerCase()));
|
|
940
|
-
const titleCoverage = jobRoleSet.size === 0 ? 1 : titleMatches.length / jobRoleSet.size;
|
|
1570
|
+
const titleCoverage = job.roleKeywords.length === 0 ? 1 : titleMatch(resume.jobTitles, job.roleKeywords);
|
|
941
1571
|
const roleComponent = clamp(titleCoverage, 0, 1) * EXPERIENCE_ROLE_WEIGHT;
|
|
942
|
-
const
|
|
1572
|
+
const seniorityComponent = (seniorityMatch.met ? 1 : 0) * EXPERIENCE_SENIORITY_WEIGHT;
|
|
1573
|
+
const score = clamp(
|
|
1574
|
+
(yearsComponent + roleComponent + seniorityComponent) * 100,
|
|
1575
|
+
0,
|
|
1576
|
+
100
|
|
1577
|
+
);
|
|
943
1578
|
const missingYears = Math.max(requiredYears - resume.totalExperienceYears, 0);
|
|
944
|
-
return {
|
|
1579
|
+
return {
|
|
1580
|
+
score,
|
|
1581
|
+
missingYears: Number(missingYears.toFixed(2)),
|
|
1582
|
+
seniorityMatch
|
|
1583
|
+
};
|
|
945
1584
|
}
|
|
946
1585
|
function keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies) {
|
|
947
1586
|
const base = requiredSet.has(keyword) ? 3 : preferredSet.has(keyword) ? 2 : 1;
|
|
948
1587
|
const freqBonus = Math.min((jdFrequencies[keyword] ?? 1) - 1, 3) * 0.25;
|
|
949
1588
|
return base + freqBonus;
|
|
950
1589
|
}
|
|
951
|
-
function scoreKeywords(resume, job, config) {
|
|
1590
|
+
function scoreKeywords(resume, job, config, matchOptions) {
|
|
952
1591
|
const jobKeywordSet = new Set(
|
|
953
|
-
job.keywords.map(
|
|
1592
|
+
job.keywords.map(
|
|
1593
|
+
(k) => normalizeSkill(k, config.skillAliases, matchOptions)
|
|
1594
|
+
)
|
|
954
1595
|
);
|
|
955
1596
|
if (jobKeywordSet.size === 0) {
|
|
956
1597
|
return {
|
|
@@ -963,20 +1604,32 @@ function scoreKeywords(resume, job, config) {
|
|
|
963
1604
|
};
|
|
964
1605
|
}
|
|
965
1606
|
const resumeTokens = tokenize(resume.normalizedText).map(
|
|
966
|
-
(t) => normalizeSkill(t, config.skillAliases)
|
|
1607
|
+
(t) => normalizeSkill(t, config.skillAliases, matchOptions)
|
|
967
1608
|
);
|
|
968
1609
|
const resumeTokenSet = new Set(resumeTokens);
|
|
969
1610
|
const resumeFrequencies = countFrequencies(resumeTokens);
|
|
970
1611
|
const requiredSet = new Set(job.requiredSkills);
|
|
971
1612
|
const preferredSet = new Set(job.preferredSkills);
|
|
972
1613
|
const jdFrequencies = countFrequencies(
|
|
973
|
-
tokenize(job.normalizedText).map(
|
|
1614
|
+
tokenize(job.normalizedText).map(
|
|
1615
|
+
(t) => normalizeSkill(t, config.skillAliases, matchOptions)
|
|
1616
|
+
)
|
|
974
1617
|
);
|
|
975
1618
|
const weightOf = (keyword) => keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies);
|
|
976
|
-
const matchedKeywords = [...jobKeywordSet].filter(
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
const
|
|
1619
|
+
const matchedKeywords = [...jobKeywordSet].filter(
|
|
1620
|
+
(keyword) => resumeTokenSet.has(keyword)
|
|
1621
|
+
);
|
|
1622
|
+
const missingKeywords = [...jobKeywordSet].filter(
|
|
1623
|
+
(keyword) => !resumeTokenSet.has(keyword)
|
|
1624
|
+
);
|
|
1625
|
+
const totalWeight = [...jobKeywordSet].reduce(
|
|
1626
|
+
(sum, keyword) => sum + weightOf(keyword),
|
|
1627
|
+
0
|
|
1628
|
+
);
|
|
1629
|
+
const matchedWeight = matchedKeywords.reduce(
|
|
1630
|
+
(sum, keyword) => sum + weightOf(keyword),
|
|
1631
|
+
0
|
|
1632
|
+
);
|
|
980
1633
|
const score = clamp(matchedWeight / totalWeight * 100, 0, 100);
|
|
981
1634
|
const totalTokens = resumeTokens.length || 1;
|
|
982
1635
|
const overusedKeywords = matchedKeywords.filter((keyword) => {
|
|
@@ -1013,38 +1666,158 @@ function scoreKeywords(resume, job, config) {
|
|
|
1013
1666
|
keywordWeights
|
|
1014
1667
|
};
|
|
1015
1668
|
}
|
|
1669
|
+
function computePerSkillExperience(resume, config, matchOptions) {
|
|
1670
|
+
const bySkill = /* @__PURE__ */ new Map();
|
|
1671
|
+
for (const entry of resume.experience) {
|
|
1672
|
+
if (!entry.dates || entry.skills.length === 0) continue;
|
|
1673
|
+
const skillsInRole = normalizeSkills(
|
|
1674
|
+
entry.skills,
|
|
1675
|
+
config.skillAliases,
|
|
1676
|
+
matchOptions
|
|
1677
|
+
);
|
|
1678
|
+
for (const skill of skillsInRole) {
|
|
1679
|
+
const list = bySkill.get(skill) ?? [];
|
|
1680
|
+
list.push(entry.dates);
|
|
1681
|
+
bySkill.set(skill, list);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return [...bySkill.entries()].map(([skill, dates]) => ({ skill, years: sumExperienceYears(dates) })).sort((a, b) => a.skill.localeCompare(b.skill));
|
|
1685
|
+
}
|
|
1686
|
+
function computeSkillExperienceGaps(resume, job, config, matchOptions, perSkillExperience) {
|
|
1687
|
+
if (job.skillExperienceRequirements.length === 0) return [];
|
|
1688
|
+
const resumeSkills = new Set(
|
|
1689
|
+
normalizeSkills(resume.skills, config.skillAliases, matchOptions)
|
|
1690
|
+
);
|
|
1691
|
+
const yearsBySkill = new Map(
|
|
1692
|
+
perSkillExperience.map((p) => [p.skill, p.years])
|
|
1693
|
+
);
|
|
1694
|
+
const gaps = [];
|
|
1695
|
+
for (const { skill, years } of job.skillExperienceRequirements) {
|
|
1696
|
+
if (!resumeSkills.has(skill)) continue;
|
|
1697
|
+
const resumeYears = yearsBySkill.get(skill) ?? 0;
|
|
1698
|
+
if (resumeYears < years) {
|
|
1699
|
+
gaps.push({ skill, requiredYears: years, resumeYears });
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
return gaps.sort((a, b) => a.skill.localeCompare(b.skill));
|
|
1703
|
+
}
|
|
1016
1704
|
function scoreEducation(resume, job) {
|
|
1017
1705
|
if (job.educationRequirements.length === 0) {
|
|
1018
1706
|
return 100;
|
|
1019
1707
|
}
|
|
1020
|
-
const resumeDegreeLevels = extractDegreeLevels(
|
|
1021
|
-
|
|
1022
|
-
(requirement) => resumeDegreeLevels.includes(requirement)
|
|
1708
|
+
const resumeDegreeLevels = extractDegreeLevels(
|
|
1709
|
+
resume.educationEntries.join(" ")
|
|
1023
1710
|
);
|
|
1024
|
-
if (
|
|
1711
|
+
if (resumeDegreeLevels.length === 0) {
|
|
1025
1712
|
return 0;
|
|
1026
1713
|
}
|
|
1027
|
-
|
|
1714
|
+
const resumeMaxRank = Math.max(
|
|
1715
|
+
...resumeDegreeLevels.map((d) => DEGREE_RANK[d] ?? 0)
|
|
1716
|
+
);
|
|
1717
|
+
const perRequirementCredit = job.educationRequirements.map(
|
|
1718
|
+
(requirement) => {
|
|
1719
|
+
if (resumeDegreeLevels.includes(requirement)) return 1;
|
|
1720
|
+
const requiredRank = DEGREE_RANK[requirement] ?? 0;
|
|
1721
|
+
const diff = resumeMaxRank - requiredRank;
|
|
1722
|
+
if (diff >= 1) return 0.85;
|
|
1723
|
+
if (diff === 0) return 0.6;
|
|
1724
|
+
if (diff === -1) return 0.35;
|
|
1725
|
+
return 0;
|
|
1726
|
+
}
|
|
1727
|
+
);
|
|
1728
|
+
const avgCredit = perRequirementCredit.reduce((sum, credit) => sum + credit, 0) / perRequirementCredit.length;
|
|
1729
|
+
return clamp(avgCredit * 100, 0, 100);
|
|
1730
|
+
}
|
|
1731
|
+
function scoreParseability(formatting, contact, detectedSections) {
|
|
1732
|
+
let score = 100;
|
|
1733
|
+
const deductions = [];
|
|
1734
|
+
const deduct = (points, reason) => {
|
|
1735
|
+
score -= points;
|
|
1736
|
+
deductions.push({ reason, points });
|
|
1737
|
+
};
|
|
1738
|
+
if (formatting.hasTables) {
|
|
1739
|
+
deduct(
|
|
1740
|
+
20,
|
|
1741
|
+
"Table or columnar formatting detected \u2014 ATS parsers often misalign fields extracted from tables"
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
if (formatting.hasMultiColumn) {
|
|
1745
|
+
deduct(
|
|
1746
|
+
20,
|
|
1747
|
+
"Multi-column layout detected \u2014 text may extract out of reading order in real ATS systems"
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
if (formatting.hasSpecialChars) {
|
|
1751
|
+
deduct(
|
|
1752
|
+
10,
|
|
1753
|
+
"Special or control characters detected \u2014 likely icon fonts or a lossy text extraction"
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
if (formatting.nonStandardBullets) {
|
|
1757
|
+
deduct(
|
|
1758
|
+
8,
|
|
1759
|
+
"Non-standard bullet characters detected \u2014 some ATS parsers won't recognize them as list items"
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
if (formatting.likelyScanned) {
|
|
1763
|
+
deduct(
|
|
1764
|
+
30,
|
|
1765
|
+
"Resume text looks scanned or image-based with little extractable text"
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
if (!contact?.email || !formatting.contactParseable) {
|
|
1769
|
+
deduct(15, "No parseable contact email detected");
|
|
1770
|
+
}
|
|
1771
|
+
if (detectedSections.length < 3) {
|
|
1772
|
+
deduct(10, "Fewer than 3 standard resume sections were detected");
|
|
1773
|
+
}
|
|
1774
|
+
return {
|
|
1775
|
+
score: clamp(score, 0, 100),
|
|
1776
|
+
report: {
|
|
1777
|
+
...formatting,
|
|
1778
|
+
detectedSectionCount: detectedSections.length,
|
|
1779
|
+
deductions
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1028
1782
|
}
|
|
1029
1783
|
function calculateScore(resume, job, config) {
|
|
1030
|
-
const
|
|
1784
|
+
const matchOptions = {
|
|
1785
|
+
fuzzy: config.matching.fuzzy,
|
|
1786
|
+
maxDistance: config.matching.threshold
|
|
1787
|
+
};
|
|
1788
|
+
const skillsResult = scoreSkills(resume, job, config, matchOptions);
|
|
1031
1789
|
const experienceResult = scoreExperience(resume, job, config);
|
|
1032
|
-
const keywordResult = scoreKeywords(resume, job, config);
|
|
1790
|
+
const keywordResult = scoreKeywords(resume, job, config, matchOptions);
|
|
1033
1791
|
const educationScore = scoreEducation(resume, job);
|
|
1792
|
+
const parseabilityResult = scoreParseability(
|
|
1793
|
+
resume.formatting,
|
|
1794
|
+
resume.contact,
|
|
1795
|
+
resume.detectedSections
|
|
1796
|
+
);
|
|
1034
1797
|
const breakdown = {
|
|
1035
1798
|
skills: skillsResult.score,
|
|
1036
1799
|
experience: experienceResult.score,
|
|
1037
1800
|
keywords: keywordResult.score,
|
|
1801
|
+
parseability: parseabilityResult.score,
|
|
1038
1802
|
education: educationScore
|
|
1039
1803
|
};
|
|
1040
|
-
const weightedScore = breakdown.skills * config.weights.skills + breakdown.experience * config.weights.experience + breakdown.keywords * config.weights.keywords + breakdown.education * config.weights.education;
|
|
1804
|
+
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;
|
|
1041
1805
|
const achievementStrength = {
|
|
1042
1806
|
strong: resume.achievements.filter((a) => a.strength === "strong").length,
|
|
1043
1807
|
weak: resume.achievements.filter((a) => a.strength === "weak").length
|
|
1044
1808
|
};
|
|
1045
|
-
const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(
|
|
1046
|
-
|
|
1047
|
-
|
|
1809
|
+
const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(resume.languages, job.requiredLanguages);
|
|
1810
|
+
const perSkillExperience = computePerSkillExperience(
|
|
1811
|
+
resume,
|
|
1812
|
+
config,
|
|
1813
|
+
matchOptions
|
|
1814
|
+
);
|
|
1815
|
+
const skillExperienceGaps = computeSkillExperienceGaps(
|
|
1816
|
+
resume,
|
|
1817
|
+
job,
|
|
1818
|
+
config,
|
|
1819
|
+
matchOptions,
|
|
1820
|
+
perSkillExperience
|
|
1048
1821
|
);
|
|
1049
1822
|
return {
|
|
1050
1823
|
score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
|
|
@@ -1059,6 +1832,7 @@ function calculateScore(resume, job, config) {
|
|
|
1059
1832
|
achievementStrength,
|
|
1060
1833
|
matchedLanguages,
|
|
1061
1834
|
missingLanguages,
|
|
1835
|
+
skillExperienceGaps,
|
|
1062
1836
|
suggestions: [],
|
|
1063
1837
|
warnings: [],
|
|
1064
1838
|
// detectedSections / parsedExperienceYears / experienceGap / experienceEntries: filled by index.ts
|
|
@@ -1067,7 +1841,11 @@ function calculateScore(resume, job, config) {
|
|
|
1067
1841
|
parsedExperienceYears: 0,
|
|
1068
1842
|
experienceEntries: [],
|
|
1069
1843
|
missingExperienceYears: experienceResult.missingYears,
|
|
1070
|
-
educationScore
|
|
1844
|
+
educationScore,
|
|
1845
|
+
parseabilityReport: parseabilityResult.report,
|
|
1846
|
+
employmentGaps: resume.employmentGaps,
|
|
1847
|
+
seniorityMatch: experienceResult.seniorityMatch,
|
|
1848
|
+
perSkillExperience
|
|
1071
1849
|
};
|
|
1072
1850
|
}
|
|
1073
1851
|
|
|
@@ -1075,109 +1853,499 @@ function calculateScore(resume, job, config) {
|
|
|
1075
1853
|
var defaultKeywordRegistry = [
|
|
1076
1854
|
// languages / frameworks
|
|
1077
1855
|
// ponytail: "node" split from javascript — Node.js runtime !== JS language
|
|
1078
|
-
{
|
|
1856
|
+
{
|
|
1857
|
+
canonical: "javascript",
|
|
1858
|
+
aliases: ["js", "ecmascript", "es6"],
|
|
1859
|
+
category: "technical"
|
|
1860
|
+
},
|
|
1079
1861
|
{ canonical: "node", aliases: ["node.js", "nodejs"], category: "technical" },
|
|
1080
1862
|
{ canonical: "typescript", aliases: ["ts"], category: "technical" },
|
|
1081
|
-
{
|
|
1082
|
-
|
|
1863
|
+
{
|
|
1864
|
+
canonical: "react",
|
|
1865
|
+
aliases: ["reactjs", "react.js"],
|
|
1866
|
+
category: "technical"
|
|
1867
|
+
},
|
|
1868
|
+
{
|
|
1869
|
+
canonical: "angular",
|
|
1870
|
+
aliases: ["angularjs", "angular2+"],
|
|
1871
|
+
category: "technical"
|
|
1872
|
+
},
|
|
1083
1873
|
{ canonical: "vue", aliases: ["vue.js", "vuejs"], category: "technical" },
|
|
1084
|
-
{ canonical: "svelte", aliases: [], category: "technical" },
|
|
1874
|
+
{ canonical: "svelte", aliases: ["sveltekit"], category: "technical" },
|
|
1085
1875
|
{ canonical: "next.js", aliases: ["nextjs"], category: "technical" },
|
|
1876
|
+
{ canonical: "nuxt.js", aliases: ["nuxtjs", "nuxt"], category: "technical" },
|
|
1877
|
+
{ canonical: "remix", aliases: [], category: "technical" },
|
|
1086
1878
|
{ canonical: "c++", aliases: ["cpp"], category: "technical" },
|
|
1087
1879
|
{ canonical: "c#", aliases: ["csharp", ".net"], category: "technical" },
|
|
1088
1880
|
{ canonical: "java", aliases: [], category: "technical" },
|
|
1089
1881
|
{ canonical: "python", aliases: ["py"], category: "technical" },
|
|
1090
1882
|
{ canonical: "go", aliases: ["golang"], category: "technical" },
|
|
1091
1883
|
{ canonical: "rust", aliases: [], category: "technical" },
|
|
1092
|
-
{
|
|
1884
|
+
{
|
|
1885
|
+
canonical: "ruby",
|
|
1886
|
+
aliases: ["ruby on rails", "rails"],
|
|
1887
|
+
category: "technical"
|
|
1888
|
+
},
|
|
1093
1889
|
{ canonical: "php", aliases: [], category: "technical" },
|
|
1094
|
-
{ canonical: "swift", aliases: [], category: "technical" },
|
|
1890
|
+
{ canonical: "swift", aliases: ["swiftui"], category: "technical" },
|
|
1095
1891
|
{ canonical: "kotlin", aliases: [], category: "technical" },
|
|
1096
1892
|
{ canonical: "scala", aliases: [], category: "technical" },
|
|
1893
|
+
{ canonical: "perl", aliases: [], category: "technical" },
|
|
1894
|
+
{
|
|
1895
|
+
canonical: "r",
|
|
1896
|
+
aliases: ["r language", "r programming"],
|
|
1897
|
+
category: "technical"
|
|
1898
|
+
},
|
|
1899
|
+
{ canonical: "matlab", aliases: [], category: "technical" },
|
|
1900
|
+
{
|
|
1901
|
+
canonical: "objective-c",
|
|
1902
|
+
aliases: ["objective c", "objc"],
|
|
1903
|
+
category: "technical"
|
|
1904
|
+
},
|
|
1905
|
+
{ canonical: "dart", aliases: [], category: "technical" },
|
|
1906
|
+
{ canonical: "elixir", aliases: [], category: "technical" },
|
|
1907
|
+
{ canonical: "haskell", aliases: [], category: "technical" },
|
|
1097
1908
|
{ canonical: "html", aliases: ["html5"], category: "technical" },
|
|
1098
1909
|
{ canonical: "css", aliases: ["css3"], category: "technical" },
|
|
1910
|
+
{ canonical: "sass", aliases: ["scss"], category: "technical" },
|
|
1911
|
+
{ canonical: "less", aliases: [], category: "technical" },
|
|
1912
|
+
{
|
|
1913
|
+
canonical: "tailwind css",
|
|
1914
|
+
aliases: ["tailwind", "tailwindcss"],
|
|
1915
|
+
category: "technical"
|
|
1916
|
+
},
|
|
1917
|
+
{ canonical: "bootstrap", aliases: [], category: "technical" },
|
|
1918
|
+
{ canonical: "webassembly", aliases: ["wasm"], category: "technical" },
|
|
1099
1919
|
{ canonical: "ios development", aliases: ["ios"], category: "technical" },
|
|
1100
|
-
{
|
|
1920
|
+
{
|
|
1921
|
+
canonical: "android development",
|
|
1922
|
+
aliases: ["android"],
|
|
1923
|
+
category: "technical"
|
|
1924
|
+
},
|
|
1101
1925
|
{ canonical: "react native", aliases: [], category: "technical" },
|
|
1102
1926
|
{ canonical: "flutter", aliases: [], category: "technical" },
|
|
1927
|
+
{ canonical: "xamarin", aliases: [], category: "technical" },
|
|
1103
1928
|
{ canonical: "machine learning", aliases: ["ml"], category: "technical" },
|
|
1104
|
-
{ canonical: "deep learning", aliases: [], category: "technical" },
|
|
1105
|
-
{
|
|
1929
|
+
{ canonical: "deep learning", aliases: ["dl"], category: "technical" },
|
|
1930
|
+
{
|
|
1931
|
+
canonical: "natural language processing",
|
|
1932
|
+
aliases: ["nlp"],
|
|
1933
|
+
category: "technical"
|
|
1934
|
+
},
|
|
1935
|
+
{ canonical: "computer vision", aliases: ["cv"], category: "technical" },
|
|
1936
|
+
{
|
|
1937
|
+
canonical: "large language models",
|
|
1938
|
+
aliases: ["llm", "llms"],
|
|
1939
|
+
category: "technical"
|
|
1940
|
+
},
|
|
1941
|
+
{
|
|
1942
|
+
canonical: "generative ai",
|
|
1943
|
+
aliases: ["genai", "gen ai"],
|
|
1944
|
+
category: "technical"
|
|
1945
|
+
},
|
|
1946
|
+
{
|
|
1947
|
+
canonical: "reinforcement learning",
|
|
1948
|
+
aliases: ["rl"],
|
|
1949
|
+
category: "technical"
|
|
1950
|
+
},
|
|
1951
|
+
{
|
|
1952
|
+
canonical: "neural networks",
|
|
1953
|
+
aliases: ["neural network"],
|
|
1954
|
+
category: "technical"
|
|
1955
|
+
},
|
|
1956
|
+
{ canonical: "data engineering", aliases: [], category: "technical" },
|
|
1957
|
+
{
|
|
1958
|
+
canonical: "data warehousing",
|
|
1959
|
+
aliases: ["data warehouse"],
|
|
1960
|
+
category: "technical"
|
|
1961
|
+
},
|
|
1962
|
+
{
|
|
1963
|
+
canonical: "etl",
|
|
1964
|
+
aliases: ["extract transform load", "elt"],
|
|
1965
|
+
category: "technical"
|
|
1966
|
+
},
|
|
1967
|
+
{ canonical: "distributed systems", aliases: [], category: "technical" },
|
|
1968
|
+
{ canonical: "embedded systems", aliases: [], category: "technical" },
|
|
1969
|
+
{
|
|
1970
|
+
canonical: "firmware development",
|
|
1971
|
+
aliases: ["firmware"],
|
|
1972
|
+
category: "technical"
|
|
1973
|
+
},
|
|
1974
|
+
{ canonical: "game development", aliases: [], category: "technical" },
|
|
1975
|
+
{ canonical: "unity", aliases: [], category: "technical" },
|
|
1976
|
+
{ canonical: "unreal engine", aliases: ["unreal"], category: "technical" },
|
|
1977
|
+
{ canonical: "web development", aliases: [], category: "technical" },
|
|
1978
|
+
{
|
|
1979
|
+
canonical: "full stack development",
|
|
1980
|
+
aliases: ["full-stack development", "full stack"],
|
|
1981
|
+
category: "technical"
|
|
1982
|
+
},
|
|
1983
|
+
{ canonical: "api development", aliases: [], category: "technical" },
|
|
1984
|
+
{ canonical: "grpc", aliases: [], category: "technical" },
|
|
1985
|
+
{ canonical: "websockets", aliases: ["websocket"], category: "technical" },
|
|
1986
|
+
{ canonical: "linux", aliases: [], category: "technical" },
|
|
1987
|
+
{ canonical: "unix", aliases: [], category: "technical" },
|
|
1988
|
+
{
|
|
1989
|
+
canonical: "bash scripting",
|
|
1990
|
+
aliases: ["bash", "shell scripting"],
|
|
1991
|
+
category: "technical"
|
|
1992
|
+
},
|
|
1993
|
+
{ canonical: "powershell", aliases: [], category: "technical" },
|
|
1106
1994
|
// tools / platforms / infra
|
|
1107
|
-
{
|
|
1995
|
+
{
|
|
1996
|
+
canonical: "sql",
|
|
1997
|
+
aliases: ["structured query language"],
|
|
1998
|
+
category: "tool"
|
|
1999
|
+
},
|
|
2000
|
+
{ canonical: "postgresql", aliases: ["postgres", "psql"], category: "tool" },
|
|
2001
|
+
{ canonical: "mysql", aliases: [], category: "tool" },
|
|
2002
|
+
{ canonical: "sqlite", aliases: [], category: "tool" },
|
|
2003
|
+
{
|
|
2004
|
+
canonical: "microsoft sql server",
|
|
2005
|
+
aliases: ["mssql", "sql server", "t-sql"],
|
|
2006
|
+
category: "tool"
|
|
2007
|
+
},
|
|
2008
|
+
{
|
|
2009
|
+
canonical: "oracle database",
|
|
2010
|
+
aliases: ["oracle db", "oracle"],
|
|
2011
|
+
category: "tool"
|
|
2012
|
+
},
|
|
2013
|
+
{ canonical: "mongodb", aliases: ["mongo"], category: "tool" },
|
|
2014
|
+
{ canonical: "cassandra", aliases: ["apache cassandra"], category: "tool" },
|
|
2015
|
+
{ canonical: "dynamodb", aliases: ["amazon dynamodb"], category: "tool" },
|
|
2016
|
+
{ canonical: "couchbase", aliases: [], category: "tool" },
|
|
2017
|
+
{ canonical: "neo4j", aliases: [], category: "tool" },
|
|
2018
|
+
{ canonical: "firebase", aliases: [], category: "tool" },
|
|
2019
|
+
{ canonical: "supabase", aliases: [], category: "tool" },
|
|
1108
2020
|
{ canonical: "graphql", aliases: ["gql"], category: "tool" },
|
|
1109
2021
|
{ canonical: "aws", aliases: ["amazon web services"], category: "tool" },
|
|
2022
|
+
{ canonical: "amazon ec2", aliases: ["ec2"], category: "tool" },
|
|
2023
|
+
{ canonical: "amazon s3", aliases: ["s3"], category: "tool" },
|
|
2024
|
+
{ canonical: "amazon rds", aliases: ["rds"], category: "tool" },
|
|
2025
|
+
{ canonical: "aws lambda", aliases: ["lambda"], category: "tool" },
|
|
2026
|
+
{ canonical: "amazon cloudfront", aliases: ["cloudfront"], category: "tool" },
|
|
2027
|
+
{ canonical: "amazon sqs", aliases: ["sqs"], category: "tool" },
|
|
2028
|
+
{ canonical: "amazon sns", aliases: ["sns"], category: "tool" },
|
|
1110
2029
|
{ canonical: "azure", aliases: ["microsoft azure"], category: "tool" },
|
|
1111
|
-
{ canonical: "
|
|
1112
|
-
{ canonical: "
|
|
2030
|
+
{ canonical: "azure devops", aliases: [], category: "tool" },
|
|
2031
|
+
{ canonical: "azure functions", aliases: [], category: "tool" },
|
|
2032
|
+
{
|
|
2033
|
+
canonical: "google cloud platform",
|
|
2034
|
+
aliases: ["gcp", "google cloud"],
|
|
2035
|
+
category: "tool"
|
|
2036
|
+
},
|
|
2037
|
+
{ canonical: "google bigquery", aliases: ["bigquery"], category: "tool" },
|
|
2038
|
+
{ canonical: "google kubernetes engine", aliases: ["gke"], category: "tool" },
|
|
2039
|
+
{
|
|
2040
|
+
canonical: "docker",
|
|
2041
|
+
aliases: ["containers", "containerization"],
|
|
2042
|
+
category: "tool"
|
|
2043
|
+
},
|
|
1113
2044
|
{ canonical: "kubernetes", aliases: ["k8s"], category: "tool" },
|
|
2045
|
+
{ canonical: "helm", aliases: [], category: "tool" },
|
|
1114
2046
|
{ canonical: "terraform", aliases: [], category: "tool" },
|
|
2047
|
+
{ canonical: "pulumi", aliases: [], category: "tool" },
|
|
1115
2048
|
{ canonical: "ansible", aliases: [], category: "tool" },
|
|
2049
|
+
{ canonical: "puppet", aliases: [], category: "tool" },
|
|
2050
|
+
{ canonical: "chef", aliases: [], category: "tool" },
|
|
1116
2051
|
{ canonical: "jenkins", aliases: [], category: "tool" },
|
|
1117
|
-
{ canonical: "
|
|
2052
|
+
{ canonical: "circleci", aliases: ["circle ci"], category: "tool" },
|
|
2053
|
+
{ canonical: "github actions", aliases: [], category: "tool" },
|
|
2054
|
+
{ canonical: "gitlab ci", aliases: ["gitlab ci/cd"], category: "tool" },
|
|
2055
|
+
{ canonical: "travis ci", aliases: [], category: "tool" },
|
|
2056
|
+
{
|
|
2057
|
+
canonical: "git",
|
|
2058
|
+
aliases: ["github", "gitlab", "bitbucket"],
|
|
2059
|
+
category: "tool"
|
|
2060
|
+
},
|
|
1118
2061
|
{ canonical: "jira", aliases: [], category: "tool" },
|
|
1119
2062
|
{ canonical: "confluence", aliases: [], category: "tool" },
|
|
2063
|
+
{ canonical: "trello", aliases: [], category: "tool" },
|
|
2064
|
+
{ canonical: "asana", aliases: [], category: "tool" },
|
|
2065
|
+
{ canonical: "monday.com", aliases: ["monday"], category: "tool" },
|
|
2066
|
+
{ canonical: "notion", aliases: [], category: "tool" },
|
|
2067
|
+
{ canonical: "linear", aliases: [], category: "tool" },
|
|
2068
|
+
{ canonical: "slack", aliases: [], category: "tool" },
|
|
2069
|
+
{ canonical: "microsoft teams", aliases: ["ms teams"], category: "tool" },
|
|
1120
2070
|
{ canonical: "pytorch", aliases: ["torch"], category: "tool" },
|
|
1121
2071
|
{ canonical: "tensorflow", aliases: ["tf"], category: "tool" },
|
|
2072
|
+
{ canonical: "keras", aliases: [], category: "tool" },
|
|
1122
2073
|
{ canonical: "scikit-learn", aliases: ["sklearn"], category: "tool" },
|
|
1123
2074
|
{ canonical: "pandas", aliases: [], category: "tool" },
|
|
1124
2075
|
{ canonical: "numpy", aliases: [], category: "tool" },
|
|
2076
|
+
{
|
|
2077
|
+
canonical: "hugging face",
|
|
2078
|
+
aliases: ["huggingface", "transformers"],
|
|
2079
|
+
category: "tool"
|
|
2080
|
+
},
|
|
2081
|
+
{ canonical: "langchain", aliases: [], category: "tool" },
|
|
2082
|
+
{ canonical: "openai api", aliases: ["openai", "gpt api"], category: "tool" },
|
|
2083
|
+
{ canonical: "jupyter", aliases: ["jupyter notebook"], category: "tool" },
|
|
1125
2084
|
{ canonical: "fastapi", aliases: [], category: "tool" },
|
|
1126
2085
|
{ canonical: "flask", aliases: [], category: "tool" },
|
|
1127
2086
|
{ canonical: "django", aliases: [], category: "tool" },
|
|
1128
|
-
{
|
|
2087
|
+
{
|
|
2088
|
+
canonical: "express.js",
|
|
2089
|
+
aliases: ["express", "expressjs"],
|
|
2090
|
+
category: "tool"
|
|
2091
|
+
},
|
|
2092
|
+
{ canonical: "spring boot", aliases: ["spring"], category: "tool" },
|
|
2093
|
+
{ canonical: "laravel", aliases: [], category: "tool" },
|
|
2094
|
+
{ canonical: "kafka", aliases: ["apache kafka"], category: "tool" },
|
|
2095
|
+
{ canonical: "rabbitmq", aliases: [], category: "tool" },
|
|
1129
2096
|
{ canonical: "redis", aliases: [], category: "tool" },
|
|
2097
|
+
{ canonical: "memcached", aliases: [], category: "tool" },
|
|
1130
2098
|
{ canonical: "elasticsearch", aliases: ["elastic"], category: "tool" },
|
|
1131
|
-
{ canonical: "
|
|
2099
|
+
{ canonical: "logstash", aliases: [], category: "tool" },
|
|
2100
|
+
{ canonical: "kibana", aliases: [], category: "tool" },
|
|
2101
|
+
{ canonical: "grafana", aliases: [], category: "tool" },
|
|
2102
|
+
{ canonical: "prometheus", aliases: [], category: "tool" },
|
|
2103
|
+
{ canonical: "datadog", aliases: [], category: "tool" },
|
|
2104
|
+
{ canonical: "new relic", aliases: [], category: "tool" },
|
|
2105
|
+
{ canonical: "splunk", aliases: [], category: "tool" },
|
|
2106
|
+
{
|
|
2107
|
+
canonical: "spark",
|
|
2108
|
+
aliases: ["apache spark", "pyspark"],
|
|
2109
|
+
category: "tool"
|
|
2110
|
+
},
|
|
2111
|
+
{ canonical: "hadoop", aliases: ["apache hadoop"], category: "tool" },
|
|
2112
|
+
{ canonical: "airflow", aliases: ["apache airflow"], category: "tool" },
|
|
2113
|
+
{ canonical: "dbt", aliases: ["data build tool"], category: "tool" },
|
|
2114
|
+
{ canonical: "snowflake", aliases: [], category: "tool" },
|
|
2115
|
+
{ canonical: "databricks", aliases: [], category: "tool" },
|
|
2116
|
+
{ canonical: "looker", aliases: [], category: "tool" },
|
|
1132
2117
|
{ canonical: "tableau", aliases: [], category: "tool" },
|
|
1133
2118
|
{ canonical: "power bi", aliases: ["powerbi"], category: "tool" },
|
|
1134
|
-
{
|
|
2119
|
+
{
|
|
2120
|
+
canonical: "excel",
|
|
2121
|
+
aliases: ["microsoft excel", "ms excel"],
|
|
2122
|
+
category: "tool"
|
|
2123
|
+
},
|
|
2124
|
+
{ canonical: "google sheets", aliases: [], category: "tool" },
|
|
2125
|
+
{ canonical: "spss", aliases: [], category: "tool" },
|
|
2126
|
+
{ canonical: "sas", aliases: [], category: "tool" },
|
|
1135
2127
|
{ canonical: "salesforce", aliases: [], category: "tool" },
|
|
1136
2128
|
{ canonical: "hubspot", aliases: [], category: "tool" },
|
|
2129
|
+
{ canonical: "marketo", aliases: [], category: "tool" },
|
|
2130
|
+
{ canonical: "mailchimp", aliases: [], category: "tool" },
|
|
2131
|
+
{ canonical: "google analytics", aliases: ["ga4"], category: "tool" },
|
|
2132
|
+
{ canonical: "google tag manager", aliases: ["gtm"], category: "tool" },
|
|
1137
2133
|
{ canonical: "sap", aliases: [], category: "tool" },
|
|
1138
2134
|
{ canonical: "quickbooks", aliases: [], category: "tool" },
|
|
2135
|
+
{ canonical: "netsuite", aliases: [], category: "tool" },
|
|
1139
2136
|
{ canonical: "workday", aliases: [], category: "tool" },
|
|
1140
2137
|
{ canonical: "zendesk", aliases: [], category: "tool" },
|
|
1141
2138
|
{ canonical: "servicenow", aliases: [], category: "tool" },
|
|
2139
|
+
{ canonical: "freshdesk", aliases: [], category: "tool" },
|
|
1142
2140
|
{ canonical: "figma", aliases: [], category: "tool" },
|
|
2141
|
+
{ canonical: "sketch", aliases: [], category: "tool" },
|
|
2142
|
+
{ canonical: "adobe xd", aliases: ["xd"], category: "tool" },
|
|
2143
|
+
{ canonical: "invision", aliases: [], category: "tool" },
|
|
1143
2144
|
{ canonical: "photoshop", aliases: ["adobe photoshop"], category: "tool" },
|
|
1144
|
-
{
|
|
2145
|
+
{
|
|
2146
|
+
canonical: "illustrator",
|
|
2147
|
+
aliases: ["adobe illustrator"],
|
|
2148
|
+
category: "tool"
|
|
2149
|
+
},
|
|
2150
|
+
{
|
|
2151
|
+
canonical: "after effects",
|
|
2152
|
+
aliases: ["adobe after effects"],
|
|
2153
|
+
category: "tool"
|
|
2154
|
+
},
|
|
2155
|
+
{ canonical: "premiere pro", aliases: ["adobe premiere"], category: "tool" },
|
|
1145
2156
|
{ canonical: "autocad", aliases: [], category: "tool" },
|
|
2157
|
+
{ canonical: "solidworks", aliases: [], category: "tool" },
|
|
2158
|
+
{ canonical: "postman", aliases: [], category: "tool" },
|
|
2159
|
+
{ canonical: "selenium", aliases: [], category: "tool" },
|
|
2160
|
+
{ canonical: "cypress", aliases: [], category: "tool" },
|
|
2161
|
+
{ canonical: "playwright", aliases: [], category: "tool" },
|
|
2162
|
+
{ canonical: "jest", aliases: [], category: "tool" },
|
|
2163
|
+
{ canonical: "mocha", aliases: [], category: "tool" },
|
|
2164
|
+
{ canonical: "junit", aliases: [], category: "tool" },
|
|
1146
2165
|
// engineering concepts
|
|
1147
2166
|
{ canonical: "accessibility", aliases: ["a11y"], category: "concept" },
|
|
1148
2167
|
{ canonical: "frontend", aliases: ["front-end"], category: "concept" },
|
|
1149
2168
|
{ canonical: "backend", aliases: ["back-end"], category: "concept" },
|
|
1150
|
-
{
|
|
1151
|
-
|
|
2169
|
+
{
|
|
2170
|
+
canonical: "security",
|
|
2171
|
+
aliases: ["cybersecurity", "infosec"],
|
|
2172
|
+
category: "concept"
|
|
2173
|
+
},
|
|
2174
|
+
{
|
|
2175
|
+
canonical: "application security",
|
|
2176
|
+
aliases: ["appsec"],
|
|
2177
|
+
category: "concept"
|
|
2178
|
+
},
|
|
2179
|
+
{
|
|
2180
|
+
canonical: "penetration testing",
|
|
2181
|
+
aliases: ["pentesting", "pen testing"],
|
|
2182
|
+
category: "concept"
|
|
2183
|
+
},
|
|
2184
|
+
{ canonical: "vulnerability assessment", aliases: [], category: "concept" },
|
|
2185
|
+
{
|
|
2186
|
+
canonical: "identity and access management",
|
|
2187
|
+
aliases: ["iam"],
|
|
2188
|
+
category: "concept"
|
|
2189
|
+
},
|
|
2190
|
+
{ canonical: "zero trust", aliases: [], category: "concept" },
|
|
2191
|
+
{ canonical: "encryption", aliases: [], category: "concept" },
|
|
2192
|
+
{
|
|
2193
|
+
canonical: "testing",
|
|
2194
|
+
aliases: ["unittest", "pytest"],
|
|
2195
|
+
category: "concept"
|
|
2196
|
+
},
|
|
2197
|
+
{ canonical: "unit testing", aliases: [], category: "concept" },
|
|
2198
|
+
{ canonical: "integration testing", aliases: [], category: "concept" },
|
|
2199
|
+
{
|
|
2200
|
+
canonical: "end-to-end testing",
|
|
2201
|
+
aliases: ["e2e testing"],
|
|
2202
|
+
category: "concept"
|
|
2203
|
+
},
|
|
2204
|
+
{ canonical: "regression testing", aliases: [], category: "concept" },
|
|
1152
2205
|
{ canonical: "microservices", aliases: [], category: "concept" },
|
|
1153
|
-
{
|
|
2206
|
+
{
|
|
2207
|
+
canonical: "monolithic architecture",
|
|
2208
|
+
aliases: ["monolith"],
|
|
2209
|
+
category: "concept"
|
|
2210
|
+
},
|
|
2211
|
+
{
|
|
2212
|
+
canonical: "event-driven architecture",
|
|
2213
|
+
aliases: ["event driven architecture"],
|
|
2214
|
+
category: "concept"
|
|
2215
|
+
},
|
|
2216
|
+
{
|
|
2217
|
+
canonical: "serverless",
|
|
2218
|
+
aliases: ["serverless architecture"],
|
|
2219
|
+
category: "concept"
|
|
2220
|
+
},
|
|
2221
|
+
{ canonical: "agile", aliases: [], category: "concept" },
|
|
2222
|
+
{ canonical: "scrum", aliases: [], category: "concept" },
|
|
1154
2223
|
{ canonical: "kanban", aliases: [], category: "concept" },
|
|
2224
|
+
{ canonical: "waterfall", aliases: [], category: "concept" },
|
|
2225
|
+
{ canonical: "sprint planning", aliases: [], category: "concept" },
|
|
1155
2226
|
{ canonical: "blockchain", aliases: [], category: "concept" },
|
|
2227
|
+
{ canonical: "smart contracts", aliases: [], category: "concept" },
|
|
1156
2228
|
{ canonical: "devops", aliases: [], category: "concept" },
|
|
1157
|
-
{
|
|
1158
|
-
|
|
2229
|
+
{
|
|
2230
|
+
canonical: "site reliability engineering",
|
|
2231
|
+
aliases: ["sre"],
|
|
2232
|
+
category: "concept"
|
|
2233
|
+
},
|
|
2234
|
+
{
|
|
2235
|
+
canonical: "infrastructure as code",
|
|
2236
|
+
aliases: ["iac"],
|
|
2237
|
+
category: "concept"
|
|
2238
|
+
},
|
|
2239
|
+
{
|
|
2240
|
+
canonical: "ci/cd",
|
|
2241
|
+
aliases: [
|
|
2242
|
+
"continuous integration",
|
|
2243
|
+
"continuous deployment",
|
|
2244
|
+
"continuous delivery"
|
|
2245
|
+
],
|
|
2246
|
+
category: "concept"
|
|
2247
|
+
},
|
|
2248
|
+
{
|
|
2249
|
+
canonical: "rest api",
|
|
2250
|
+
aliases: ["restful api", "rest apis"],
|
|
2251
|
+
category: "concept"
|
|
2252
|
+
},
|
|
2253
|
+
{ canonical: "api design", aliases: [], category: "concept" },
|
|
1159
2254
|
{ canonical: "design patterns", aliases: [], category: "concept" },
|
|
1160
2255
|
{ canonical: "data structures", aliases: [], category: "concept" },
|
|
1161
2256
|
{ canonical: "algorithms", aliases: [], category: "concept" },
|
|
2257
|
+
{
|
|
2258
|
+
canonical: "object-oriented programming",
|
|
2259
|
+
aliases: ["oop", "object oriented programming"],
|
|
2260
|
+
category: "concept"
|
|
2261
|
+
},
|
|
2262
|
+
{ canonical: "functional programming", aliases: [], category: "concept" },
|
|
1162
2263
|
{ canonical: "cloud computing", aliases: [], category: "concept" },
|
|
2264
|
+
{ canonical: "cloud migration", aliases: [], category: "concept" },
|
|
1163
2265
|
{ canonical: "system design", aliases: [], category: "concept" },
|
|
1164
|
-
{ canonical: "
|
|
2266
|
+
{ canonical: "high availability", aliases: [], category: "concept" },
|
|
2267
|
+
{ canonical: "scalability", aliases: [], category: "concept" },
|
|
2268
|
+
{ canonical: "load balancing", aliases: [], category: "concept" },
|
|
2269
|
+
{ canonical: "caching", aliases: [], category: "concept" },
|
|
2270
|
+
{
|
|
2271
|
+
canonical: "tdd",
|
|
2272
|
+
aliases: ["test driven development", "test-driven development"],
|
|
2273
|
+
category: "concept"
|
|
2274
|
+
},
|
|
2275
|
+
{
|
|
2276
|
+
canonical: "bdd",
|
|
2277
|
+
aliases: ["behavior driven development", "behavior-driven development"],
|
|
2278
|
+
category: "concept"
|
|
2279
|
+
},
|
|
2280
|
+
{ canonical: "code review", aliases: ["code reviews"], category: "concept" },
|
|
2281
|
+
{ canonical: "pair programming", aliases: [], category: "concept" },
|
|
1165
2282
|
{ canonical: "ux design", aliases: ["user experience"], category: "concept" },
|
|
1166
|
-
{
|
|
2283
|
+
{
|
|
2284
|
+
canonical: "ui design",
|
|
2285
|
+
aliases: ["user interface design"],
|
|
2286
|
+
category: "concept"
|
|
2287
|
+
},
|
|
2288
|
+
{ canonical: "user research", aliases: [], category: "concept" },
|
|
2289
|
+
{ canonical: "wireframing", aliases: [], category: "concept" },
|
|
2290
|
+
{ canonical: "prototyping", aliases: [], category: "concept" },
|
|
2291
|
+
{ canonical: "design systems", aliases: [], category: "concept" },
|
|
2292
|
+
{ canonical: "responsive design", aliases: [], category: "concept" },
|
|
1167
2293
|
{ canonical: "project management", aliases: [], category: "concept" },
|
|
2294
|
+
{ canonical: "program management", aliases: [], category: "concept" },
|
|
1168
2295
|
{ canonical: "change management", aliases: [], category: "concept" },
|
|
1169
2296
|
{ canonical: "risk management", aliases: [], category: "concept" },
|
|
1170
2297
|
{ canonical: "quality assurance", aliases: ["qa"], category: "concept" },
|
|
2298
|
+
{ canonical: "data privacy", aliases: [], category: "concept" },
|
|
2299
|
+
{ canonical: "version control", aliases: [], category: "concept" },
|
|
2300
|
+
{
|
|
2301
|
+
canonical: "documentation",
|
|
2302
|
+
aliases: ["technical documentation"],
|
|
2303
|
+
category: "concept"
|
|
2304
|
+
},
|
|
2305
|
+
{ canonical: "requirements gathering", aliases: [], category: "concept" },
|
|
2306
|
+
{
|
|
2307
|
+
canonical: "business process improvement",
|
|
2308
|
+
aliases: [],
|
|
2309
|
+
category: "concept"
|
|
2310
|
+
},
|
|
2311
|
+
{ canonical: "root cause analysis", aliases: [], category: "concept" },
|
|
2312
|
+
{ canonical: "lean", aliases: ["lean methodology"], category: "concept" },
|
|
2313
|
+
{ canonical: "six sigma", aliases: [], category: "concept" },
|
|
1171
2314
|
// product / data domain
|
|
1172
|
-
{ canonical: "roadmap", aliases: [], category: "domain" },
|
|
2315
|
+
{ canonical: "roadmap", aliases: ["product roadmap"], category: "domain" },
|
|
1173
2316
|
{ canonical: "stakeholder management", aliases: [], category: "domain" },
|
|
1174
2317
|
{ canonical: "prioritization", aliases: [], category: "domain" },
|
|
1175
2318
|
{ canonical: "a/b testing", aliases: ["ab testing"], category: "domain" },
|
|
1176
2319
|
{ canonical: "analytics", aliases: [], category: "domain" },
|
|
1177
2320
|
{ canonical: "statistics", aliases: ["stats"], category: "domain" },
|
|
1178
2321
|
{ canonical: "data visualization", aliases: [], category: "domain" },
|
|
2322
|
+
{ canonical: "data governance", aliases: [], category: "domain" },
|
|
2323
|
+
{ canonical: "product management", aliases: [], category: "domain" },
|
|
2324
|
+
{ canonical: "product strategy", aliases: [], category: "domain" },
|
|
2325
|
+
{
|
|
2326
|
+
canonical: "go-to-market strategy",
|
|
2327
|
+
aliases: ["go to market", "gtm strategy"],
|
|
2328
|
+
category: "domain"
|
|
2329
|
+
},
|
|
2330
|
+
{
|
|
2331
|
+
canonical: "user story writing",
|
|
2332
|
+
aliases: ["user stories"],
|
|
2333
|
+
category: "domain"
|
|
2334
|
+
},
|
|
2335
|
+
{ canonical: "competitive analysis", aliases: [], category: "domain" },
|
|
2336
|
+
{
|
|
2337
|
+
canonical: "okrs",
|
|
2338
|
+
aliases: ["objectives and key results"],
|
|
2339
|
+
category: "domain"
|
|
2340
|
+
},
|
|
2341
|
+
{
|
|
2342
|
+
canonical: "kpis",
|
|
2343
|
+
aliases: ["key performance indicators"],
|
|
2344
|
+
category: "domain"
|
|
2345
|
+
},
|
|
1179
2346
|
// finance / accounting domain
|
|
1180
2347
|
{ canonical: "financial analysis", aliases: [], category: "domain" },
|
|
2348
|
+
{ canonical: "financial modeling", aliases: [], category: "domain" },
|
|
1181
2349
|
{ canonical: "budgeting", aliases: [], category: "domain" },
|
|
1182
2350
|
{ canonical: "forecasting", aliases: [], category: "domain" },
|
|
1183
2351
|
{ canonical: "bookkeeping", aliases: [], category: "domain" },
|
|
@@ -1187,78 +2355,260 @@ var defaultKeywordRegistry = [
|
|
|
1187
2355
|
{ canonical: "auditing", aliases: ["audit"], category: "domain" },
|
|
1188
2356
|
{ canonical: "tax preparation", aliases: [], category: "domain" },
|
|
1189
2357
|
{ canonical: "gaap", aliases: [], category: "domain" },
|
|
2358
|
+
{ canonical: "ifrs", aliases: [], category: "domain" },
|
|
2359
|
+
{
|
|
2360
|
+
canonical: "reconciliation",
|
|
2361
|
+
aliases: ["account reconciliation"],
|
|
2362
|
+
category: "domain"
|
|
2363
|
+
},
|
|
2364
|
+
{ canonical: "general ledger", aliases: [], category: "domain" },
|
|
2365
|
+
{ canonical: "cost accounting", aliases: [], category: "domain" },
|
|
2366
|
+
{
|
|
2367
|
+
canonical: "mergers and acquisitions",
|
|
2368
|
+
aliases: ["m&a"],
|
|
2369
|
+
category: "domain"
|
|
2370
|
+
},
|
|
2371
|
+
{ canonical: "investment banking", aliases: [], category: "domain" },
|
|
2372
|
+
{ canonical: "portfolio management", aliases: [], category: "domain" },
|
|
2373
|
+
{ canonical: "equity research", aliases: [], category: "domain" },
|
|
2374
|
+
{
|
|
2375
|
+
canonical: "treasury management",
|
|
2376
|
+
aliases: ["treasury"],
|
|
2377
|
+
category: "domain"
|
|
2378
|
+
},
|
|
2379
|
+
{ canonical: "fintech", aliases: [], category: "domain" },
|
|
2380
|
+
{
|
|
2381
|
+
canonical: "payments processing",
|
|
2382
|
+
aliases: ["payment processing"],
|
|
2383
|
+
category: "domain"
|
|
2384
|
+
},
|
|
1190
2385
|
// sales / account management domain
|
|
1191
2386
|
{ canonical: "lead generation", aliases: [], category: "domain" },
|
|
1192
2387
|
{ canonical: "account management", aliases: [], category: "domain" },
|
|
1193
|
-
{
|
|
1194
|
-
|
|
2388
|
+
{
|
|
2389
|
+
canonical: "crm",
|
|
2390
|
+
aliases: ["customer relationship management"],
|
|
2391
|
+
category: "domain"
|
|
2392
|
+
},
|
|
2393
|
+
{
|
|
2394
|
+
canonical: "sales pipeline",
|
|
2395
|
+
aliases: ["pipeline management"],
|
|
2396
|
+
category: "domain"
|
|
2397
|
+
},
|
|
1195
2398
|
{ canonical: "cold calling", aliases: [], category: "domain" },
|
|
1196
2399
|
{ canonical: "upselling", aliases: ["cross-selling"], category: "domain" },
|
|
1197
2400
|
{ canonical: "customer retention", aliases: [], category: "domain" },
|
|
2401
|
+
{
|
|
2402
|
+
canonical: "business development",
|
|
2403
|
+
aliases: ["bizdev"],
|
|
2404
|
+
category: "domain"
|
|
2405
|
+
},
|
|
2406
|
+
{ canonical: "quota attainment", aliases: [], category: "domain" },
|
|
2407
|
+
{ canonical: "b2b sales", aliases: [], category: "domain" },
|
|
2408
|
+
{ canonical: "b2c sales", aliases: [], category: "domain" },
|
|
2409
|
+
{ canonical: "saas", aliases: ["software as a service"], category: "domain" },
|
|
2410
|
+
{ canonical: "e-commerce", aliases: ["ecommerce"], category: "domain" },
|
|
1198
2411
|
// human resources domain
|
|
1199
|
-
{
|
|
2412
|
+
{
|
|
2413
|
+
canonical: "recruiting",
|
|
2414
|
+
aliases: ["talent acquisition"],
|
|
2415
|
+
category: "domain"
|
|
2416
|
+
},
|
|
1200
2417
|
{ canonical: "onboarding", aliases: [], category: "domain" },
|
|
1201
2418
|
{ canonical: "employee relations", aliases: [], category: "domain" },
|
|
1202
2419
|
{ canonical: "benefits administration", aliases: [], category: "domain" },
|
|
1203
2420
|
{ canonical: "performance management", aliases: [], category: "domain" },
|
|
2421
|
+
{
|
|
2422
|
+
canonical: "compensation and benefits",
|
|
2423
|
+
aliases: ["comp and benefits"],
|
|
2424
|
+
category: "domain"
|
|
2425
|
+
},
|
|
2426
|
+
{
|
|
2427
|
+
canonical: "diversity and inclusion",
|
|
2428
|
+
aliases: ["dei"],
|
|
2429
|
+
category: "domain"
|
|
2430
|
+
},
|
|
2431
|
+
{ canonical: "employee engagement", aliases: [], category: "domain" },
|
|
2432
|
+
{
|
|
2433
|
+
canonical: "hris",
|
|
2434
|
+
aliases: ["human resources information system"],
|
|
2435
|
+
category: "domain"
|
|
2436
|
+
},
|
|
1204
2437
|
// healthcare domain
|
|
1205
2438
|
{ canonical: "patient care", aliases: [], category: "domain" },
|
|
1206
2439
|
{ canonical: "clinical documentation", aliases: [], category: "domain" },
|
|
1207
2440
|
{ canonical: "hipaa", aliases: [], category: "domain" },
|
|
1208
|
-
{
|
|
2441
|
+
{
|
|
2442
|
+
canonical: "electronic health records",
|
|
2443
|
+
aliases: ["ehr", "emr"],
|
|
2444
|
+
category: "domain"
|
|
2445
|
+
},
|
|
1209
2446
|
{ canonical: "medical billing", aliases: [], category: "domain" },
|
|
2447
|
+
{
|
|
2448
|
+
canonical: "medical coding",
|
|
2449
|
+
aliases: ["icd-10", "cpt coding"],
|
|
2450
|
+
category: "domain"
|
|
2451
|
+
},
|
|
2452
|
+
{ canonical: "telehealth", aliases: [], category: "domain" },
|
|
2453
|
+
{ canonical: "clinical trials", aliases: [], category: "domain" },
|
|
2454
|
+
{ canonical: "regulatory affairs", aliases: [], category: "domain" },
|
|
1210
2455
|
// legal domain
|
|
1211
2456
|
{ canonical: "contract review", aliases: [], category: "domain" },
|
|
1212
2457
|
{ canonical: "legal research", aliases: [], category: "domain" },
|
|
1213
2458
|
{ canonical: "litigation", aliases: [], category: "domain" },
|
|
1214
|
-
{
|
|
2459
|
+
{
|
|
2460
|
+
canonical: "regulatory compliance",
|
|
2461
|
+
aliases: ["compliance"],
|
|
2462
|
+
category: "domain"
|
|
2463
|
+
},
|
|
1215
2464
|
{ canonical: "due diligence", aliases: [], category: "domain" },
|
|
2465
|
+
{
|
|
2466
|
+
canonical: "intellectual property",
|
|
2467
|
+
aliases: ["ip law"],
|
|
2468
|
+
category: "domain"
|
|
2469
|
+
},
|
|
2470
|
+
{
|
|
2471
|
+
canonical: "gdpr",
|
|
2472
|
+
aliases: ["general data protection regulation"],
|
|
2473
|
+
category: "domain"
|
|
2474
|
+
},
|
|
2475
|
+
{ canonical: "soc 2", aliases: ["soc2"], category: "domain" },
|
|
2476
|
+
{ canonical: "iso 27001", aliases: [], category: "domain" },
|
|
2477
|
+
{ canonical: "pci dss", aliases: ["pci compliance"], category: "domain" },
|
|
1216
2478
|
// education domain
|
|
1217
2479
|
{ canonical: "curriculum development", aliases: [], category: "domain" },
|
|
1218
2480
|
{ canonical: "lesson planning", aliases: [], category: "domain" },
|
|
1219
2481
|
{ canonical: "classroom management", aliases: [], category: "domain" },
|
|
1220
2482
|
{ canonical: "instructional design", aliases: [], category: "domain" },
|
|
2483
|
+
{ canonical: "student assessment", aliases: [], category: "domain" },
|
|
2484
|
+
{ canonical: "e-learning", aliases: ["elearning"], category: "domain" },
|
|
1221
2485
|
// operations / supply chain domain
|
|
1222
|
-
{
|
|
2486
|
+
{
|
|
2487
|
+
canonical: "supply chain management",
|
|
2488
|
+
aliases: ["supply chain"],
|
|
2489
|
+
category: "domain"
|
|
2490
|
+
},
|
|
1223
2491
|
{ canonical: "inventory management", aliases: [], category: "domain" },
|
|
1224
2492
|
{ canonical: "procurement", aliases: [], category: "domain" },
|
|
1225
2493
|
{ canonical: "vendor management", aliases: [], category: "domain" },
|
|
1226
2494
|
{ canonical: "logistics", aliases: [], category: "domain" },
|
|
2495
|
+
{ canonical: "warehouse management", aliases: [], category: "domain" },
|
|
2496
|
+
{ canonical: "demand planning", aliases: [], category: "domain" },
|
|
2497
|
+
{ canonical: "manufacturing", aliases: [], category: "domain" },
|
|
2498
|
+
{ canonical: "quality control", aliases: [], category: "domain" },
|
|
2499
|
+
{ canonical: "lean manufacturing", aliases: [], category: "domain" },
|
|
1227
2500
|
// customer service domain
|
|
1228
|
-
{
|
|
2501
|
+
{
|
|
2502
|
+
canonical: "customer support",
|
|
2503
|
+
aliases: ["customer service"],
|
|
2504
|
+
category: "domain"
|
|
2505
|
+
},
|
|
1229
2506
|
{ canonical: "technical support", aliases: [], category: "domain" },
|
|
1230
2507
|
{ canonical: "conflict resolution", aliases: [], category: "domain" },
|
|
2508
|
+
{ canonical: "customer success", aliases: [], category: "domain" },
|
|
2509
|
+
{ canonical: "help desk", aliases: ["helpdesk"], category: "domain" },
|
|
1231
2510
|
// soft skills
|
|
1232
2511
|
{ canonical: "communication", aliases: [], category: "soft" },
|
|
2512
|
+
{ canonical: "written communication", aliases: [], category: "soft" },
|
|
2513
|
+
{ canonical: "verbal communication", aliases: [], category: "soft" },
|
|
1233
2514
|
{ canonical: "leadership", aliases: [], category: "soft" },
|
|
2515
|
+
{ canonical: "team leadership", aliases: [], category: "soft" },
|
|
1234
2516
|
{ canonical: "teamwork", aliases: ["collaboration"], category: "soft" },
|
|
1235
|
-
{
|
|
2517
|
+
{
|
|
2518
|
+
canonical: "cross-functional collaboration",
|
|
2519
|
+
aliases: ["cross functional collaboration"],
|
|
2520
|
+
category: "soft"
|
|
2521
|
+
},
|
|
2522
|
+
{
|
|
2523
|
+
canonical: "problem solving",
|
|
2524
|
+
aliases: ["problem-solving"],
|
|
2525
|
+
category: "soft"
|
|
2526
|
+
},
|
|
1236
2527
|
{ canonical: "adaptability", aliases: ["flexibility"], category: "soft" },
|
|
1237
2528
|
{ canonical: "time management", aliases: [], category: "soft" },
|
|
1238
2529
|
{ canonical: "critical thinking", aliases: [], category: "soft" },
|
|
1239
2530
|
{ canonical: "creativity", aliases: [], category: "soft" },
|
|
1240
2531
|
{ canonical: "attention to detail", aliases: [], category: "soft" },
|
|
1241
|
-
{
|
|
2532
|
+
{
|
|
2533
|
+
canonical: "decision making",
|
|
2534
|
+
aliases: ["decision-making"],
|
|
2535
|
+
category: "soft"
|
|
2536
|
+
},
|
|
1242
2537
|
{ canonical: "emotional intelligence", aliases: [], category: "soft" },
|
|
1243
2538
|
{ canonical: "negotiation", aliases: [], category: "soft" },
|
|
1244
|
-
{
|
|
1245
|
-
|
|
2539
|
+
{
|
|
2540
|
+
canonical: "organization",
|
|
2541
|
+
aliases: ["organizational skills"],
|
|
2542
|
+
category: "soft"
|
|
2543
|
+
},
|
|
2544
|
+
{
|
|
2545
|
+
canonical: "public speaking",
|
|
2546
|
+
aliases: ["presentation skills"],
|
|
2547
|
+
category: "soft"
|
|
2548
|
+
},
|
|
1246
2549
|
{ canonical: "mentoring", aliases: ["coaching"], category: "soft" },
|
|
1247
2550
|
{ canonical: "interpersonal skills", aliases: [], category: "soft" },
|
|
1248
2551
|
{ canonical: "work ethic", aliases: [], category: "soft" },
|
|
2552
|
+
{ canonical: "stakeholder communication", aliases: [], category: "soft" },
|
|
2553
|
+
{
|
|
2554
|
+
canonical: "self-motivation",
|
|
2555
|
+
aliases: ["self motivation"],
|
|
2556
|
+
category: "soft"
|
|
2557
|
+
},
|
|
2558
|
+
{ canonical: "resilience", aliases: [], category: "soft" },
|
|
2559
|
+
{ canonical: "empathy", aliases: [], category: "soft" },
|
|
2560
|
+
{ canonical: "active listening", aliases: [], category: "soft" },
|
|
2561
|
+
{ canonical: "delegation", aliases: [], category: "soft" },
|
|
2562
|
+
{ canonical: "strategic thinking", aliases: [], category: "soft" },
|
|
2563
|
+
{ canonical: "multitasking", aliases: [], category: "soft" },
|
|
1249
2564
|
// marketing
|
|
1250
|
-
{
|
|
2565
|
+
{
|
|
2566
|
+
canonical: "seo",
|
|
2567
|
+
aliases: ["search engine optimization"],
|
|
2568
|
+
category: "marketing"
|
|
2569
|
+
},
|
|
2570
|
+
{
|
|
2571
|
+
canonical: "sem",
|
|
2572
|
+
aliases: ["search engine marketing"],
|
|
2573
|
+
category: "marketing"
|
|
2574
|
+
},
|
|
1251
2575
|
{ canonical: "branding", aliases: ["brand strategy"], category: "marketing" },
|
|
1252
2576
|
{ canonical: "campaign management", aliases: [], category: "marketing" },
|
|
1253
2577
|
{ canonical: "content marketing", aliases: [], category: "marketing" },
|
|
1254
|
-
{ canonical: "
|
|
2578
|
+
{ canonical: "content strategy", aliases: [], category: "marketing" },
|
|
2579
|
+
{
|
|
2580
|
+
canonical: "social media marketing",
|
|
2581
|
+
aliases: ["social media"],
|
|
2582
|
+
category: "marketing"
|
|
2583
|
+
},
|
|
1255
2584
|
{ canonical: "email marketing", aliases: [], category: "marketing" },
|
|
1256
2585
|
{ canonical: "digital marketing", aliases: [], category: "marketing" },
|
|
2586
|
+
{
|
|
2587
|
+
canonical: "growth marketing",
|
|
2588
|
+
aliases: ["growth hacking"],
|
|
2589
|
+
category: "marketing"
|
|
2590
|
+
},
|
|
1257
2591
|
{ canonical: "copywriting", aliases: [], category: "marketing" },
|
|
1258
2592
|
{ canonical: "market research", aliases: [], category: "marketing" },
|
|
1259
|
-
{
|
|
1260
|
-
|
|
1261
|
-
|
|
2593
|
+
{
|
|
2594
|
+
canonical: "ppc",
|
|
2595
|
+
aliases: ["pay-per-click", "google ads"],
|
|
2596
|
+
category: "marketing"
|
|
2597
|
+
},
|
|
2598
|
+
{
|
|
2599
|
+
canonical: "conversion rate optimization",
|
|
2600
|
+
aliases: ["cro"],
|
|
2601
|
+
category: "marketing"
|
|
2602
|
+
},
|
|
2603
|
+
{ canonical: "public relations", aliases: ["pr"], category: "marketing" },
|
|
2604
|
+
{ canonical: "marketing automation", aliases: [], category: "marketing" },
|
|
2605
|
+
{ canonical: "affiliate marketing", aliases: [], category: "marketing" },
|
|
2606
|
+
{ canonical: "influencer marketing", aliases: [], category: "marketing" },
|
|
2607
|
+
{ canonical: "product marketing", aliases: [], category: "marketing" },
|
|
2608
|
+
{ canonical: "event marketing", aliases: [], category: "marketing" },
|
|
2609
|
+
{ canonical: "brand awareness", aliases: [], category: "marketing" },
|
|
2610
|
+
{ canonical: "media planning", aliases: [], category: "marketing" },
|
|
2611
|
+
{ canonical: "lead nurturing", aliases: [], category: "marketing" }
|
|
1262
2612
|
];
|
|
1263
2613
|
var defaultSkillAliases = deriveSkillAliases(defaultKeywordRegistry);
|
|
1264
2614
|
var softwareEngineerProfile = {
|
|
@@ -1287,10 +2637,11 @@ var defaultProfiles = [
|
|
|
1287
2637
|
|
|
1288
2638
|
// src/core/scoring/weights.ts
|
|
1289
2639
|
var DEFAULT_WEIGHTS = {
|
|
1290
|
-
skills: 0.
|
|
1291
|
-
experience: 0.
|
|
2640
|
+
skills: 0.25,
|
|
2641
|
+
experience: 0.2,
|
|
1292
2642
|
keywords: 0.25,
|
|
1293
|
-
|
|
2643
|
+
parseability: 0.2,
|
|
2644
|
+
education: 0.1
|
|
1294
2645
|
};
|
|
1295
2646
|
var DEFAULT_KEYWORD_DENSITY = {
|
|
1296
2647
|
min: 25e-4,
|
|
@@ -1301,16 +2652,20 @@ var DEFAULT_SECTION_PENALTIES = {
|
|
|
1301
2652
|
missingSummary: 4,
|
|
1302
2653
|
missingExperience: 10,
|
|
1303
2654
|
missingSkills: 8,
|
|
1304
|
-
missingEducation: 6
|
|
2655
|
+
missingEducation: 6,
|
|
2656
|
+
// Raised from 0 (warning-only) in v1 — a real ATS treats an unparseable contact email as a
|
|
2657
|
+
// near-knockout (no way to reach the candidate at all), so this now actually moves the score.
|
|
2658
|
+
missingContact: 12
|
|
1305
2659
|
};
|
|
1306
2660
|
function normalizeWeights(weights) {
|
|
1307
|
-
const total = weights.skills + weights.experience + weights.keywords + weights.education;
|
|
2661
|
+
const total = weights.skills + weights.experience + weights.keywords + weights.parseability + weights.education;
|
|
1308
2662
|
if (total === 0) {
|
|
1309
|
-
const equal = 1 /
|
|
2663
|
+
const equal = 1 / 5;
|
|
1310
2664
|
return {
|
|
1311
2665
|
skills: equal,
|
|
1312
2666
|
experience: equal,
|
|
1313
2667
|
keywords: equal,
|
|
2668
|
+
parseability: equal,
|
|
1314
2669
|
education: equal,
|
|
1315
2670
|
normalizedTotal: 1
|
|
1316
2671
|
};
|
|
@@ -1319,6 +2674,7 @@ function normalizeWeights(weights) {
|
|
|
1319
2674
|
skills: weights.skills / total,
|
|
1320
2675
|
experience: weights.experience / total,
|
|
1321
2676
|
keywords: weights.keywords / total,
|
|
2677
|
+
parseability: weights.parseability / total,
|
|
1322
2678
|
education: weights.education / total,
|
|
1323
2679
|
normalizedTotal: 1
|
|
1324
2680
|
};
|
|
@@ -1328,12 +2684,19 @@ function resolveConfig(config = {}) {
|
|
|
1328
2684
|
skills: config.weights?.skills ?? DEFAULT_WEIGHTS.skills,
|
|
1329
2685
|
experience: config.weights?.experience ?? DEFAULT_WEIGHTS.experience,
|
|
1330
2686
|
keywords: config.weights?.keywords ?? DEFAULT_WEIGHTS.keywords,
|
|
2687
|
+
parseability: config.weights?.parseability ?? DEFAULT_WEIGHTS.parseability,
|
|
1331
2688
|
education: config.weights?.education ?? DEFAULT_WEIGHTS.education
|
|
1332
2689
|
};
|
|
1333
|
-
const keywordRegistry = mergeKeywordRegistries(
|
|
2690
|
+
const keywordRegistry = mergeKeywordRegistries(
|
|
2691
|
+
defaultKeywordRegistry,
|
|
2692
|
+
config.keywordRegistry ?? []
|
|
2693
|
+
);
|
|
1334
2694
|
const resolved = {
|
|
1335
2695
|
weights: normalizeWeights(weights),
|
|
1336
|
-
skillAliases: {
|
|
2696
|
+
skillAliases: {
|
|
2697
|
+
...deriveSkillAliases(keywordRegistry),
|
|
2698
|
+
...config.skillAliases ?? {}
|
|
2699
|
+
},
|
|
1337
2700
|
keywordRegistry,
|
|
1338
2701
|
categoryIndex: buildCategoryIndex(keywordRegistry),
|
|
1339
2702
|
profile: config.profile ?? softwareEngineerProfile,
|
|
@@ -1344,6 +2707,13 @@ function resolveConfig(config = {}) {
|
|
|
1344
2707
|
...config.sectionPenalties ?? {}
|
|
1345
2708
|
},
|
|
1346
2709
|
allowPartialMatches: config.allowPartialMatches ?? true,
|
|
2710
|
+
// Fuzzy/stem matching is on by default in v2 (typos, word-form variants like "developing"
|
|
2711
|
+
// vs "develop", "ReactJS" vs "react") — callers who want v1's exact-match-only behavior can
|
|
2712
|
+
// override via config.matching = { fuzzy: false }.
|
|
2713
|
+
matching: {
|
|
2714
|
+
fuzzy: config.matching?.fuzzy ?? true,
|
|
2715
|
+
threshold: config.matching?.threshold
|
|
2716
|
+
},
|
|
1347
2717
|
referenceDate: config.referenceDate ? new Date(config.referenceDate) : void 0
|
|
1348
2718
|
};
|
|
1349
2719
|
return resolved;
|
|
@@ -1356,13 +2726,17 @@ function formatList(values, max = 6) {
|
|
|
1356
2726
|
return trimmed.join(", ") + (uniqueValues.length > max ? "..." : "");
|
|
1357
2727
|
}
|
|
1358
2728
|
function buildAliasReplacementSuggestions(resume, job, config) {
|
|
1359
|
-
const jobKeywordSet = new Set(
|
|
2729
|
+
const jobKeywordSet = new Set(
|
|
2730
|
+
job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
|
|
2731
|
+
);
|
|
1360
2732
|
const replacements = [];
|
|
1361
2733
|
for (const token of unique(tokenize(resume.normalizedText))) {
|
|
1362
2734
|
const canonical = normalizeSkill(token, config.skillAliases);
|
|
1363
2735
|
const jdSurface = job.keywordSurfaceForms[canonical];
|
|
1364
2736
|
if (jdSurface && jobKeywordSet.has(canonical) && jdSurface.toLowerCase() !== token.toLowerCase()) {
|
|
1365
|
-
replacements.push(
|
|
2737
|
+
replacements.push(
|
|
2738
|
+
`Replace "${token}" with "${jdSurface}" to match the job description's wording.`
|
|
2739
|
+
);
|
|
1366
2740
|
}
|
|
1367
2741
|
}
|
|
1368
2742
|
return unique(replacements).slice(0, 5);
|
|
@@ -1370,7 +2744,10 @@ function buildAliasReplacementSuggestions(resume, job, config) {
|
|
|
1370
2744
|
var SuggestionEngine = class {
|
|
1371
2745
|
generate(input) {
|
|
1372
2746
|
const suggestions = [];
|
|
1373
|
-
const warnings = [
|
|
2747
|
+
const warnings = [
|
|
2748
|
+
...input.ruleWarnings,
|
|
2749
|
+
...input.resume.warnings
|
|
2750
|
+
];
|
|
1374
2751
|
if (input.score.missingSkills.length > 0) {
|
|
1375
2752
|
suggestions.push(
|
|
1376
2753
|
`Highlight these required skills: ${formatList(input.score.missingSkills)}`
|
|
@@ -1381,7 +2758,9 @@ var SuggestionEngine = class {
|
|
|
1381
2758
|
`Incorporate job-specific keywords: ${formatList(input.score.missingKeywords)}`
|
|
1382
2759
|
);
|
|
1383
2760
|
}
|
|
1384
|
-
suggestions.push(
|
|
2761
|
+
suggestions.push(
|
|
2762
|
+
...buildAliasReplacementSuggestions(input.resume, input.job, input.config)
|
|
2763
|
+
);
|
|
1385
2764
|
if (input.score.overusedKeywords.length > 0) {
|
|
1386
2765
|
suggestions.push(
|
|
1387
2766
|
`Avoid keyword stuffing for: ${formatList(input.score.overusedKeywords)}`
|
|
@@ -1392,7 +2771,7 @@ var SuggestionEngine = class {
|
|
|
1392
2771
|
`Clarify at least ${input.job.minExperienceYears ?? input.score.missingExperienceYears} years of relevant experience with quantified achievements.`
|
|
1393
2772
|
);
|
|
1394
2773
|
}
|
|
1395
|
-
if (input.job.educationRequirements.length > 0 && input.score.educationScore
|
|
2774
|
+
if (input.job.educationRequirements.length > 0 && input.score.educationScore < 60) {
|
|
1396
2775
|
suggestions.push(
|
|
1397
2776
|
`State your education credentials matching: ${formatList(input.job.educationRequirements)}`
|
|
1398
2777
|
);
|
|
@@ -1411,7 +2790,9 @@ var SuggestionEngine = class {
|
|
|
1411
2790
|
const formatted = input.score.missingLanguages.map((l) => l.level ? `${l.name} (${l.level})` : l.name).join(", ");
|
|
1412
2791
|
suggestions.push(`Mention your proficiency in: ${formatted}`);
|
|
1413
2792
|
}
|
|
1414
|
-
const weakAchievement = input.resume.achievements.find(
|
|
2793
|
+
const weakAchievement = input.resume.achievements.find(
|
|
2794
|
+
(a) => a.strength === "weak"
|
|
2795
|
+
);
|
|
1415
2796
|
if (weakAchievement) {
|
|
1416
2797
|
suggestions.push(
|
|
1417
2798
|
`Strengthen "${weakAchievement.text}" \u2014 add scope/metrics, e.g. "Built and maintained scalable services handling 500k+ requests/day."`
|
|
@@ -1422,6 +2803,34 @@ var SuggestionEngine = class {
|
|
|
1422
2803
|
"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."
|
|
1423
2804
|
);
|
|
1424
2805
|
}
|
|
2806
|
+
if (!input.resume.contact?.email) {
|
|
2807
|
+
suggestions.push(
|
|
2808
|
+
"Add a clearly formatted email address near the top of your resume so ATS and recruiters can contact you."
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
for (const gap of input.score.skillExperienceGaps) {
|
|
2812
|
+
suggestions.push(
|
|
2813
|
+
`The role asks for ${gap.requiredYears}+ years of ${gap.skill}; make that duration explicit in your experience section.`
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
if (input.score.breakdown.parseability < 80) {
|
|
2817
|
+
for (const deduction of input.score.parseabilityReport.deductions) {
|
|
2818
|
+
suggestions.push(`Improve resume parseability: ${deduction.reason}.`);
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
const GAP_THRESHOLD_MONTHS = 6;
|
|
2822
|
+
for (const gap of input.score.employmentGaps) {
|
|
2823
|
+
if (gap.months >= GAP_THRESHOLD_MONTHS) {
|
|
2824
|
+
suggestions.push(
|
|
2825
|
+
`Address the ${gap.months}-month employment gap after "${gap.afterRole}" \u2014 a brief explanation (upskilling, caregiving, freelance work, etc.) reassures recruiters.`
|
|
2826
|
+
);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
if (!input.score.seniorityMatch.met) {
|
|
2830
|
+
suggestions.push(
|
|
2831
|
+
`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.`
|
|
2832
|
+
);
|
|
2833
|
+
}
|
|
1425
2834
|
return { suggestions, warnings };
|
|
1426
2835
|
}
|
|
1427
2836
|
};
|
|
@@ -1469,7 +2878,10 @@ var LLMBudgetManager = class {
|
|
|
1469
2878
|
callsUsed: this.callCount,
|
|
1470
2879
|
callsRemaining: Math.max(0, this.limits.maxCalls - this.callCount),
|
|
1471
2880
|
tokensUsed: this.totalTokensUsed,
|
|
1472
|
-
tokensRemaining: Math.max(
|
|
2881
|
+
tokensRemaining: Math.max(
|
|
2882
|
+
0,
|
|
2883
|
+
this.limits.maxTotalTokens - this.totalTokensUsed
|
|
2884
|
+
),
|
|
1473
2885
|
totalCalls: this.limits.maxCalls,
|
|
1474
2886
|
totalTokens: this.limits.maxTotalTokens
|
|
1475
2887
|
};
|
|
@@ -1491,7 +2903,8 @@ var LLMBudgetManager = class {
|
|
|
1491
2903
|
|
|
1492
2904
|
// src/llm/validation.ts
|
|
1493
2905
|
function validateJsonSchema(data, schema) {
|
|
1494
|
-
if (schema.type !== "object" || typeof data !== "object" || data === null)
|
|
2906
|
+
if (schema.type !== "object" || typeof data !== "object" || data === null)
|
|
2907
|
+
return false;
|
|
1495
2908
|
const obj = data;
|
|
1496
2909
|
if (schema.required) {
|
|
1497
2910
|
for (const r of schema.required) {
|
|
@@ -1521,9 +2934,12 @@ function validateJsonSchema(data, schema) {
|
|
|
1521
2934
|
if (items && items.type && Array.isArray(value)) {
|
|
1522
2935
|
const itemType = items.type;
|
|
1523
2936
|
for (const item of value) {
|
|
1524
|
-
if (itemType === "string" && typeof item !== "string")
|
|
1525
|
-
|
|
1526
|
-
if (itemType === "
|
|
2937
|
+
if (itemType === "string" && typeof item !== "string")
|
|
2938
|
+
return false;
|
|
2939
|
+
if (itemType === "number" && typeof item !== "number")
|
|
2940
|
+
return false;
|
|
2941
|
+
if (itemType === "object" && (typeof item !== "object" || item === null))
|
|
2942
|
+
return false;
|
|
1527
2943
|
}
|
|
1528
2944
|
}
|
|
1529
2945
|
break;
|
|
@@ -1550,24 +2966,36 @@ var LLMManager = class {
|
|
|
1550
2966
|
*/
|
|
1551
2967
|
async callLLM(systemPrompt, userPrompt, schema, options = {}) {
|
|
1552
2968
|
const onUnhandled = (reason) => {
|
|
1553
|
-
this.warnings.push(
|
|
2969
|
+
this.warnings.push(
|
|
2970
|
+
`Unhandled rejection during LLM call: ${String(reason)}`
|
|
2971
|
+
);
|
|
1554
2972
|
};
|
|
1555
2973
|
process.on("unhandledRejection", onUnhandled);
|
|
1556
2974
|
let clientPromise = void 0;
|
|
1557
2975
|
try {
|
|
1558
|
-
const estimatedTokens = this.estimateTokens(
|
|
2976
|
+
const estimatedTokens = this.estimateTokens(
|
|
2977
|
+
systemPrompt,
|
|
2978
|
+
userPrompt,
|
|
2979
|
+
options.requestedTokens
|
|
2980
|
+
);
|
|
1559
2981
|
try {
|
|
1560
2982
|
this.budgetManager.assertCanCall(estimatedTokens);
|
|
1561
2983
|
} catch (e) {
|
|
1562
2984
|
const msg = `LLM budget exhausted: ${e.message}`;
|
|
1563
2985
|
this.warnings.push(msg);
|
|
1564
|
-
globalThis.setTimeout(
|
|
2986
|
+
globalThis.setTimeout(
|
|
2987
|
+
() => process.removeListener("unhandledRejection", onUnhandled),
|
|
2988
|
+
100
|
|
2989
|
+
);
|
|
1565
2990
|
return { success: false, fallback: true, error: msg };
|
|
1566
2991
|
}
|
|
1567
2992
|
if (!this.isValidJsonSchema(schema)) {
|
|
1568
2993
|
const msg = "Invalid JSON schema provided";
|
|
1569
2994
|
this.warnings.push(msg);
|
|
1570
|
-
globalThis.setTimeout(
|
|
2995
|
+
globalThis.setTimeout(
|
|
2996
|
+
() => process.removeListener("unhandledRejection", onUnhandled),
|
|
2997
|
+
100
|
|
2998
|
+
);
|
|
1571
2999
|
return { success: false, fallback: true, error: msg };
|
|
1572
3000
|
}
|
|
1573
3001
|
const strictUserPrompt = `${userPrompt}
|
|
@@ -1586,22 +3014,36 @@ No explanations. No markdown. No additional text.`;
|
|
|
1586
3014
|
void clientPromise.catch(() => {
|
|
1587
3015
|
});
|
|
1588
3016
|
const timeoutPromise = new Promise((_, reject) => {
|
|
1589
|
-
const id = globalThis.setTimeout(
|
|
1590
|
-
|
|
3017
|
+
const id = globalThis.setTimeout(
|
|
3018
|
+
() => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)),
|
|
3019
|
+
this.timeoutMs
|
|
3020
|
+
);
|
|
3021
|
+
void clientPromise.finally(() => globalThis.clearTimeout(id)).catch(() => {
|
|
3022
|
+
});
|
|
1591
3023
|
});
|
|
1592
3024
|
void timeoutPromise.catch(() => {
|
|
1593
3025
|
});
|
|
1594
|
-
const response = await Promise.race([
|
|
3026
|
+
const response = await Promise.race([
|
|
3027
|
+
clientPromise,
|
|
3028
|
+
timeoutPromise
|
|
3029
|
+
]);
|
|
1595
3030
|
if (!response || !response.content) {
|
|
1596
3031
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1597
3032
|
try {
|
|
1598
|
-
await Promise.race([
|
|
1599
|
-
|
|
1600
|
-
|
|
3033
|
+
await Promise.race([
|
|
3034
|
+
clientPromise.catch(() => {
|
|
3035
|
+
}),
|
|
3036
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
3037
|
+
]);
|
|
3038
|
+
} catch {
|
|
1601
3039
|
} finally {
|
|
1602
3040
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1603
3041
|
}
|
|
1604
|
-
return {
|
|
3042
|
+
return {
|
|
3043
|
+
success: false,
|
|
3044
|
+
fallback: true,
|
|
3045
|
+
error: "Empty response from LLM"
|
|
3046
|
+
};
|
|
1605
3047
|
}
|
|
1606
3048
|
let parsedContent;
|
|
1607
3049
|
try {
|
|
@@ -1615,9 +3057,12 @@ No explanations. No markdown. No additional text.`;
|
|
|
1615
3057
|
this.warnings.push(msg);
|
|
1616
3058
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1617
3059
|
try {
|
|
1618
|
-
await Promise.race([
|
|
1619
|
-
|
|
1620
|
-
|
|
3060
|
+
await Promise.race([
|
|
3061
|
+
clientPromise.catch(() => {
|
|
3062
|
+
}),
|
|
3063
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
3064
|
+
]);
|
|
3065
|
+
} catch {
|
|
1621
3066
|
} finally {
|
|
1622
3067
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1623
3068
|
}
|
|
@@ -1628,9 +3073,12 @@ No explanations. No markdown. No additional text.`;
|
|
|
1628
3073
|
this.warnings.push(msg);
|
|
1629
3074
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1630
3075
|
try {
|
|
1631
|
-
await Promise.race([
|
|
1632
|
-
|
|
1633
|
-
|
|
3076
|
+
await Promise.race([
|
|
3077
|
+
clientPromise.catch(() => {
|
|
3078
|
+
}),
|
|
3079
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
3080
|
+
]);
|
|
3081
|
+
} catch {
|
|
1634
3082
|
} finally {
|
|
1635
3083
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1636
3084
|
}
|
|
@@ -1650,9 +3098,12 @@ No explanations. No markdown. No additional text.`;
|
|
|
1650
3098
|
this.warnings.push(msg);
|
|
1651
3099
|
const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
|
|
1652
3100
|
try {
|
|
1653
|
-
await Promise.race([
|
|
1654
|
-
|
|
1655
|
-
|
|
3101
|
+
await Promise.race([
|
|
3102
|
+
clientPromise.catch(() => {
|
|
3103
|
+
}),
|
|
3104
|
+
new Promise((r) => setTimeout(r, graceMs))
|
|
3105
|
+
]);
|
|
3106
|
+
} catch {
|
|
1656
3107
|
} finally {
|
|
1657
3108
|
process.removeListener("unhandledRejection", onUnhandled);
|
|
1658
3109
|
}
|
|
@@ -1702,7 +3153,7 @@ No explanations. No markdown. No additional text.`;
|
|
|
1702
3153
|
validateAgainstSchema(data, schema) {
|
|
1703
3154
|
try {
|
|
1704
3155
|
return validateJsonSchema(data, schema);
|
|
1705
|
-
} catch
|
|
3156
|
+
} catch {
|
|
1706
3157
|
if (typeof data !== "object" || data === null) {
|
|
1707
3158
|
return false;
|
|
1708
3159
|
}
|
|
@@ -1762,7 +3213,15 @@ var sectionClassificationSchema = {
|
|
|
1762
3213
|
},
|
|
1763
3214
|
classification: {
|
|
1764
3215
|
type: "string",
|
|
1765
|
-
enum: [
|
|
3216
|
+
enum: [
|
|
3217
|
+
"summary",
|
|
3218
|
+
"experience",
|
|
3219
|
+
"skills",
|
|
3220
|
+
"education",
|
|
3221
|
+
"projects",
|
|
3222
|
+
"certifications",
|
|
3223
|
+
"other"
|
|
3224
|
+
],
|
|
1766
3225
|
description: "Classified section type"
|
|
1767
3226
|
},
|
|
1768
3227
|
confidence: {
|
|
@@ -2065,7 +3524,10 @@ function analyzeResume(input) {
|
|
|
2065
3524
|
let suggestions = suggestionResult.suggestions;
|
|
2066
3525
|
const llmWarnings = [];
|
|
2067
3526
|
if (input.llm && suggestionResult.suggestions.length > 0) {
|
|
2068
|
-
const llmResult = enhanceSuggestionsWithLLM(
|
|
3527
|
+
const llmResult = enhanceSuggestionsWithLLM(
|
|
3528
|
+
input.llm,
|
|
3529
|
+
suggestionResult.suggestions
|
|
3530
|
+
);
|
|
2069
3531
|
if (llmResult.success) {
|
|
2070
3532
|
suggestions = llmResult.enhancedSuggestions || suggestions;
|
|
2071
3533
|
}
|
|
@@ -2085,21 +3547,25 @@ function analyzeResume(input) {
|
|
|
2085
3547
|
achievementStrength: scoring.achievementStrength,
|
|
2086
3548
|
matchedLanguages: scoring.matchedLanguages,
|
|
2087
3549
|
missingLanguages: scoring.missingLanguages,
|
|
3550
|
+
skillExperienceGaps: scoring.skillExperienceGaps,
|
|
2088
3551
|
experienceGap: scoring.experienceGap,
|
|
2089
3552
|
detectedSections: parsedResume.detectedSections,
|
|
2090
3553
|
parsedExperienceYears: parsedResume.totalExperienceYears,
|
|
2091
3554
|
experienceEntries: parsedResume.experience,
|
|
3555
|
+
parseabilityReport: scoring.parseabilityReport,
|
|
3556
|
+
employmentGaps: scoring.employmentGaps,
|
|
3557
|
+
seniorityMatch: scoring.seniorityMatch,
|
|
3558
|
+
perSkillExperience: scoring.perSkillExperience,
|
|
2092
3559
|
suggestions,
|
|
2093
3560
|
warnings: [...suggestionResult.warnings, ...llmWarnings]
|
|
2094
3561
|
};
|
|
2095
3562
|
}
|
|
2096
|
-
function enhanceSuggestionsWithLLM(config,
|
|
3563
|
+
function enhanceSuggestionsWithLLM(config, _suggestions) {
|
|
2097
3564
|
if (!config.enable?.suggestions) {
|
|
2098
3565
|
return { success: false, warnings: [] };
|
|
2099
3566
|
}
|
|
2100
3567
|
const warnings = [];
|
|
2101
3568
|
try {
|
|
2102
|
-
const llmManager = new LLMManager(config);
|
|
2103
3569
|
warnings.push(
|
|
2104
3570
|
"LLM suggestion enhancement skipped - use async analyzeResumeAsync for LLM features"
|
|
2105
3571
|
);
|
|
@@ -2156,10 +3622,15 @@ async function analyzeResumeAsync(input) {
|
|
|
2156
3622
|
achievementStrength: scoring.achievementStrength,
|
|
2157
3623
|
matchedLanguages: scoring.matchedLanguages,
|
|
2158
3624
|
missingLanguages: scoring.missingLanguages,
|
|
3625
|
+
skillExperienceGaps: scoring.skillExperienceGaps,
|
|
2159
3626
|
experienceGap: scoring.experienceGap,
|
|
2160
3627
|
detectedSections: parsedResume.detectedSections,
|
|
2161
3628
|
parsedExperienceYears: parsedResume.totalExperienceYears,
|
|
2162
3629
|
experienceEntries: parsedResume.experience,
|
|
3630
|
+
parseabilityReport: scoring.parseabilityReport,
|
|
3631
|
+
employmentGaps: scoring.employmentGaps,
|
|
3632
|
+
seniorityMatch: scoring.seniorityMatch,
|
|
3633
|
+
perSkillExperience: scoring.perSkillExperience,
|
|
2163
3634
|
suggestions,
|
|
2164
3635
|
warnings: [...suggestionResult.warnings, ...llmWarnings]
|
|
2165
3636
|
};
|
|
@@ -2181,13 +3652,19 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
|
|
|
2181
3652
|
if (result.error) {
|
|
2182
3653
|
warnings.push(`LLM suggestion enhancement failed: ${result.error}`);
|
|
2183
3654
|
}
|
|
2184
|
-
return {
|
|
3655
|
+
return {
|
|
3656
|
+
success: false,
|
|
3657
|
+
warnings: [...warnings, ...llmManager.getWarnings()]
|
|
3658
|
+
};
|
|
2185
3659
|
}
|
|
2186
3660
|
const enhanced = adaptSuggestionEnhancementResponse(result.data);
|
|
2187
3661
|
const enhancedSuggestions = enhanced.filter((e) => e.actionable !== false).map((e) => e.enhanced);
|
|
2188
3662
|
if (enhancedSuggestions.length === 0) {
|
|
2189
3663
|
warnings.push("LLM returned no actionable enhanced suggestions");
|
|
2190
|
-
return {
|
|
3664
|
+
return {
|
|
3665
|
+
success: false,
|
|
3666
|
+
warnings: [...warnings, ...llmManager.getWarnings()]
|
|
3667
|
+
};
|
|
2191
3668
|
}
|
|
2192
3669
|
return {
|
|
2193
3670
|
success: true,
|
|
@@ -2195,7 +3672,9 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
|
|
|
2195
3672
|
warnings: llmManager.getWarnings()
|
|
2196
3673
|
};
|
|
2197
3674
|
} catch (e) {
|
|
2198
|
-
warnings.push(
|
|
3675
|
+
warnings.push(
|
|
3676
|
+
`Unexpected error in LLM enhancement: ${e.message}`
|
|
3677
|
+
);
|
|
2199
3678
|
return { success: false, warnings };
|
|
2200
3679
|
}
|
|
2201
3680
|
}
|